Merge pull request #1239 from Hestia-Homes/feature/per-cert-mapper-validation

Feature/per cert mapper validation
This commit is contained in:
KhalimCK 2026-06-16 21:22:09 +08:00 committed by GitHub
commit e1e570fdc7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1138 additions and 18 deletions

View file

@ -290,6 +290,10 @@ class ElmhurstSiteNotesExtractor:
party_wall_type=self._local_str(lines, "Party Wall Type"),
thickness_mm=thickness_mm,
insulation_thickness_mm=insulation_thickness_mm,
# Summary §7 "Dry-lining: Yes/No" on the main/extension wall.
# RdSAP 10 §5.8 + Table 14 dry-lining R=0.17 adjustment. The
# alt-wall path reads its own "Alternative Wall N Dry-lining".
dry_lined=self._local_bool(lines, "Dry-lining"),
alternative_walls=self._alternative_walls_from_lines(lines),
# Summary §7 lodges the per-BP "Curtain Wall Age" line only
# when `Type: CW Curtain Wall`. Per RdSAP 10 §5.18 (PDF
@ -548,6 +552,20 @@ class ElmhurstSiteNotesExtractor:
if self._is_next_rir_row(lines[j]):
break
tokens.append(lines[j])
# Every RIR row ends with [default_u, "Yes"/"No", u_value]; the
# "Yes"/"No" is the unique u_value_known marker (gable types and
# insulation cells never take that value). Stop once we've
# appended that flag plus the trailing u_value numeric so the
# LAST surface row (no next-row name to bound it) does not
# over-read into the following section and shift the trailing
# token slotting — which silently zeroed Common Wall 2's
# default_u (case 43: 1.90 -> 0.00).
if (
len(tokens) >= 2
and tokens[-2] in ("Yes", "No")
and self._RIR_NUMERIC_RE.match(tokens[-1])
):
break
# First two numerics = length, height
length = float(tokens[0]) if tokens and self._RIR_NUMERIC_RE.match(tokens[0]) else 0.0
height = float(tokens[1]) if len(tokens) > 1 and self._RIR_NUMERIC_RE.match(tokens[1]) else 0.0
@ -698,6 +716,7 @@ class ElmhurstSiteNotesExtractor:
party_wall_type=ext_party_wall_type,
thickness_mm=main_walls.thickness_mm,
insulation_thickness_mm=main_walls.insulation_thickness_mm,
dry_lined=main_walls.dry_lined,
alternative_walls=self._alternative_walls_from_lines(wall_lines),
)
else:
@ -1528,6 +1547,18 @@ class ElmhurstSiteNotesExtractor:
first = cylinder_ins_thickness_raw.split()[0]
if first.isdigit():
cylinder_insulation_thickness_mm = int(first)
# §15.1 "Cylinder Volume (l)" — the measured volume lodged alongside
# a "Value known" Cylinder Size. The value is written as a decimal
# ("117.00"); take the integer part for the cascade's measured-volume
# field (gov-API "Exact" descriptor, code 6).
cylinder_volume_raw = self._local_val(cylinder_lines, "Cylinder Volume (l)")
cylinder_volume_measured_l: Optional[int] = None
if cylinder_volume_raw:
first = cylinder_volume_raw.split()[0]
try:
cylinder_volume_measured_l = int(float(first))
except ValueError:
cylinder_volume_measured_l = None
cylinder_thermostat_raw = self._local_val(
cylinder_lines, "Cylinder Thermostat",
)
@ -1560,6 +1591,7 @@ class ElmhurstSiteNotesExtractor:
cylinder_size_label=cylinder_size_label,
cylinder_insulation_label=cylinder_insulation_label,
cylinder_insulation_thickness_mm=cylinder_insulation_thickness_mm,
cylinder_volume_measured_l=cylinder_volume_measured_l,
cylinder_thermostat=cylinder_thermostat,
immersion_type=immersion_type,
)

Binary file not shown.

Binary file not shown.

View file

@ -99,6 +99,9 @@ _SUMMARY_000565_PDF = _FIXTURES / "Summary_000565.pdf" # cert 000565 (5-bp Elmh
_SUMMARY_001431_CASE20_PDF = _FIXTURES / "Summary_001431_case20.pdf" # sim case 20 (storage heaters + RR type-2 + wrapped "Double between 2002 and 2021" glazing)
_SUMMARY_001431_TOPFLOOR_PDF = _FIXTURES / "Summary_001431_topfloor_flat.pdf" # gas-boiler-upgrade recommendation "after" — top-floor flat, PS sloping roof; exercises the Date-Built age-band + flat-position layout regressions
_SUMMARY_001431_LPG_PDF = _FIXTURES / "Summary_001431_lpg_boiler.pdf" # lpg-boiler recommendation "before" — §14 SAP code 115, §15 "Bottled gas"; exercises the bottled-LPG main-fuel mapping
_SUMMARY_001431_CASE41_RAFTERS_PDF = _FIXTURES / "Summary_001431_case41_rafters.pdf" # sim case 41 — 4-bp roof: Main joists 200mm, Ext1 rafters 200mm, Ext2 joists unknown, Ext3 rafters As Built (RdSAP 10 §5.11.2 Table 16 col 2 + Table 18 col 2)
_SUMMARY_001431_CASE42_50MM_RAFTERS_PDF = _FIXTURES / "Summary_001431_case42_50mm_rafters.pdf" # sim case 42 — single-bp roof: rafters 50mm (Table 16 col 2 → 0.88)
_SUMMARY_001431_CASE42_UNKNOWN_RAFTERS_PDF = _FIXTURES / "Summary_001431_case42_unknown_rafters.pdf" # sim case 42 — single-bp roof: rafters unknown thickness (Table 18 col 2 band C → 2.30)
# GOV.UK EPB API JSON for cert 001479 — the API-path counterpart of the
# Summary_001479.pdf fixture. Together they drive the API ≡ Summary
@ -178,6 +181,69 @@ def test_summary_001431_case20_fabric_heat_loss_matches_worksheet_line_33() -> N
assert abs(ht.fabric_heat_loss_w_per_k - 285.9847) <= 1e-4
def test_summary_001431_case41_roof_drives_rafters_column_per_part() -> None:
# Arrange — sim case 41's 4 building parts exercise both RdSAP 10 roof
# columns per-part (RdSAP 10 §5.11.2 Table 16 + §5.11 Table 18,
# PDF p.42-45). The P960 §3 line (30) "External roof" A×U per part:
# Main joists 200mm → 0.21 × 59.5 = 12.4950 (Table 16 col 1)
# Ext1 rafters 200mm → 0.29 × 10.0 = 2.9000 (Table 16 col 2)
# Ext2 joists unknown→ 0.40 × 10.0 = 4.0000 (Table 18 col 1, band E)
# Ext3 rafters AsBlt → 0.68 × 8.0 = 5.4400 (Table 18 col 2, band F)
# Total (sum of (30)) = 24.8350 W/K. Before the rafters column the two
# rafter parts were mis-billed at the joists U (Ext1 0.21, Ext3 0.40).
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_001431_CASE41_RAFTERS_PDF)
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(
ElmhurstSiteNotesExtractor(pages).extract()
)
# Act
ht = heat_transmission_section_from_cert(epc)
# Assert
assert abs(ht.roof_w_per_k - 24.8350) <= 1e-4
def test_summary_001431_case42_rafters_50mm_uses_table16_column_2() -> None:
# Arrange — sim case 42's single-bp roof lodged "R Rafters" + 50 mm.
# RdSAP 10 §5.11.2 Table 16 (PDF p.43) column (2) "insulation at
# rafters" 50 mm → U=0.88 (vs the joists column (1) 0.68). P960 §3 (30)
# = 0.88 × 59.5 = 52.3600 W/K.
pages = _summary_pdf_to_textract_style_pages(
_SUMMARY_001431_CASE42_50MM_RAFTERS_PDF
)
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(
ElmhurstSiteNotesExtractor(pages).extract()
)
# Act
ht = heat_transmission_section_from_cert(epc)
# Assert
assert abs(ht.roof_w_per_k - 52.3600) <= 1e-4
def test_summary_001431_case42_rafters_unknown_uses_table18_column_2() -> None:
# Arrange — sim case 42's single-bp roof lodged "R Rafters" with an
# unknown thickness, age band C. RdSAP 10 §5.11 Table 18 (PDF p.45)
# column (2) "insulation at rafters" applies "for unknown and as built"
# (footnote 1) → band A-D = 2.30 (NOT the joists column (1) 100 mm
# default 0.40, which only applies to the "between joists or unknown"
# column). Worksheet-confirmed by the case-42 variant set. P960 §3 (30)
# = 2.30 × 59.5 = 136.8500 W/K.
pages = _summary_pdf_to_textract_style_pages(
_SUMMARY_001431_CASE42_UNKNOWN_RAFTERS_PDF
)
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(
ElmhurstSiteNotesExtractor(pages).extract()
)
# Act
ht = heat_transmission_section_from_cert(epc)
# Assert
assert abs(ht.roof_w_per_k - 136.8500) <= 1e-4
def test_summary_001431_topfloor_extracts_main_property_age_band() -> None:
# Arrange — the gas-boiler-upgrade recommendation "after" Summary
# renders "3.0 Date Built:" glued to its "Main Property" row header

View file

