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"), party_wall_type=self._local_str(lines, "Party Wall Type"),
thickness_mm=thickness_mm, thickness_mm=thickness_mm,
insulation_thickness_mm=insulation_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), alternative_walls=self._alternative_walls_from_lines(lines),
# Summary §7 lodges the per-BP "Curtain Wall Age" line only # Summary §7 lodges the per-BP "Curtain Wall Age" line only
# when `Type: CW Curtain Wall`. Per RdSAP 10 §5.18 (PDF # 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]): if self._is_next_rir_row(lines[j]):
break break
tokens.append(lines[j]) 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 # First two numerics = length, height
length = float(tokens[0]) if tokens and self._RIR_NUMERIC_RE.match(tokens[0]) else 0.0 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 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, party_wall_type=ext_party_wall_type,
thickness_mm=main_walls.thickness_mm, thickness_mm=main_walls.thickness_mm,
insulation_thickness_mm=main_walls.insulation_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), alternative_walls=self._alternative_walls_from_lines(wall_lines),
) )
else: else:
@ -1528,6 +1547,18 @@ class ElmhurstSiteNotesExtractor:
first = cylinder_ins_thickness_raw.split()[0] first = cylinder_ins_thickness_raw.split()[0]
if first.isdigit(): if first.isdigit():
cylinder_insulation_thickness_mm = int(first) 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_thermostat_raw = self._local_val(
cylinder_lines, "Cylinder Thermostat", cylinder_lines, "Cylinder Thermostat",
) )
@ -1560,6 +1591,7 @@ class ElmhurstSiteNotesExtractor:
cylinder_size_label=cylinder_size_label, cylinder_size_label=cylinder_size_label,
cylinder_insulation_label=cylinder_insulation_label, cylinder_insulation_label=cylinder_insulation_label,
cylinder_insulation_thickness_mm=cylinder_insulation_thickness_mm, cylinder_insulation_thickness_mm=cylinder_insulation_thickness_mm,
cylinder_volume_measured_l=cylinder_volume_measured_l,
cylinder_thermostat=cylinder_thermostat, cylinder_thermostat=cylinder_thermostat,
immersion_type=immersion_type, 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_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_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_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 # GOV.UK EPB API JSON for cert 001479 — the API-path counterpart of the
# Summary_001479.pdf fixture. Together they drive the API ≡ Summary # 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 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: def test_summary_001431_topfloor_extracts_main_property_age_band() -> None:
# Arrange — the gas-boiler-upgrade recommendation "after" Summary # Arrange — the gas-boiler-upgrade recommendation "after" Summary
# renders "3.0 Date Built:" glued to its "Main Property" row header # 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 None # int from API; str from site notes
) )
cylinder_insulation_thickness_mm: Optional[int] = None 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. # SAP10 hot-water demand inputs from sap_heating.
number_baths: Optional[int] = None number_baths: Optional[int] = None
number_baths_wwhrs: Optional[int] = None number_baths_wwhrs: Optional[int] = None
@ -499,7 +505,7 @@ class SapBuildingPart:
building_part_number: Optional[int] = ( building_part_number: Optional[int] = (
None # Not sure how we get this from site notes 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 wall_thickness_mm: Optional[int] = None
# Union[str, int]: a numeric mm value when the API lodges # Union[str, int]: a numeric mm value when the API lodges
# `wall_insulation_thickness == "measured"` (resolved from the # `wall_insulation_thickness == "measured"` (resolved from the
@ -552,6 +558,13 @@ class SapBuildingPart:
roof_insulation_thickness: Optional[Union[str, int]] = ( roof_insulation_thickness: Optional[Union[str, int]] = (
None # TODO: make enum/mapping? 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 sap_room_in_roof: Optional[SapRoomInRoof] = None
# Per RdSAP 10 §5.18 (PDF p.48), a curtain wall (wall_construction # 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 # =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.roof_insulation_thickness,
bp.construction_age_band, 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=( sap_room_in_roof=(
SapRoomInRoof( SapRoomInRoof(
floor_area=_measurement_value(bp.sap_room_in_roof.floor_area), floor_area=_measurement_value(bp.sap_room_in_roof.floor_area),
@ -1220,6 +1225,11 @@ class EpcPropertyDataMapper:
bp.roof_insulation_thickness, bp.roof_insulation_thickness,
bp.construction_age_band, 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=( sap_room_in_roof=(
SapRoomInRoof( SapRoomInRoof(
# ADR-0028: floor_area is usually a Measurement but # ADR-0028: floor_area is usually a Measurement but
@ -1470,6 +1480,11 @@ class EpcPropertyDataMapper:
bp.roof_insulation_thickness, bp.roof_insulation_thickness,
bp.construction_age_band, 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=( sap_room_in_roof=(
SapRoomInRoof( SapRoomInRoof(
floor_area=_measurement_value( floor_area=_measurement_value(
@ -1573,6 +1588,7 @@ class EpcPropertyDataMapper:
== "true", == "true",
cylinder_size=schema.sap_heating.cylinder_size, cylinder_size=schema.sap_heating.cylinder_size,
cylinder_volume_measured_l=schema.sap_heating.cylinder_size_measured, 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_code=schema.sap_heating.water_heating_code,
water_heating_fuel=schema.sap_heating.water_heating_fuel, water_heating_fuel=schema.sap_heating.water_heating_fuel,
immersion_heating_type=schema.sap_heating.immersion_heating_type, immersion_heating_type=schema.sap_heating.immersion_heating_type,
@ -1705,6 +1721,10 @@ class EpcPropertyDataMapper:
bp.construction_age_band, bp.construction_age_band,
bp.sloping_ceiling_insulation_thickness, 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 # RdSAP 10 §5.1 — the assessor's lodged roof/wall/floor U
# overrides the §5.6/§5.7/§5.11 construction-default cascade # overrides the §5.6/§5.7/§5.11 construction-default cascade
# (gov open data can redact the backing insulation). # (gov open data can redact the backing insulation).
@ -1883,6 +1903,7 @@ class EpcPropertyDataMapper:
== "true", == "true",
cylinder_size=schema.sap_heating.cylinder_size, cylinder_size=schema.sap_heating.cylinder_size,
cylinder_volume_measured_l=schema.sap_heating.cylinder_size_measured, 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_code=schema.sap_heating.water_heating_code,
water_heating_fuel=schema.sap_heating.water_heating_fuel, water_heating_fuel=schema.sap_heating.water_heating_fuel,
immersion_heating_type=schema.sap_heating.immersion_heating_type, immersion_heating_type=schema.sap_heating.immersion_heating_type,
@ -2014,6 +2035,10 @@ class EpcPropertyDataMapper:
bp.construction_age_band, bp.construction_age_band,
bp.sloping_ceiling_insulation_thickness, 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 # RdSAP 10 §5.1 — the assessor's lodged roof/wall/floor U
# overrides the §5.6/§5.7/§5.11 construction-default cascade # overrides the §5.6/§5.7/§5.11 construction-default cascade
# (gov open data can redact the backing insulation). # (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: 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)) area = _round_half_up_2dp(float(length), float(height))
surfaces.append(SapRoomInRoofSurface(kind=gable_kind, area_m2=area)) 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 ( if (
details.flat_ceiling_length_1 is not None details.flat_ceiling_length_1 is not None
and details.flat_ceiling_height_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_is_basement=_elmhurst_wall_is_basement(walls.wall_type),
wall_insulation_type=_elmhurst_wall_insulation_int(walls.insulation), wall_insulation_type=_elmhurst_wall_insulation_int(walls.insulation),
wall_thickness_measured=not walls.thickness_unknown, 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( party_wall_construction=_elmhurst_party_wall_construction_int(
walls.party_wall_type walls.party_wall_type
), ),
@ -5942,6 +6026,13 @@ def _elmhurst_cylinder_size_code(
Table 28 page 55.""" Table 28 page 55."""
if not cylinder_present or cylinder_size_label is None: if not cylinder_present or cylinder_size_label is None:
return 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 cylinder_size_label == "No Access":
if water_heating_fuel_label is None or meter_type_label is None: if water_heating_fuel_label is None or meter_type_label is None:
raise UnmappedElmhurstLabel( raise UnmappedElmhurstLabel(
@ -6587,6 +6678,14 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating:
), ),
cylinder_insulation_type=cylinder_insulation_type_field, cylinder_insulation_type=cylinder_insulation_type_field,
cylinder_insulation_thickness_mm=cylinder_insulation_thickness_mm_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 # Cascade reads `cylinder_thermostat == "Y"` (string compare) per
# `cert_to_inputs.py:2252` / `:2218`. Map the bool to the Y/N # `cert_to_inputs.py:2252` / `:2218`. Map the bool to the Y/N
# string the cascade expects; None when no cylinder is present. # 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. # worksheet uses per-bp sums and the mapper now mirrors that.
assert result.total_floor_area_m2 == 45.82 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 --- # --- property flags ---
def test_solar_water_heating(self, result: EpcPropertyData) -> None: 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.number_baths == expected_baths
assert result.sap_heating.mixer_shower_count == expected_mixers 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 # RdSAP 10 §10.5 Table 28 — measured cylinder volume (litres), lodged
# only when `cylinder_size` is the "Exact" descriptor (code 6). # only when `cylinder_size` is the "Exact" descriptor (code 6).
cylinder_size_measured: Optional[int] = None 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 @dataclass
@ -204,6 +209,31 @@ class RoomInRoofDetails:
flat_ceiling_height_1: Optional[float] = None flat_ceiling_height_1: Optional[float] = None
flat_ceiling_insulation_type_1: Optional[int] = None flat_ceiling_insulation_type_1: Optional[int] = None
flat_ceiling_insulation_thickness_1: Optional[str] = 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 @dataclass
@ -265,6 +295,14 @@ class SapBuildingPart:
# the slope as uninsulated (Table 16 / Table 18 fallback). Consumed by # the slope as uninsulated (Table 16 / Table 18 fallback). Consumed by
# `_api_resolve_sloping_ceiling_thickness` → Table 17 column (1a). # `_api_resolve_sloping_ceiling_thickness` → Table 17 column (1a).
sloping_ceiling_insulation_thickness: Optional[Union[str, int]] = None 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, # Lodged roof U-value (W/m²K) — the assessor's RdSAP-assessed roof U,
# authoritative when the open data redacts the backing insulation # authoritative when the open data redacts the backing insulation
# thickness. Consumed by `heat_transmission` as a §5.1 documentary-evidence # 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 # RdSAP 10 §10.5 Table 28 — measured cylinder volume (litres), lodged
# only when `cylinder_size` is the "Exact" descriptor (code 6). # only when `cylinder_size` is the "Exact" descriptor (code 6).
cylinder_size_measured: Optional[int] = None 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 @dataclass
@ -240,6 +246,36 @@ class RoomInRoofDetails:
flat_ceiling_height_1: Optional[float] = None flat_ceiling_height_1: Optional[float] = None
flat_ceiling_insulation_type_1: Optional[int] = None flat_ceiling_insulation_type_1: Optional[int] = None
flat_ceiling_insulation_thickness_1: Optional[str] = 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 @dataclass
@ -303,6 +339,14 @@ class SapBuildingPart:
# the slope as uninsulated (Table 16 / Table 18 fallback). Consumed by # the slope as uninsulated (Table 16 / Table 18 fallback). Consumed by
# `_api_resolve_sloping_ceiling_thickness` → Table 17 column (1a). # `_api_resolve_sloping_ceiling_thickness` → Table 17 column (1a).
sloping_ceiling_insulation_thickness: Optional[Union[str, int]] = None 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 # 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 # gov open data can redact the backing insulation thickness, so this is the
# authoritative per-element value; consumed by `heat_transmission` as a # 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 # "Insulation Thickness" / "100 mm" line pair when a composite or
# retrofit insulation is recorded. None when the PDF omits the line. # retrofit insulation is recorded. None when the PDF omits the line.
insulation_thickness_mm: Optional[int] = None 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 # Per-BP curtain-wall installation age, lodged in Summary §7 as
# "Curtain Wall Age" when `wall_type` is "CW Curtain Wall". Per # "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 # 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 cylinder_insulation_label: Optional[str] = None
# §15.1 "Insulation Thickness" lodging in mm (an integer or None). # §15.1 "Insulation Thickness" lodging in mm (an integer or None).
cylinder_insulation_thickness_mm: Optional[int] = 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 # §15.1 "Cylinder Thermostat" lodging (Yes / No). False or absent
# keeps the cascade's no-thermostat Table 2b temperature factor. # keeps the cascade's no-thermostat Table 2b temperature factor.
cylinder_thermostat: Optional[bool] = None 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 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: def _thermal_mass_parameter_kj_per_m2_k(epc: EpcPropertyData) -> float:
"""RdSAP 10 §5.16 Table 22 (PDF p.48) — thermal mass parameter from """RdSAP 10 §5.16 Table 22 (PDF p.48) — thermal mass parameter from
the MAIN building's wall construction. the MAIN building's wall construction.
@ -6489,7 +6505,31 @@ def _cylinder_storage_loss_override(
if not epc.has_hot_water_cylinder: if not epc.has_hot_water_cylinder:
return None return None
sh = epc.sap_heating 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) 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: if volume_l is None:
return None return None
insulation_label = _cylinder_storage_loss_insulation_label( insulation_label = _cylinder_storage_loss_insulation_label(

View file

@ -41,7 +41,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal 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 ( from datatypes.epc.domain.epc_property_data import (
EpcPropertyData, EpcPropertyData,
@ -349,6 +349,27 @@ def _joined_descriptions(elements: list[Any]) -> Optional[str]:
return " | ".join(parts) 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]: def _joined_main_roof_descriptions(roofs: list[Any]) -> Optional[str]:
"""Join roof descriptions for the MAIN (non-RR) roof U-value, dropping """Join roof descriptions for the MAIN (non-RR) roof U-value, dropping
"Roof room(s)" entries. "Roof room(s)" entries.
@ -770,7 +791,22 @@ def heat_transmission_from_cert(
or _described_as_retrofit_insulated(wall_description) or _described_as_retrofit_insulated(wall_description)
) )
party_construction = _int_or_none(part.party_wall_construction) 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) 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) roof_thickness = _parse_thickness_mm(raw_roof_thickness)
floor_ins_thickness = _parse_thickness_mm(getattr(part, "floor_insulation_thickness", None)) 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" # col (1) per the cohort, so only the literal "sloping ceiling"
# string triggers the col (3) age-band default in `u_roof`. # string triggers the col (3) age-band default in `u_roof`.
is_pitched_sloping_ceiling = "sloping ceiling" in roof_type_lower 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 # 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 # 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 # 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, thickness_mm: float,
has_cylinder_thermostat: bool, has_cylinder_thermostat: bool,
separately_timed_dhw: bool, separately_timed_dhw: bool,
declared_loss_kwh_per_day: Optional[float] = None,
) -> tuple[float, ...]: ) -> tuple[float, ...]:
"""SAP 10.2 §4 line (56)m water storage loss per spec (PDF p.136): """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) 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) (56)m = (55) × n_m (n_m = days in month)
Returns 12 monthly values in calendar order Jan..Dec. The cert's 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 solar storage is present in the vessel callers handling solar
storage must adjust further per `(57)m = (56)m × [(47) - Vs] / (47)`. 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( TF = cylinder_temperature_factor_table_2b(
has_cylinder_thermostat=has_cylinder_thermostat, has_cylinder_thermostat=has_cylinder_thermostat,
separately_timed_dhw=separately_timed_dhw, 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) 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, "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. # 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 # 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 # 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_flat_roof: bool = False,
is_sloping_ceiling: bool = False, is_sloping_ceiling: bool = False,
is_pitched_sloping_ceiling: bool = False, is_pitched_sloping_ceiling: bool = False,
insulation_at_rafters: bool = False,
) -> float: ) -> float:
"""RdSAP10 roof U-value in W/m^2K, never null. """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 (code 5) are deliberately excluded they stay on column (1) per the
cohort evidence above. Worksheet-validated by simulated case 15 (the cohort evidence above. Worksheet-validated by simulated case 15 (the
7536 replica): Ext1 band L 0.18, Ext2 band F 0.68. 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) measured = _measured_u_from_description(description)
if measured is not None: if measured is not None:
# Full-SAP cert lodges a measured roof U-value in the description # 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 # genuine "no insulation" lodgement, which keeps 2.30 (below). The
# discriminator is the deterministic "Unknown" text RdSAP renders # discriminator is the deterministic "Unknown" text RdSAP renders
# for an undetermined-thickness observation. # 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) return table_18.get(age_band.upper(), 0.4)
if ( if (
is_sloping_ceiling is_sloping_ceiling
@ -877,9 +926,10 @@ def u_roof(
# uninsulated 2.30 W/m²K. # uninsulated 2.30 W/m²K.
return 0.68 # Table 16 row 50, "Insulation at joists at ceiling level" return 0.68 # Table 16 row 50, "Insulation at joists at ceiling level"
if insulation_thickness_mm is not None: if insulation_thickness_mm is not None:
# nearest tabulated thickness <= supplied # nearest tabulated thickness <= supplied (Table 16 column (1)
u = _ROOF_BY_THICKNESS[0][1] # joists or column (2) rafters per `insulation_at_rafters`)
for t, val in _ROOF_BY_THICKNESS: u = roof_by_thickness[0][1]
for t, val in roof_by_thickness:
if insulation_thickness_mm >= t: if insulation_thickness_mm >= t:
u = val u = val
return u return u
@ -923,7 +973,7 @@ def u_roof(
return _FLAT_ROOF_BY_AGE.get(age_band.upper(), 0.4) return _FLAT_ROOF_BY_AGE.get(age_band.upper(), 0.4)
if is_flat_roof: if is_flat_roof:
return _FLAT_ROOF_BY_AGE.get(age_band.upper(), 0.4) 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 # 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) 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: def test_u_roof_unknown_age_band_falls_back_to_mid_range() -> None:
# Arrange — nothing known. # 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) 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: 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 # 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 # 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_000516 as _w000516,
_elmhurst_worksheet_001431_case6 as _w001431_case6, _elmhurst_worksheet_001431_case6 as _w001431_case6,
_elmhurst_worksheet_001431_case21 as _w001431_case21, _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: def test_case6_main_2_emitter_and_control_extracted() -> None:
"""Simulated case 6's §14.1 Main Heating2 lodges its OWN emitter """Simulated case 6's §14.1 Main Heating2 lodges its OWN emitter
("Underfloor Heating") and control ("SAP code 2110, ...") the two ("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) 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: 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 """Cert-round-trip conformance: 000474 mid-terrace combi-gas (PDF
HW fuel = 2291.78 kWh/yr). Slice 1 closed Σ(61) via PCDB Table 3b 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). # 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 # 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. # 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_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 _MAX_PE_PER_M2_MAE = 16.0 # kWh / m2 / yr vs energy_consumption_current