@ -153,6 +153,12 @@ class SapHeating:
None # int from API; str from site notes
)
cylinder_insulation_thickness_mm: Optional[int] = None
# SAP 10.2 §4 branch a) — manufacturer's declared cylinder loss factor
# (kWh/day). When present, `_cylinder_storage_loss_override` uses it
# directly (× Table-2b temperature factor) in place of the Table 2
# V×L×VF computation; the gov lodges it instead of cylinder volume /
# insulation, so it must be read or the storage loss is dropped.
cylinder_heat_loss: Optional[float] = None
# SAP10 hot-water demand inputs from sap_heating.
number_baths: Optional[int] = None
number_baths_wwhrs: Optional[int] = None
@ -499,7 +505,7 @@ class SapBuildingPart:
building_part_number: Optional[int] = (
None # Not sure how we get this from site notes
)
wall_dry_lined: Optional[bool] = None # Don't think we have this in site notes
wall_dry_lined: Optional[bool] = None # Summary §7 "Dry-lining: Yes/No"
wall_thickness_mm: Optional[int] = None
# Union[str, int]: a numeric mm value when the API lodges
# `wall_insulation_thickness == "measured"` (resolved from the
@ -552,6 +558,13 @@ class SapBuildingPart:
roof_insulation_thickness: Optional[Union[str, int]] = (
None # TODO: make enum/mapping?
)
# Lodged insulation thickness (e.g. "225mm", or "AB" As Built) for a roof
# insulated AT RAFTERS (roof_insulation_location == 1). The gov API lodges
# rafter insulation in this dedicated field — `roof_insulation_thickness`
# stays None for rafter roofs. `heat_transmission` prefers this field over
# `roof_insulation_thickness` when the part is at-rafters, so the measured
# Table 16 column (2) row applies instead of the unknown-thickness default.
rafter_insulation_thickness: Optional[Union[str, int]] = None
sap_room_in_roof: Optional[SapRoomInRoof] = None
# Per RdSAP 10 §5.18 (PDF p.48), a curtain wall (wall_construction
# =WALL_CURTAIN=9) takes its U-value from the per-BP installation

View file

@ -1023,6 +1023,11 @@ class EpcPropertyDataMapper:
bp.roof_insulation_thickness,
bp.construction_age_band,
),
# Rafter insulation thickness lives in its own gov-API field
# (only on the 21.0.x schemas; getattr is None elsewhere).
rafter_insulation_thickness=getattr(
bp, "rafter_insulation_thickness", None
),
sap_room_in_roof=(
SapRoomInRoof(
floor_area=_measurement_value(bp.sap_room_in_roof.floor_area),
@ -1220,6 +1225,11 @@ class EpcPropertyDataMapper:
bp.roof_insulation_thickness,
bp.construction_age_band,
),
# Rafter insulation thickness lives in its own gov-API field
# (only on the 21.0.x schemas; getattr is None elsewhere).
rafter_insulation_thickness=getattr(
bp, "rafter_insulation_thickness", None
),
sap_room_in_roof=(
SapRoomInRoof(
# ADR-0028: floor_area is usually a Measurement but
@ -1470,6 +1480,11 @@ class EpcPropertyDataMapper:
bp.roof_insulation_thickness,
bp.construction_age_band,
),
# Rafter insulation thickness lives in its own gov-API field
# (only on the 21.0.x schemas; getattr is None elsewhere).
rafter_insulation_thickness=getattr(
bp, "rafter_insulation_thickness", None
),
sap_room_in_roof=(
SapRoomInRoof(
floor_area=_measurement_value(
@ -1573,6 +1588,7 @@ class EpcPropertyDataMapper:
== "true",
cylinder_size=schema.sap_heating.cylinder_size,
cylinder_volume_measured_l=schema.sap_heating.cylinder_size_measured,
cylinder_heat_loss=schema.sap_heating.cylinder_heat_loss,
water_heating_code=schema.sap_heating.water_heating_code,
water_heating_fuel=schema.sap_heating.water_heating_fuel,
immersion_heating_type=schema.sap_heating.immersion_heating_type,
@ -1705,6 +1721,10 @@ class EpcPropertyDataMapper:
bp.construction_age_band,
bp.sloping_ceiling_insulation_thickness,
),
# RdSAP 10 §5.11.2 — rafter insulation thickness lives in its
# own gov-API field (roof_insulation_thickness stays None for
# rafter roofs); heat_transmission prefers it when at-rafters.
rafter_insulation_thickness=bp.rafter_insulation_thickness,
# RdSAP 10 §5.1 — the assessor's lodged roof/wall/floor U
# overrides the §5.6/§5.7/§5.11 construction-default cascade
# (gov open data can redact the backing insulation).
@ -1883,6 +1903,7 @@ class EpcPropertyDataMapper:
== "true",
cylinder_size=schema.sap_heating.cylinder_size,
cylinder_volume_measured_l=schema.sap_heating.cylinder_size_measured,
cylinder_heat_loss=schema.sap_heating.cylinder_heat_loss,
water_heating_code=schema.sap_heating.water_heating_code,
water_heating_fuel=schema.sap_heating.water_heating_fuel,
immersion_heating_type=schema.sap_heating.immersion_heating_type,
@ -2014,6 +2035,10 @@ class EpcPropertyDataMapper:
bp.construction_age_band,
bp.sloping_ceiling_insulation_thickness,
),
# RdSAP 10 §5.11.2 — rafter insulation thickness lives in its
# own gov-API field (roof_insulation_thickness stays None for
# rafter roofs); heat_transmission prefers it when at-rafters.
rafter_insulation_thickness=bp.rafter_insulation_thickness,
# RdSAP 10 §5.1 — the assessor's lodged roof/wall/floor U
# overrides the §5.6/§5.7/§5.11 construction-default cascade
# (gov open data can redact the backing insulation).
@ -3935,6 +3960,59 @@ def _api_rir_detailed_surfaces(
if length is not None and height is not None and length > 0 and height > 0:
area = _round_half_up_2dp(float(length), float(height))
surfaces.append(SapRoomInRoofSurface(kind=gable_kind, area_m2=area))
# Sloping ceiling + stud walls — up to two of each (RdSAP §3.9 Figure 4).
# Both route to the roof aggregate (line (30)) via the cascade's
# Detailed-RR branch (`u_rr_slope` / `u_rr_stud_wall`, Table 17 cols 1/3).
# insulation_type is left None so the cascade defers to the Table 17
# column (a) mineral-wool default, mirroring the flat_ceiling branch.
slope_specs = (
(details.slope_length_1, details.slope_height_1,
details.slope_insulation_thickness_1),
(details.slope_length_2, details.slope_height_2,
details.slope_insulation_thickness_2),
)
stud_specs = (
(details.stud_wall_length_1, details.stud_wall_height_1,
details.stud_wall_insulation_thickness_1),
(details.stud_wall_length_2, details.stud_wall_height_2,
details.stud_wall_insulation_thickness_2),
)
for kind, specs in (("slope", slope_specs), ("stud_wall", stud_specs)):
for length, height, thickness_str in specs:
if (
length is not None and height is not None
and length > 0 and height > 0
):
area = _round_half_up_2dp(float(length), float(height))
surfaces.append(
SapRoomInRoofSurface(
kind=kind,
area_m2=area,
insulation_thickness_mm=(
_parse_rir_insulation_thickness_mm(thickness_str)
),
)
)
# Common walls — billed as external wall at the storey-below main-wall U
# (cascade `kind="common_wall"`), so no insulation thickness is read.
# Detailed BPs use the raw L × H area (RdSAP 10 §3.9.2; the cascade's
# common_wall branch applies the L × (0.25 + H) form only to Simplified
# BPs). The cascade deducts this area from the §3.10.1 residual roof.
common_wall_specs = (
(details.common_wall_length_1, details.common_wall_height_1),
(details.common_wall_length_2, details.common_wall_height_2),
)
for length, height in common_wall_specs:
if (
length is not None and height is not None
and length > 0 and height > 0
):
surfaces.append(
SapRoomInRoofSurface(
kind="common_wall",
area_m2=_round_half_up_2dp(float(length), float(height)),
)
)
if (
details.flat_ceiling_length_1 is not None
and details.flat_ceiling_height_1 is not None
@ -4358,6 +4436,12 @@ def _map_elmhurst_building_part(
wall_is_basement=_elmhurst_wall_is_basement(walls.wall_type),
wall_insulation_type=_elmhurst_wall_insulation_int(walls.insulation),
wall_thickness_measured=not walls.thickness_unknown,
# Summary §7 "Dry-lining: Yes" → RdSAP 10 §5.8 Table 14 R=0.17
# adjustment in the cascade (`dry_lined=bool(part.wall_dry_lined)`).
# Emit None (not False) when undried so the field stays absent for
# the non-dry-lined majority (cascade-equivalent: bool(None) == False);
# only a lodged "Yes" populates it.
wall_dry_lined=walls.dry_lined or None,
party_wall_construction=_elmhurst_party_wall_construction_int(
walls.party_wall_type
),
@ -5942,6 +6026,13 @@ def _elmhurst_cylinder_size_code(
Table 28 page 55."""
if not cylinder_present or cylinder_size_label is None:
return None
if cylinder_size_label == "Value known":
# Measured-volume cylinder — the Summary-path equivalent of the
# gov-API "Exact" descriptor. RdSAP 10 §10.5 Table 28 (p.55): when
# the cylinder volume is measured it is used directly. Cascade code
# 6 routes `_cylinder_volume_l_from_code` to the lodged
# `cylinder_volume_measured_l` (`cert_to_inputs.py:5281`).
return 6 # Exact / measured volume
if cylinder_size_label == "No Access":
if water_heating_fuel_label is None or meter_type_label is None:
raise UnmappedElmhurstLabel(
@ -6587,6 +6678,14 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating:
),
cylinder_insulation_type=cylinder_insulation_type_field,
cylinder_insulation_thickness_mm=cylinder_insulation_thickness_mm_field,
# §15.1 "Cylinder Volume (l)" — measured volume for a "Value known"
# cylinder (cascade code 6 / Exact). None unless a cylinder is
# present; the cascade reads it only when `cylinder_size == 6`.
cylinder_volume_measured_l=(
survey.water_heating.cylinder_volume_measured_l
if survey.water_heating.hot_water_cylinder_present
else None
),
# Cascade reads `cylinder_thermostat == "Y"` (string compare) per
# `cert_to_inputs.py:2252` / `:2218`. Map the bool to the Y/N
# string the cascade expects; None when no cylinder is present.

View file

@ -406,6 +406,57 @@ class TestFromRdSapSchema21_0_1:
# worksheet uses per-bp sums and the mapper now mirrors that.
assert result.total_floor_area_m2 == 45.82
def test_rafter_insulation_thickness_threaded(
self, schema: RdSapSchema21_0_1
) -> None:
# Arrange — the gov API lodges rafter insulation in the dedicated
# `rafter_insulation_thickness` field (RdSAP 10 §5.11.2); it was
# previously undeclared, so `from_dict` dropped it and the cascade
# fell to the Table 18 col (2) unknown default. The mapper must
# thread it through to the domain SapBuildingPart so
# heat_transmission can reach the measured Table 16 col (2) row.
import dataclasses
bps = schema.sap_building_parts
patched = dataclasses.replace(
schema,
sap_building_parts=[
dataclasses.replace(
bps[0], roof_insulation_location=1,
rafter_insulation_thickness="225mm",
),
*bps[1:],
],
)
# Act
result = EpcPropertyDataMapper.from_rdsap_schema_21_0_1(patched)
# Assert
assert result.sap_building_parts[0].rafter_insulation_thickness == "225mm"
def test_cylinder_heat_loss_threaded(
self, schema: RdSapSchema21_0_1
) -> None:
# Arrange — the gov API lodges the manufacturer's declared cylinder
# loss factor (kWh/day) in `sap_heating.cylinder_heat_loss` (SAP
# 10.2 §4 branch a). Previously undeclared → `from_dict` dropped it
# and the §4 storage loss fell to None → the dwelling over-rated.
import dataclasses
patched = dataclasses.replace(
schema,
sap_heating=dataclasses.replace(
schema.sap_heating, cylinder_heat_loss=1.72
),
)
# Act
result = EpcPropertyDataMapper.from_rdsap_schema_21_0_1(patched)
# Assert
assert result.sap_heating.cylinder_heat_loss == 1.72
# --- property flags ---
def test_solar_water_heating(self, result: EpcPropertyData) -> None:
@ -2101,3 +2152,79 @@ class TestRdSap17_1ReducedFieldSynthesis:
assert result.sap_heating.number_baths == expected_baths
assert result.sap_heating.mixer_shower_count == expected_mixers
class TestRoomInRoofDetailedSlopeAndStudWall:
"""RdSAP 10 §3.9 Detailed RR — the gov API lodges the sloping ceiling
and stud-wall surfaces under `room_in_roof_details.slope_*` /
`stud_wall_*`. These were undeclared on the schema, so `from_dict`
dropped them and the API mapper built ONLY the gable + flat-ceiling
surfaces omitting the (large) sloping roof and vertical knee walls
undercounted RR heat loss a systematic ~+4 SAP over-rate across the
15 detailed-RR corpus certs carrying `slope_height_1`."""
def test_slope_surface_survives_from_dict_round_trip(self) -> None:
# Arrange — a 21.0.1 detailed-RR block (cert 0390-2538 shape).
from datatypes.epc.schema.rdsap_schema_21_0_1 import RoomInRoofDetails
raw = {
"slope_length_1": 7.0,
"slope_height_1": 1.4,
"slope_insulation_thickness_1": "100mm",
"stud_wall_length_1": 7.0,
"stud_wall_height_1": 1.03,
"stud_wall_insulation_thickness_1": "75mm",
}
# Act
details = from_dict(RoomInRoofDetails, raw)
# Assert — the fields are no longer silently dropped.
assert details.slope_height_1 == 1.4
assert details.slope_insulation_thickness_1 == "100mm"
assert details.stud_wall_height_1 == 1.03
def test_from_api_response_builds_slope_and_stud_wall_surfaces(self) -> None:
# Arrange — drive the PUBLIC API path: take the 21.0.1 fixture's RR
# building part and replace its Simplified Type-1 block with a
# Detailed RR carrying two sloping ceilings (7 × 1.4) + two stud
# walls (7 × 1.03). cert 0390-2538 went +5.95 -> +3.56 SAP once these
# surfaces entered the roof aggregate.
cert = load("21_0_1.json")
rir = cert["sap_building_parts"][0]["sap_room_in_roof"]
rir.pop("room_in_roof_type_1", None)
rir["room_in_roof_details"] = {
"slope_length_1": 7.0, "slope_height_1": 1.4,
"slope_length_2": 7.0, "slope_height_2": 1.4,
"slope_insulation_thickness_1": "100mm",
"slope_insulation_thickness_2": "100mm",
"stud_wall_length_1": 7.0, "stud_wall_height_1": 1.03,
"stud_wall_length_2": 7.0, "stud_wall_height_2": 1.03,
"stud_wall_insulation_thickness_1": "75mm",
"stud_wall_insulation_thickness_2": "75mm",
"common_wall_length_1": 8.6, "common_wall_height_1": 1.2,
"common_wall_length_2": 8.6, "common_wall_height_2": 1.2,
}
# Act
result = EpcPropertyDataMapper.from_api_response(cert)
# Assert — both slopes + both stud walls reach the cascade, with the
# lodged thickness parsed and the L × H area to 2 d.p. Common walls
# route to the `common_wall` kind (raw L × H, billed at main-wall U).
rir_part = result.sap_building_parts[0].sap_room_in_roof
assert rir_part is not None
surfaces = rir_part.detailed_surfaces
assert surfaces is not None
slopes = [s for s in surfaces if s.kind == "slope"]
studs = [s for s in surfaces if s.kind == "stud_wall"]
commons = [s for s in surfaces if s.kind == "common_wall"]
assert len(slopes) == 2
assert len(studs) == 2
assert len(commons) == 2
assert abs(slopes[0].area_m2 - 9.8) <= 1e-9
assert slopes[0].insulation_thickness_mm == 100
assert abs(studs[0].area_m2 - 7.21) <= 1e-9
assert studs[0].insulation_thickness_mm == 75
assert abs(commons[0].area_m2 - 10.32) <= 1e-9
assert commons[0].insulation_thickness_mm is None

View file

@ -79,6 +79,11 @@ class SapHeating:
# RdSAP 10 §10.5 Table 28 — measured cylinder volume (litres), lodged
# only when `cylinder_size` is the "Exact" descriptor (code 6).
cylinder_size_measured: Optional[int] = None
# SAP 10.2 §4 branch a) (PDF p.136) — the manufacturer's declared
# cylinder loss factor (kWh/day). When lodged it replaces the Table 2
# V×L×VF storage-loss computation. Previously undeclared → dropped by
# `from_dict`, so the storage loss fell through to None.
cylinder_heat_loss: Optional[float] = None
@dataclass
@ -204,6 +209,31 @@ class RoomInRoofDetails:
flat_ceiling_height_1: Optional[float] = None
flat_ceiling_insulation_type_1: Optional[int] = None
flat_ceiling_insulation_thickness_1: Optional[str] = None
# Sloping-ceiling + stud-wall surfaces of a Detailed RR — see
# `rdsap_schema_21_0_1.RoomInRoofDetails`. Previously undeclared and
# dropped by `from_dict`.
slope_length_1: Optional[float] = None
slope_length_2: Optional[float] = None
slope_height_1: Optional[float] = None
slope_height_2: Optional[float] = None
slope_insulation_type_1: Optional[int] = None
slope_insulation_type_2: Optional[int] = None
slope_insulation_thickness_1: Optional[str] = None
slope_insulation_thickness_2: Optional[str] = None
stud_wall_length_1: Optional[float] = None
stud_wall_length_2: Optional[float] = None
stud_wall_height_1: Optional[float] = None
stud_wall_height_2: Optional[float] = None
stud_wall_insulation_type_1: Optional[int] = None
stud_wall_insulation_type_2: Optional[int] = None
stud_wall_insulation_thickness_1: Optional[str] = None
stud_wall_insulation_thickness_2: Optional[str] = None
# §3.9.2 common walls of a Detailed RR — see
# `rdsap_schema_21_0_1.RoomInRoofDetails`. Previously dropped.
common_wall_length_1: Optional[float] = None
common_wall_length_2: Optional[float] = None
common_wall_height_1: Optional[float] = None
common_wall_height_2: Optional[float] = None
@dataclass
@ -265,6 +295,14 @@ class SapBuildingPart:
# the slope as uninsulated (Table 16 / Table 18 fallback). Consumed by
# `_api_resolve_sloping_ceiling_thickness` → Table 17 column (1a).
sloping_ceiling_insulation_thickness: Optional[Union[str, int]] = None
# Lodged insulation thickness (e.g. "225mm", or "AB" As Built) for a roof
# insulated AT RAFTERS (roof_insulation_location == 1). The gov API lodges
# rafter insulation in this dedicated field — NOT `roof_insulation_thickness`
# (which stays None for rafter roofs, since rafters aren't loft joists).
# Previously undeclared → dropped by `from_dict`, so the cascade fell to the
# Table 18 col (2) unknown default (2.30) instead of the measured Table 16
# col (2) row. Consumed by `heat_transmission` when at-rafters.
rafter_insulation_thickness: Optional[Union[str, int]] = None
# Lodged roof U-value (W/m²K) — the assessor's RdSAP-assessed roof U,
# authoritative when the open data redacts the backing insulation
# thickness. Consumed by `heat_transmission` as a §5.1 documentary-evidence

View file

@ -84,6 +84,12 @@ class SapHeating:
# RdSAP 10 §10.5 Table 28 — measured cylinder volume (litres), lodged
# only when `cylinder_size` is the "Exact" descriptor (code 6).
cylinder_size_measured: Optional[int] = None
# SAP 10.2 §4 branch a) (PDF p.136) — the manufacturer's declared
# cylinder loss factor (kWh/day). When lodged it replaces the Table 2
# V×L×VF storage-loss computation (the gov leaves volume/insulation
# None in that case). Previously undeclared → dropped by `from_dict`,
# so the storage loss fell through to None and the dwelling over-rated.
cylinder_heat_loss: Optional[float] = None
@dataclass
@ -240,6 +246,36 @@ class RoomInRoofDetails:
flat_ceiling_height_1: Optional[float] = None
flat_ceiling_insulation_type_1: Optional[int] = None
flat_ceiling_insulation_thickness_1: Optional[str] = None
# The sloping-ceiling and stud-wall surfaces of a Detailed RR. Up to two
# of each per spec Figure 4. Previously undeclared, so `from_dict`
# silently dropped them and the API mapper built ONLY the gable + flat-
# ceiling surfaces — omitting the (large) sloping roof and the vertical
# stud walls → undercounted RR heat loss → systematic over-rate.
slope_length_1: Optional[float] = None
slope_length_2: Optional[float] = None
slope_height_1: Optional[float] = None
slope_height_2: Optional[float] = None
slope_insulation_type_1: Optional[int] = None
slope_insulation_type_2: Optional[int] = None
slope_insulation_thickness_1: Optional[str] = None
slope_insulation_thickness_2: Optional[str] = None
stud_wall_length_1: Optional[float] = None
stud_wall_length_2: Optional[float] = None
stud_wall_height_1: Optional[float] = None
stud_wall_height_2: Optional[float] = None
stud_wall_insulation_type_1: Optional[int] = None
stud_wall_insulation_type_2: Optional[int] = None
stud_wall_insulation_thickness_1: Optional[str] = None
stud_wall_insulation_thickness_2: Optional[str] = None
# The §3.9.2 common walls of a Detailed RR (the wall separating the RR
# from the rest of the cold roof void). Billed as external wall at the
# storey-below main-wall U (cascade `kind="common_wall"`). Detailed BPs
# use the raw L × H area (Simplified Type-2 BPs use L × (0.25 + H)).
# Previously undeclared → dropped → the RR undercounted wall loss.
common_wall_length_1: Optional[float] = None
common_wall_length_2: Optional[float] = None
common_wall_height_1: Optional[float] = None
common_wall_height_2: Optional[float] = None
@dataclass
@ -303,6 +339,14 @@ class SapBuildingPart:
# the slope as uninsulated (Table 16 / Table 18 fallback). Consumed by
# `_api_resolve_sloping_ceiling_thickness` → Table 17 column (1a).
sloping_ceiling_insulation_thickness: Optional[Union[str, int]] = None
# Lodged insulation thickness (e.g. "225mm", or "AB" As Built) for a roof
# insulated AT RAFTERS (roof_insulation_location == 1). The gov API lodges
# rafter insulation in this dedicated field — NOT `roof_insulation_thickness`
# (which stays None for rafter roofs, since rafters aren't loft joists).
# Previously undeclared → dropped by `from_dict`, so the cascade fell to the
# Table 18 col (2) unknown default (2.30) instead of the measured Table 16
# col (2) row. Consumed by `heat_transmission` when at-rafters.
rafter_insulation_thickness: Optional[Union[str, int]] = None
# Lodged roof U-value (W/m²K) — the assessor's RdSAP-assessed roof U. The
# gov open data can redact the backing insulation thickness, so this is the
# authoritative per-element value; consumed by `heat_transmission` as a

View file

@ -94,6 +94,12 @@ class WallDetails:
# "Insulation Thickness" / "100 mm" line pair when a composite or
# retrofit insulation is recorded. None when the PDF omits the line.
insulation_thickness_mm: Optional[int] = None
# Summary §7 "Dry-lining: Yes/No" on the main/extension wall (distinct
# from the per-alt-wall `AlternativeWall.dry_lined`). Per RdSAP 10
# §5.8 + Table 14 a dry-lined uninsulated wall adds R=0.17 m²K/W →
# U = 1/(1/U_base + 0.17). Previously unread, so dry-lined solid/
# cavity walls were billed at the un-adjusted (higher) base U.
dry_lined: bool = False
# Per-BP curtain-wall installation age, lodged in Summary §7 as
# "Curtain Wall Age" when `wall_type` is "CW Curtain Wall". Per
# RdSAP 10 §5.18 (PDF p.48) the curtain-wall U-value keys on this
@ -369,6 +375,11 @@ class WaterHeating:
cylinder_insulation_label: Optional[str] = None
# §15.1 "Insulation Thickness" lodging in mm (an integer or None).
cylinder_insulation_thickness_mm: Optional[int] = None
# §15.1 "Cylinder Volume (l)" lodging — the measured cylinder volume in
# litres, present when "Cylinder Size" is lodged as "Value known"
# (the Summary-path equivalent of the gov-API "Exact" descriptor,
# cascade code 6). None when no cylinder is present or the line is absent.
cylinder_volume_measured_l: Optional[int] = None
# §15.1 "Cylinder Thermostat" lodging (Yes / No). False or absent
# keeps the cascade's no-thermostat Table 2b temperature factor.
cylinder_thermostat: Optional[bool] = None

View file

@ -3992,6 +3992,22 @@ def _int_or_none(value: object) -> Optional[int]:
return value if isinstance(value, int) else None
def _float_or_none(value: object) -> Optional[float]:
"""Coerce a lodged numeric (int / float / numeric string) to float,
else None. Used for measured overrides like the cylinder declared
loss factor (`cylinder_heat_loss`, kWh/day)."""
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
try:
return float(value.strip())
except ValueError:
return None
return None
def _thermal_mass_parameter_kj_per_m2_k(epc: EpcPropertyData) -> float:
"""RdSAP 10 §5.16 Table 22 (PDF p.48) — thermal mass parameter from
the MAIN building's wall construction.
@ -6489,7 +6505,31 @@ def _cylinder_storage_loss_override(
if not epc.has_hot_water_cylinder:
return None
sh = epc.sap_heating
# SAP 10.2 §4 branch a) (PDF p.136) — a lodged manufacturer's declared
# cylinder loss factor (kWh/day, gov-API `cylinder_heat_loss`) replaces
# the Table 2 V×L×VF computation. It does NOT need the insulation
# type / thickness / volume (which the gov leaves None precisely
# because the declared loss is lodged instead), so resolve it BEFORE
# those guards — otherwise the storage loss is dropped entirely and the
# dwelling over-rates (the declared-loss is typically ~1.5 kWh/day ≈
# 550 kWh/yr). The Table-2b temperature factor still applies (49)→(50).
declared_loss = _float_or_none(getattr(sh, "cylinder_heat_loss", None))
volume_l = _cylinder_volume_l_from_code(epc)
if declared_loss is not None:
storage_56m = cylinder_storage_loss_monthly_kwh(
volume_l=volume_l or 0.0,
insulation_type="factory_insulated", # unused in the declared branch
thickness_mm=0.0, # unused in the declared branch
has_cylinder_thermostat=_cylinder_thermostat_present(epc, main),
separately_timed_dhw=_table_2b_note_b_multiplier_applies(epc, main),
declared_loss_kwh_per_day=declared_loss,
)
# (57)m solar adjustment only when solar HW + a resolvable volume.
if not epc.solar_water_heating or volume_l is None:
return storage_56m
vs_l = round(volume_l * _COMBINED_CYLINDER_SOLAR_PREHEAT_FRACTION)
factor = (volume_l - vs_l) / volume_l
return tuple(s * factor for s in storage_56m)
if volume_l is None:
return None
insulation_label = _cylinder_storage_loss_insulation_label(

View file

@ -41,7 +41,7 @@ from __future__ import annotations
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal
from typing import Any, Final, Optional
from typing import Any, Final, Optional, Union
from datatypes.epc.domain.epc_property_data import (
EpcPropertyData,
@ -349,6 +349,27 @@ def _joined_descriptions(elements: list[Any]) -> Optional[str]:
return " | ".join(parts)
def _roof_insulation_at_rafters(location: Optional[Union[int, str]]) -> bool:
"""True when a building part's roof insulation sits at the rafters
(the sloping side of the roof) rather than between the ceiling joists.
`roof_insulation_location` is the authoritative per-part signal it
carries the gov-EPC API integer code (1 = Rafters, per the empirically
watertight single-roof corpus map) on the API path and the stripped
Elmhurst Summary label ("Rafters" from "R Rafters") on the Summary
path. Both resolve here so `u_roof` selects the RdSAP 10 §5.11.2
Table 16 column (2) / Table 18 rafters column instead of the loft-
joist column (1). The flat deduplicated `epc.roofs[]` description list
cannot give this per-part 190/329 multi-part certs have
len(roofs) != len(parts) so the per-part location is the only
reliable discriminator (worksheet-validated by simulated case 41)."""
if location is None:
return False
if isinstance(location, int):
return location == 1
return "rafter" in location.strip().lower()
def _joined_main_roof_descriptions(roofs: list[Any]) -> Optional[str]:
"""Join roof descriptions for the MAIN (non-RR) roof U-value, dropping
"Roof room(s)" entries.
@ -770,7 +791,22 @@ def heat_transmission_from_cert(
or _described_as_retrofit_insulated(wall_description)
)
party_construction = _int_or_none(part.party_wall_construction)
# RdSAP 10 §5.11.2 — a roof insulated AT RAFTERS lodges its thickness in
# the dedicated gov-API `rafter_insulation_thickness` field, NOT
# `roof_insulation_thickness` (which stays None for rafter roofs, since
# rafters aren't loft joists). Prefer the rafter field when the part is
# at-rafters so the measured Table 16 column (2) row applies instead of
# the unknown-thickness default. The Summary path lodges rafter
# thickness in `roof_insulation_thickness` (no separate field), so the
# fallback covers it.
insulation_at_rafters = _roof_insulation_at_rafters(
getattr(part, "roof_insulation_location", None)
)
raw_roof_thickness = getattr(part, "roof_insulation_thickness", None)
if insulation_at_rafters:
raw_rafter_thickness = getattr(part, "rafter_insulation_thickness", None)
if raw_rafter_thickness is not None:
raw_roof_thickness = raw_rafter_thickness
roof_thickness = _parse_thickness_mm(raw_roof_thickness)
floor_ins_thickness = _parse_thickness_mm(getattr(part, "floor_insulation_thickness", None))
@ -873,7 +909,14 @@ def heat_transmission_from_cert(
# col (1) per the cohort, so only the literal "sloping ceiling"
# string triggers the col (3) age-band default in `u_roof`.
is_pitched_sloping_ceiling = "sloping ceiling" in roof_type_lower
ur = u_roof(country=country, age_band=age_band, insulation_thickness_mm=roof_thickness, description=effective_roof_description, is_flat_roof=is_flat_roof, is_sloping_ceiling=is_sloping_ceiling, is_pitched_sloping_ceiling=is_pitched_sloping_ceiling)
# RdSAP 10 §5.11.2 Table 16 column (2) / Table 18 rafters column —
# a roof lodged insulated AT RAFTERS sits on the shallower sloping
# side, so the same insulation depth yields a higher U than the
# loft-joist column (1). `insulation_at_rafters` (computed above) is
# driven per-part from `roof_insulation_location` because the
# deduplicated `epc.roofs[]` description list cannot attribute a
# location to each building part.
ur = u_roof(country=country, age_band=age_band, insulation_thickness_mm=roof_thickness, description=effective_roof_description, is_flat_roof=is_flat_roof, is_sloping_ceiling=is_sloping_ceiling, is_pitched_sloping_ceiling=is_pitched_sloping_ceiling, insulation_at_rafters=insulation_at_rafters)
# RdSAP 10 §5.1 — a lodged/known roof U-value (the assessor's RdSAP
# output, surfaced by the gov-EPC API as `roof_u_value`) is used
# directly in place of the §5.11 construction-default cascade. The gov

View file

@ -628,10 +628,20 @@ def cylinder_storage_loss_monthly_kwh(
thickness_mm: float,
has_cylinder_thermostat: bool,
separately_timed_dhw: bool,
declared_loss_kwh_per_day: Optional[float] = None,
) -> tuple[float, ...]:
"""SAP 10.2 §4 line (56)m water storage loss per spec (PDF p.136):
(54) = V × L × VF × TF (Table 2 absence-of-declared-loss branch)
(55) = (54) (no manufacturer's declared loss)
"""SAP 10.2 §4 line (56)m water storage loss per spec (PDF p.136).
Two branches, selected by whether the manufacturer's declared loss
factor is lodged:
a) declared loss known (`declared_loss_kwh_per_day` set):
(50) = (48) declared loss (kWh/day) × (49) Table-2b temperature factor
`volume_l` / `insulation_type` / `thickness_mm` are unused.
b) declared loss not known (the default):
(54) = (47) V × (51) L × (52) VF × (53) TF
(55) = (50) or (54)
(56)m = (55) × n_m (n_m = days in month)
Returns 12 monthly values in calendar order Jan..Dec. The cert's
@ -639,15 +649,21 @@ def cylinder_storage_loss_monthly_kwh(
solar storage is present in the vessel callers handling solar
storage must adjust further per `(57)m = (56)m × [(47) - Vs] / (47)`.
"""
L = cylinder_storage_loss_factor_table_2(
insulation_type=insulation_type, thickness_mm=thickness_mm,
)
VF = cylinder_volume_factor_table_2a(volume_l)
TF = cylinder_temperature_factor_table_2b(
has_cylinder_thermostat=has_cylinder_thermostat,
separately_timed_dhw=separately_timed_dhw,
)
combined_55 = volume_l * L * VF * TF
if declared_loss_kwh_per_day is not None:
# SAP 10.2 §4 (PDF p.136) branch a) — the lodged manufacturer's
# declared loss (kWh/day) replaces the Table 2 V×L×VF computation;
# the Table-2b temperature factor still applies (line (49)→(50)).
combined_55 = declared_loss_kwh_per_day * TF
else:
L = cylinder_storage_loss_factor_table_2(
insulation_type=insulation_type, thickness_mm=thickness_mm,
)
VF = cylinder_volume_factor_table_2a(volume_l)
combined_55 = volume_l * L * VF * TF
return tuple(combined_55 * n for n in _DAYS_IN_MONTH)

View file

@ -747,6 +747,34 @@ _ROOF_BY_AGE: Final[dict[str, float]] = {
"K": 0.16, "L": 0.16, "M": 0.15,
}
# Table 16 column (2): insulation AT RAFTERS (sloping side of the roof,
# rather than between the ceiling joists). RdSAP 10 §5.11.2 Table 16
# (PDF p.42-43). The rafter cavity is shallower than a loft void, so the
# same insulation depth yields a HIGHER U than the column (1) joist row
# (e.g. 200 mm: rafters 0.29 vs joists 0.21). Thickness mm -> U.
_ROOF_RAFTERS_BY_THICKNESS: Final[list[tuple[int, float]]] = [
(0, 2.30), (12, 1.75), (25, 1.30), (50, 0.88), (75, 0.67),
(100, 0.54), (125, 0.45), (150, 0.39), (175, 0.32), (200, 0.29),
(225, 0.25), (250, 0.23), (270, 0.21), (300, 0.19), (350, 0.16),
(400, 0.14),
]
# Table 18 rafters column: pitched-roof "insulation at rafters" default U
# by age band when the thickness cannot be determined. RdSAP 10 §5.11
# Table 18 (PDF p.45). Identical to the joist column (1) for bands A-G
# (2.30 → 0.40), then diverges higher (H 0.35 vs 0.30, I 0.35 vs 0.26,
# J/K 0.20 vs 0.16, L 0.18 vs 0.16). Unlike the loft-joist default this
# does NOT collapse to the optimistic 0.40 "assume modern retrofit" floor
# at old bands — a rafter cavity cannot be topped up from the loft, so an
# unknown-thickness rafter roof keeps the as-built age-band U (band F
# 0.68, band E 1.50, A-D 2.30). Worksheet-validated by simulated case 41
# Ext3 (band F, R Rafters, As Built → P960 §3 (30) U=0.68).
_ROOF_RAFTERS_BY_AGE: Final[dict[str, float]] = {
"A": 2.30, "B": 2.30, "C": 2.30, "D": 2.30, "E": 1.50,
"F": 0.68, "G": 0.40, "H": 0.35, "I": 0.35, "J": 0.20,
"K": 0.20, "L": 0.18, "M": 0.18,
}
# Table 18 column (3): flat-roof default U by age band when thickness unknown.
# RdSAP 10 §5.11 Table 18 page 45 — the pitched-roof column (1) defaults
# bottom out at 0.40 because "between joists insulation" is the implicit
@ -793,6 +821,7 @@ def u_roof(
is_flat_roof: bool = False,
is_sloping_ceiling: bool = False,
is_pitched_sloping_ceiling: bool = False,
insulation_at_rafters: bool = False,
) -> float:
"""RdSAP10 roof U-value in W/m^2K, never null.
@ -829,7 +858,27 @@ def u_roof(
(code 5) are deliberately excluded they stay on column (1) per the
cohort evidence above. Worksheet-validated by simulated case 15 (the
7536 replica): Ext1 band L 0.18, Ext2 band F 0.68.
`insulation_at_rafters` selects the RdSAP 10 §5.11.2 Table 16 column
(2) thickness ladder and the Table 18 rafters age-band column instead
of the loft-joist column (1). A roof lodged insulated AT RAFTERS
(`roof_insulation_location == 1` on the API path, "R Rafters" on the
Summary path) sits on the sloping side of the roof a shallower
cavity than a loft void, so the same insulation depth yields a higher
U (200 mm: 0.29 vs the joists 0.21). Ignored for flat / sloping-
ceiling roofs (the rafter distinction is a pitched-with-loft concept).
Worksheet-validated by simulated case 41 Ext1 (band C, R Rafters,
200 mm 0.29) and Ext3 (band F, R Rafters, As Built 0.68).
"""
# RdSAP 10 §5.11.2 Table 16 / §5.11 Table 18 — pick the rafters
# column when the insulation sits at the rafters rather than the
# loft joists. Flat / sloping-ceiling geometries keep their own
# dedicated tables (rafters is meaningless there).
use_rafters = insulation_at_rafters and not (is_flat_roof or is_sloping_ceiling)
roof_by_thickness = (
_ROOF_RAFTERS_BY_THICKNESS if use_rafters else _ROOF_BY_THICKNESS
)
roof_by_age = _ROOF_RAFTERS_BY_AGE if use_rafters else _ROOF_BY_AGE
measured = _measured_u_from_description(description)
if measured is not None:
# Full-SAP cert lodges a measured roof U-value in the description
@ -852,7 +901,7 @@ def u_roof(
# genuine "no insulation" lodgement, which keeps 2.30 (below). The
# discriminator is the deterministic "Unknown" text RdSAP renders
# for an undetermined-thickness observation.
table_18 = _FLAT_ROOF_BY_AGE if is_flat_roof else _ROOF_BY_AGE
table_18 = _FLAT_ROOF_BY_AGE if is_flat_roof else roof_by_age
return table_18.get(age_band.upper(), 0.4)
if (
is_sloping_ceiling
@ -877,9 +926,10 @@ def u_roof(
# uninsulated 2.30 W/m²K.
return 0.68 # Table 16 row 50, "Insulation at joists at ceiling level"
if insulation_thickness_mm is not None:
# nearest tabulated thickness <= supplied
u = _ROOF_BY_THICKNESS[0][1]
for t, val in _ROOF_BY_THICKNESS:
# nearest tabulated thickness <= supplied (Table 16 column (1)
# joists or column (2) rafters per `insulation_at_rafters`)
u = roof_by_thickness[0][1]
for t, val in roof_by_thickness:
if insulation_thickness_mm >= t:
u = val
return u
@ -923,7 +973,7 @@ def u_roof(
return _FLAT_ROOF_BY_AGE.get(age_band.upper(), 0.4)
if is_flat_roof:
return _FLAT_ROOF_BY_AGE.get(age_band.upper(), 0.4)
return _ROOF_BY_AGE.get(age_band.upper(), 0.4)
return roof_by_age.get(age_band.upper(), 0.4)
# RdSAP10 Table 17 — U-values for rooms in roof where insulation thickness

View file

@ -961,6 +961,69 @@ def test_u_roof_with_explicit_insulation_thickness_uses_table16() -> None:
assert result == pytest.approx(0.21, abs=0.001)
def test_u_roof_at_rafters_explicit_thickness_uses_table16_column_2() -> None:
# Arrange — RdSAP 10 §5.11.2 Table 16 (PDF p.42-43) column (2)
# "insulation at rafters". A roof lodged insulated AT RAFTERS
# (roof_insulation_location == 1, "R Rafters" on the Summary path)
# takes the rafters thickness ladder, NOT the column (1) joist row:
# at 200 mm the rafters U is 0.29 W/m²K vs the joists 0.21 — a ~38%
# heat-loss understatement when the joists column is mis-used. The
# joists column (1) stays 0.21 for the same thickness.
# Act
at_rafters = u_roof(
country=Country.ENG, age_band="C", insulation_thickness_mm=200,
insulation_at_rafters=True,
)
at_joists = u_roof(
country=Country.ENG, age_band="C", insulation_thickness_mm=200,
insulation_at_rafters=False,
)
# Assert
assert abs(at_rafters - 0.29) <= 0.001
assert abs(at_joists - 0.21) <= 0.001
def test_u_roof_at_rafters_thickness_ladder_matches_table16_column_2() -> None:
# Arrange — RdSAP 10 §5.11.2 Table 16 (PDF p.42-43) column (2) rows:
# 50 mm → 0.88, 100 mm → 0.54, 150 mm → 0.39, 270 mm → 0.21. Each is
# higher than the joists column (1) value at the same thickness (the
# rafter cavity is shallower so the same insulation depth yields a
# higher U).
# Act / Assert
assert abs(u_roof(country=Country.ENG, age_band="C", insulation_thickness_mm=50, insulation_at_rafters=True) - 0.88) <= 0.001
assert abs(u_roof(country=Country.ENG, age_band="C", insulation_thickness_mm=100, insulation_at_rafters=True) - 0.54) <= 0.001
assert abs(u_roof(country=Country.ENG, age_band="C", insulation_thickness_mm=150, insulation_at_rafters=True) - 0.39) <= 0.001
assert abs(u_roof(country=Country.ENG, age_band="C", insulation_thickness_mm=270, insulation_at_rafters=True) - 0.21) <= 0.001
def test_u_roof_at_rafters_unknown_thickness_uses_table18_rafters_age_band() -> None:
# Arrange — RdSAP 10 §5.11 Table 18 (PDF p.45) rafters age-band
# column. A rafter-insulated roof with no determinable thickness
# ("R Rafters" + "As Built" → thickness None) takes the rafters
# age-band default. Band F → 0.68 (== the joists value at F), band H
# → 0.35 (vs joists 0.30), band J → 0.20 (vs joists 0.16). Unlike a
# loft-joist roof the rafter cavity cannot be topped up, so the
# optimistic 0.40 "assume modern retrofit" joist floor does NOT apply
# at old bands — band C stays 2.30 (vs the joists-unknown 0.40).
# Worksheet-validated by simulated case 41 Ext3 (band F, R Rafters,
# As Built → P960 §3 (30) U=0.68).
# Act
band_f = u_roof(country=Country.ENG, age_band="F", insulation_thickness_mm=None, insulation_at_rafters=True)
band_h = u_roof(country=Country.ENG, age_band="H", insulation_thickness_mm=None, insulation_at_rafters=True)
band_j = u_roof(country=Country.ENG, age_band="J", insulation_thickness_mm=None, insulation_at_rafters=True)
band_c = u_roof(country=Country.ENG, age_band="C", insulation_thickness_mm=None, insulation_at_rafters=True)
# Assert
assert abs(band_f - 0.68) <= 0.001
assert abs(band_h - 0.35) <= 0.001
assert abs(band_j - 0.20) <= 0.001
assert abs(band_c - 2.30) <= 0.001
def test_u_roof_unknown_age_band_falls_back_to_mid_range() -> None:
# Arrange — nothing known.

View file

@ -0,0 +1,60 @@
"""Mapper boundary: the Elmhurst §15.1 "Cylinder Size" label.
A cylinder lodged "Value known" carries a measured volume in the §15.1
"Cylinder Volume (l)" line the Summary-path equivalent of the gov-API
"Exact" descriptor. Per RdSAP 10 §10.5 Table 28 (p.55) the measured volume
is used directly; cascade code 6 routes `_cylinder_volume_l_from_code` to
the lodged `cylinder_volume_measured_l`. Before this was mapped the label
raised `UnmappedElmhurstLabel`, blocking every measured-volume-cylinder
Summary.
"""
from datatypes.epc.domain.mapper import (
UnmappedElmhurstLabel,
_elmhurst_cylinder_size_code, # pyright: ignore[reportPrivateUsage]
)
def test_value_known_label_maps_to_exact_code_6() -> None:
# Arrange
label = "Value known"
# Act
code = _elmhurst_cylinder_size_code(label, cylinder_present=True)
# Assert
assert code == 6
def test_value_known_label_with_no_cylinder_maps_to_none() -> None:
# Arrange
label = "Value known"
# Act
code = _elmhurst_cylinder_size_code(label, cylinder_present=False)
# Assert
assert code is None
def test_normal_label_still_maps_to_code_2() -> None:
# Arrange
label = "Normal"
# Act
code = _elmhurst_cylinder_size_code(label, cylinder_present=True)
# Assert
assert code == 2
def test_unknown_label_still_raises() -> None:
# Arrange
label = "Spray-on unicorn cylinder"
# Act / Assert
try:
_elmhurst_cylinder_size_code(label, cylinder_present=True)
except UnmappedElmhurstLabel:
return
raise AssertionError("expected UnmappedElmhurstLabel for an unknown label")

View file

@ -0,0 +1,121 @@
"""Mapper-driven cascade pin against the Elmhurst P960-0001-001431
"simulated case 39" worksheet an age-A (pre-1900) mid-terrace heated by
**direct-acting electric room heaters** (SAP code 691, category 10, control
2602 appliance thermostats), with an electric room-heater secondary (also
691) and electric-immersion DHW (WHC 903) off a **measured-volume hot-water
cylinder** ("Cylinder Size: Value known", 117 L, foam 38 mm), on a single
(standard) electricity meter.
This case was generated to probe the API-corpus's worst-served cohort
(category-10 direct-acting electric, 46% within-0.5). It exposed a real
Summary-path gap: the §15.1 "Cylinder Size: Value known" lodging (the
Summary equivalent of the gov-API "Exact" descriptor) was unmapped, so the
extractor/mapper raised `UnmappedElmhurstLabel` and once that was mapped
the measured "Cylinder Volume (l)" was not threaded through, dropping the
cylinder storage loss (~468 kWh/yr) from (219) water heating. Wiring the
measured volume (cascade code 6 `_cylinder_volume_l_from_code`) closes the
whole cascade EXACTLY.
Like 000565 / the _rr cases / case 20 / 21 / 38, this fixture does NOT hand-
build the EpcPropertyData: it routes the Summary PDF through
ElmhurstSiteNotesExtractor + from_elmhurst_site_notes so the pin exercises
the WHOLE extractor + mapper + calculator pipeline.
Source: user-simulated PDFs at `sap worksheets/golden fixture debugging/
simulated case 39/`. The Summary is mirrored into the tracked
`backend/documents_parser/tests/fixtures/Summary_001431_case39.pdf` so the
test runs without depending on the unstaged workspace.
Worksheet pin targets (P960-0001-001431, "11a. SAP rating" / "12a. CO2
emissions" block — the UK-average-climate rating block our cascade
reproduces; the P960's separate postcode-climate EPC block (272)=1803.19 is
a known regional-climate gap, not a SAP-rating divergence):
- SAP value (un-rounded, before (258) integer rounding) = 36.6365 (band F)
- (272) Total CO2, kg/year = 2056.0731
Per [[feedback-zero-error-strict]] + [[feedback-continuous-sap-tolerance]]:
pins are abs <= 1e-3 against the worksheet PDF (printed to 4 dp).
"""
from __future__ import annotations
import re
import subprocess
from pathlib import Path
from typing import Final
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
from domain.sap10_calculator.rdsap.cert_to_inputs import cert_to_inputs
# parents[0]=worksheet/, [1]=sap10_calculator/, [2]=domain/, [3]=tests/,
# [4]=repo root.
_SUMMARY_PDF: Final[Path] = (
Path(__file__).resolve().parents[4]
/ "backend" / "documents_parser" / "tests" / "fixtures"
/ "Summary_001431_case39.pdf"
)
LINE_258_SAP_VALUE_CONTINUOUS: Final[float] = 36.6365
LINE_272_TOTAL_CO2_KG_PER_YR: Final[float] = 2056.0731
_PIN_ABS: Final[float] = 1e-3
def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]:
"""Convert a Summary PDF into the per-page text format the
ElmhurstSiteNotesExtractor expects (label/value token sequences).
Mirror of the helper in the other `_elmhurst_worksheet_*` fixtures.
"""
info = subprocess.run(
["pdfinfo", str(pdf_path)], capture_output=True, text=True, check=True,
).stdout
m = re.search(r"Pages:\s+(\d+)", info)
if m is None:
raise RuntimeError(f"Could not parse page count from {pdf_path}")
page_count = int(m.group(1))
pages: list[str] = []
for i in range(1, page_count + 1):
layout = subprocess.run(
[
"pdftotext", "-layout", "-f", str(i), "-l", str(i),
str(pdf_path), "-",
],
capture_output=True, text=True, check=True,
).stdout
tokens: list[str] = []
for line in layout.splitlines():
if not line.strip():
tokens.append("")
continue
parts = [p for p in re.split(r"\s{2,}", line.strip()) if p]
tokens.extend(parts)
pages.append("\n".join(tokens))
return pages
def build_epc() -> EpcPropertyData:
"""Route the simulated case-39 Summary through extractor + mapper.
No hand-built EpcPropertyData the extractor and mapper are part of
the test target."""
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_PDF)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
return EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
def test_case39_measured_volume_cylinder_reproduces_the_worksheet_sap_and_co2() -> None:
# Arrange — the full extractor -> mapper -> calculator pipeline on the
# simulated case-39 Summary (direct-electric room heaters + electric
# immersion DHW off a "Value known" 117 L measured-volume cylinder).
epc = build_epc()
# Act
result = calculate_sap_from_inputs(cert_to_inputs(epc))
# Assert — the SAP-rating block reproduces the worksheet exactly.
assert (
abs(result.sap_score_continuous - LINE_258_SAP_VALUE_CONTINUOUS)
<= _PIN_ABS
)
assert abs(result.co2_kg_per_yr - LINE_272_TOTAL_CO2_KG_PER_YR) <= _PIN_ABS

View file

@ -0,0 +1,116 @@
"""Mapper-driven cascade pin against the Elmhurst P960-0001-001431
"simulated case 43" worksheet a 2-storey mid-terrace deliberately built to
exercise every feature in one dwelling:
- a DETAILED room-in-roof on the Main BP (two slopes, two flat ceilings,
a party + an exposed gable, two common walls) exercises the
slope / stud / common_wall detailed-RR surfaces end-to-end;
- a MIXED-insulation multi-section roof (Main insulated 0.16/0.54/0.68/0.11
+ Extension uninsulated 2.30);
- a DRY-LINED extension solid wall (RdSAP 10 §5.8 Table 14 R=0.17:
solid brick 1.70 -> 1.32);
- a mains-gas boiler (SAP 102, control 2106 interlock) with a House-coal
solid-fuel SECONDARY (633, 60%) and a 210 L declared-loss cylinder.
This case was generated to settle the room-in-roof + mixed-roof + secondary
feature set with a single 1e-4 pin. It exposed two compensating Elmhurst-
extractor bugs (commit `a33707f8`) whose fabric errors nearly cancelled
(walls net -0.76 W/K, hidden behind a +0.05 SAP delta):
1. the main/extension wall "Dry-lining: Yes" line was read only for
ALTERNATIVE walls -> the dry-lined extension wall billed at the
un-adjusted 1.70 instead of 1.32;
2. the LAST room-in-roof surface row's per-row token scan over-read into
the next section -> Common Wall 2's default U silently zeroed
(1.90 -> 0.00).
With both fixed the whole §3 fabric and the SAP/CO2 reproduce EXACTLY.
Like 000565 / the _rr cases / case 20 / 21 / 38 / 39, this fixture does NOT
hand-build the EpcPropertyData: it routes the Summary PDF through
ElmhurstSiteNotesExtractor + from_elmhurst_site_notes so the pin exercises
the WHOLE extractor + mapper + calculator pipeline.
Source: user-simulated PDFs at `sap worksheets/golden fixture debugging/
simulated case 43/`. The Summary is mirrored into the tracked
`backend/documents_parser/tests/fixtures/Summary_001431_case43.pdf` so the
test runs without depending on the unstaged workspace.
Worksheet pin targets (P960-0001-001431, "11a. SAP rating" / "12a. CO2
emissions" block — the UK-average-climate rating block our cascade
reproduces):
- SAP value (un-rounded, before (258) integer rounding) = 73.2332 (band C)
- (272) Total CO2, kg/year = 3518.30
Per [[feedback-zero-error-strict]] + [[feedback-continuous-sap-tolerance]]:
pins are abs <= 1e-3 against the worksheet PDF (printed to 4 dp).
"""
from __future__ import annotations
import re
import subprocess
from pathlib import Path
from typing import Final
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
# parents[0]=worksheet/, [1]=sap10_calculator/, [2]=domain/, [3]=tests/,
# [4]=repo root.
_SUMMARY_PDF: Final[Path] = (
Path(__file__).resolve().parents[4]
/ "backend" / "documents_parser" / "tests" / "fixtures"
/ "Summary_001431_case43.pdf"
)
LINE_29A_WALLS_W_PER_K: Final[float] = 74.5800
# (30) = ΣA×U: FlatCeil1 4.3200 + FlatCeil2 6.9000 + Slope1 1.0200 +
# Slope2 0.1408 + roof Main 3.1200 + roof Ext1 (uninsulated) 23.0000.
LINE_30_ROOF_W_PER_K: Final[float] = 38.5008
LINE_33_FABRIC_W_PER_K: Final[float] = 172.7844
LINE_258_SAP_VALUE_CONTINUOUS: Final[float] = 73.2332
LINE_272_TOTAL_CO2_KG_PER_YR: Final[float] = 3518.3037
def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]:
"""Convert a Summary PDF into the per-page text format the
ElmhurstSiteNotesExtractor expects (label/value token sequences).
Mirror of the helper in the other `_elmhurst_worksheet_*` fixtures.
"""
info = subprocess.run(
["pdfinfo", str(pdf_path)], capture_output=True, text=True, check=True,
).stdout
m = re.search(r"Pages:\s+(\d+)", info)
if m is None:
raise RuntimeError(f"Could not parse page count from {pdf_path}")
page_count = int(m.group(1))
pages: list[str] = []
for i in range(1, page_count + 1):
layout = subprocess.run(
[
"pdftotext", "-layout", "-f", str(i), "-l", str(i),
str(pdf_path), "-",
],
capture_output=True, text=True, check=True,
).stdout
tokens: list[str] = []
for line in layout.splitlines():
if not line.strip():
tokens.append("")
continue
parts = [p for p in re.split(r"\s{2,}", line.strip()) if p]
tokens.extend(parts)
pages.append("\n".join(tokens))
return pages
def build_epc() -> EpcPropertyData:
"""Route the simulated case-43 Summary through extractor + mapper.
No hand-built EpcPropertyData the extractor and mapper are part of
the test target. This module is a pin PROVIDER (build_epc + LINE_*
constants, mirroring `_elmhurst_worksheet_001431_case6` / `_case21`);
the collected assertion lives in
`test_section_cascade_pins.test_case43_*`."""
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_PDF)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
return EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)

View file

@ -151,6 +151,83 @@ def test_roof_insulated_assumed_with_ni_thickness_uses_50mm_per_section_5_11_4()
assert result.roof_w_per_k == pytest.approx(68.0, abs=2.0)
def test_roof_insulation_location_rafters_drives_table16_column_2_api_int_path() -> None:
# Arrange — the gov-EPC API lodges roof_insulation_location as an
# integer (1 = Rafters per the empirically watertight corpus map). A
# roof insulated AT RAFTERS with 200 mm takes RdSAP 10 §5.11.2 Table 16
# (PDF p.43) column (2) → U=0.29, NOT the joists column (1) 0.21 — the
# rafter cavity is shallower so the same depth yields a higher U. The
# per-part location is the authoritative signal (the deduplicated
# epc.roofs[] list cannot attribute a location per building part).
# Geometry: 100 m² plan → roof area 100 m². rafters: 0.29 × 100 = 29
# W/K (vs the joists 0.21 × 100 = 21 W/K).
main = make_building_part(
construction_age_band="C",
wall_construction=3,
wall_insulation_type=4,
party_wall_construction=1,
roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=100.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=40.0, floor=0,
),
],
)
main.roof_insulation_location = 1 # gov-API int: Rafters
main.roof_insulation_thickness = "200mm"
epc = make_minimal_sap10_epc(
total_floor_area_m2=100.0,
country_code="ENG",
sap_building_parts=[main],
)
# Act
result = heat_transmission_from_cert(epc)
# Assert
assert abs(result.roof_w_per_k - 29.0) <= 1e-4
def test_rafter_insulation_thickness_field_drives_table16_column_2() -> None:
# Arrange — the gov-EPC API lodges rafter insulation in a DEDICATED
# `rafter_insulation_thickness` field (e.g. "225mm"), leaving
# `roof_insulation_thickness` None for rafter roofs (rafters aren't loft
# joists). heat_transmission must prefer the rafter field when the part
# is at-rafters (roof_insulation_location == 1) so the measured RdSAP 10
# §5.11.2 Table 16 column (2) row applies — 225 mm → U=0.25 — instead of
# the Table 18 col (2) unknown default (2.30). Cert 3100-8675-0922-8628
# (band E, rafters 225mm) went +8.93 -> +0.43 SAP on this field.
# Geometry: 100 m² plan → roof area 100 m². 0.25 × 100 = 25 W/K.
main = make_building_part(
construction_age_band="E",
wall_construction=4,
wall_insulation_type=4,
party_wall_construction=1,
roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=100.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=40.0, floor=0,
),
],
)
main.roof_insulation_location = 1 # Rafters
main.roof_insulation_thickness = None # gov leaves this None for rafters
main.rafter_insulation_thickness = "225mm" # the thickness lives here
epc = make_minimal_sap10_epc(
total_floor_area_m2=100.0,
country_code="ENG",
sap_building_parts=[main],
)
# Act
result = heat_transmission_from_cert(epc)
# Assert
assert abs(result.roof_w_per_k - 25.0) <= 1e-4
def test_lodged_roof_u_value_overrides_construction_default() -> None:
# Arrange — RdSAP 10 §5.1: where an element's U-value is known from the
# assessment (documentary evidence / the lodged RdSAP output) it is used

View file

@ -44,6 +44,7 @@ from tests.domain.sap10_calculator.worksheet import (
_elmhurst_worksheet_000516 as _w000516,
_elmhurst_worksheet_001431_case6 as _w001431_case6,
_elmhurst_worksheet_001431_case21 as _w001431_case21,
_elmhurst_worksheet_001431_case43 as _w001431_case43,
)
@ -328,6 +329,47 @@ def test_section_3_wall_u_by_thickness_case21_match_pdf() -> None:
)
def test_case43_detailed_rr_dryline_and_mixed_roof_match_pdf() -> None:
"""Full-feature pin for simulated case 43 — a 2-BP mid-terrace with a
DETAILED room-in-roof (slopes + flat ceilings + party/exposed gables +
common walls), a MIXED-insulation multi-section roof (Main insulated +
Extension uninsulated), a DRY-LINED extension solid wall (RdSAP 10 §5.8
Table 14: 1.70 -> 1.32), a mains-gas boiler (102, control 2106) and a
House-coal solid-fuel secondary (633). Exposed + regression-guards two
compensating Elmhurst-extractor bugs (commit a33707f8): the unread
main-wall dry-lining and the last-RR-row default-U over-read, whose
fabric errors nearly cancelled (walls net -0.76). With both fixed the
§3 fabric and the SAP-rating block reproduce the P960 exactly."""
# Arrange
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
epc = _w001431_case43.build_epc()
# Act
ht = heat_transmission_section_from_cert(epc)
result = calculate_sap_from_inputs(cert_to_inputs(epc))
# Assert — §3 fabric (the RR + dry-lining + mixed-roof fixes) and the
# SAP-rating block, each at abs=1e-4.
_pin(ht.walls_w_per_k, _w001431_case43.LINE_29A_WALLS_W_PER_K, "§3 (29a) case43")
_pin(ht.roof_w_per_k, _w001431_case43.LINE_30_ROOF_W_PER_K, "§3 (30) case43")
_pin(
ht.fabric_heat_loss_w_per_k,
_w001431_case43.LINE_33_FABRIC_W_PER_K,
"§3 (33) case43",
)
_pin(
result.sap_score_continuous,
_w001431_case43.LINE_258_SAP_VALUE_CONTINUOUS,
"(258) case43",
)
_pin(
result.co2_kg_per_yr,
_w001431_case43.LINE_272_TOTAL_CO2_KG_PER_YR,
"(272) case43",
)
def test_case6_main_2_emitter_and_control_extracted() -> None:
"""Simulated case 6's §14.1 Main Heating2 lodges its OWN emitter
("Underfloor Heating") and control ("SAP code 2110, ...") the two

View file

@ -676,6 +676,40 @@ def test_water_efficiency_monthly_via_equation_d1_weights_winter_summer_per_mont
assert monthly[0] == pytest.approx(num / denom, abs=1e-6)
def test_cylinder_storage_loss_uses_declared_loss_factor_times_temp_factor() -> None:
# Arrange — SAP 10.2 §4 branch a) (PDF p.136): when the manufacturer's
# declared cylinder loss factor (kWh/day) is lodged, storage loss
# (50) = (48) declared × (49) Table-2b temperature factor — replacing
# the Table 2 V×L×VF computation. Volume / insulation are unused.
from domain.sap10_calculator.worksheet.water_heating import (
cylinder_storage_loss_monthly_kwh,
cylinder_temperature_factor_table_2b,
)
declared = 1.72
tf: float = cylinder_temperature_factor_table_2b(
has_cylinder_thermostat=True, separately_timed_dhw=False,
)
# Act
result = cylinder_storage_loss_monthly_kwh(
volume_l=110.0, insulation_type="factory_insulated", thickness_mm=0.0,
has_cylinder_thermostat=True, separately_timed_dhw=False,
declared_loss_kwh_per_day=declared,
)
# Same declared loss with a different volume / insulation must give the
# same result — they are not consulted in the declared branch.
result_other_geometry = cylinder_storage_loss_monthly_kwh(
volume_l=300.0, insulation_type="loose_jacket", thickness_mm=50.0,
has_cylinder_thermostat=True, separately_timed_dhw=False,
declared_loss_kwh_per_day=declared,
)
# Assert — January (31 days) = declared × TF × 31; geometry-invariant.
assert abs(result[0] - declared * tf * 31) <= 1e-9
assert result == result_other_geometry
def test_000474_cert_to_inputs_hot_water_kwh_closes_within_1pct_post_slice_2() -> None:
"""Cert-round-trip conformance: 000474 mid-terrace combi-gas (PDF
HW fuel = 2291.78 kWh/yr). Slice 1 closed Σ(61) via PCDB Table 3b

View file

@ -67,8 +67,36 @@ _CORPUS = Path(
# energy were 5% high; actual SAP bias is +0.145).
# So closing demand over-estimates lifts BOTH the SAP gauge and PE/CO2; there is
# no one-slice factor fix. RATCHET any ceiling up when a slice tightens it.
_MIN_WITHIN_HALF_SAP = 0.65
_MAX_SAP_MAE = 1.08
#
# RAFTERS ROOF (RdSAP 10 §5.11.2 Table 16 col 2 + §5.11 Table 18 col 2): roofs
# insulated AT RAFTERS (roof_insulation_location == 1) are billed on the spec
# rafters column instead of the joists column, AND their thickness is read from
# the dedicated gov-API `rafter_insulation_thickness` field. That field was
# UNDECLARED on the schema, so `from_dict` dropped it — the rafter certs only
# *looked* redacted (roof EER 2-4 = insulated yet `roof_insulation_thickness`
# None); the thickness was there all along in `rafter_insulation_thickness`
# (e.g. "225mm"). Declaring + threading it recovers them: cert 3100-8675-0922
# (band E, rafters 225mm) +8.93 -> +0.43 SAP. Net of both changes within-0.5
# went 66.9% -> 67.0% (MAE 1.039 -> 1.025). Worksheet-validated to 1e-4 on
# simulated case 41 (measured rafters 200mm -> 0.29; rafters As-Built band F
# -> 0.68) and case 42 (rafters 50mm -> 0.88; rafters genuine-unknown band C
# -> 2.30 per Table 18 footnote 1 "applies for unknown and as built"). Do NOT
# revert the rafters column.
#
# DETAILED RR SLOPE + STUD WALL (RdSAP 10 §3.9 Figure 4 + §5.11.3 Table 17 cols
# 1/3, p.43-44): the gov API lodges a Detailed RR's sloping ceilings (slope_*)
# and stud/knee walls (stud_wall_*) alongside the gable + flat-ceiling surfaces.
# Those fields were UNDECLARED on the schema, so `from_dict` dropped them and the
# mapper built only gable + flat-ceiling — the (large) sloping roof and knee
# walls contributed ZERO heat loss -> undercounted RR fabric -> over-rate.
# Declaring + threading slope/stud into `detailed_surfaces` (cascade already
# routes both to the roof aggregate) recovered the 15-cert /tmp cohort from
# mean|err| 4.26 -> 2.05 (e.g. 0390-2538 +5.95 -> +3.56). Corpus within-0.5
# 67.3% -> 67.5% (MAE 1.020 -> 0.987). The follow-on `common_wall_*` Detailed-RR
# surfaces (billed at main-wall U, deducted from the §3.10.1 residual) took the
# 6-cert detailed-common-wall cohort 2.43 -> 1.25; corpus -> 67.6% (MAE 0.979).
_MIN_WITHIN_HALF_SAP = 0.67
_MAX_SAP_MAE = 0.99
_MAX_CO2_MAE_TONNES = 0.35 # t CO2 / yr vs co2_emissions_current
_MAX_PE_PER_M2_MAE = 16.0 # kWh / m2 / yr vs energy_consumption_current