diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index 55dd04d6..a8c9e596 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -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, ) diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_case39.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_case39.pdf new file mode 100644 index 00000000..137985f2 Binary files /dev/null and b/backend/documents_parser/tests/fixtures/Summary_001431_case39.pdf differ diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_case41_rafters.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_case41_rafters.pdf new file mode 100644 index 00000000..1f21db8c Binary files /dev/null and b/backend/documents_parser/tests/fixtures/Summary_001431_case41_rafters.pdf differ diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_case42_50mm_rafters.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_case42_50mm_rafters.pdf new file mode 100644 index 00000000..dbb3f4c8 Binary files /dev/null and b/backend/documents_parser/tests/fixtures/Summary_001431_case42_50mm_rafters.pdf differ diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_case42_unknown_rafters.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_case42_unknown_rafters.pdf new file mode 100644 index 00000000..c8cb35ce Binary files /dev/null and b/backend/documents_parser/tests/fixtures/Summary_001431_case42_unknown_rafters.pdf differ diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_case43.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_case43.pdf new file mode 100644 index 00000000..080fe618 Binary files /dev/null and b/backend/documents_parser/tests/fixtures/Summary_001431_case43.pdf differ diff --git a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py index e37db750..2104cdaa 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -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 diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index a3fd091b..baf1db00 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -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 diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 06771a7e..b3fc944f 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -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. diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index 75cb929d..054e9864 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -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 diff --git a/datatypes/epc/schema/rdsap_schema_21_0_0.py b/datatypes/epc/schema/rdsap_schema_21_0_0.py index e70d7b52..4f7e7e40 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_0.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_0.py @@ -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 diff --git a/datatypes/epc/schema/rdsap_schema_21_0_1.py b/datatypes/epc/schema/rdsap_schema_21_0_1.py index 48843b05..37714034 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -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 diff --git a/datatypes/epc/surveys/elmhurst_site_notes.py b/datatypes/epc/surveys/elmhurst_site_notes.py index 3d5b2b21..4614d33c 100644 --- a/datatypes/epc/surveys/elmhurst_site_notes.py +++ b/datatypes/epc/surveys/elmhurst_site_notes.py @@ -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 diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index d60daefd..f4a86295 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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( diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index ebdb2b52..83668e6f 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -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 diff --git a/domain/sap10_calculator/worksheet/water_heating.py b/domain/sap10_calculator/worksheet/water_heating.py index 56eb0cd9..bf0dd475 100644 --- a/domain/sap10_calculator/worksheet/water_heating.py +++ b/domain/sap10_calculator/worksheet/water_heating.py @@ -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) diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index ea1188db..b2e76b8f 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -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 diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index 1018ce30..8d4e3612 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -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. diff --git a/tests/datatypes/epc/domain/test_mapper_cylinder_size.py b/tests/datatypes/epc/domain/test_mapper_cylinder_size.py new file mode 100644 index 00000000..b8d03a9a --- /dev/null +++ b/tests/datatypes/epc/domain/test_mapper_cylinder_size.py @@ -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") diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case39.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case39.py new file mode 100644 index 00000000..88547fbc --- /dev/null +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case39.py @@ -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 diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case43.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case43.py new file mode 100644 index 00000000..2e4a7ae3 --- /dev/null +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case43.py @@ -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) diff --git a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py index 3153f1bc..d1ed0c92 100644 --- a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py +++ b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py @@ -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 diff --git a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py index b8f166ab..4e7336e3 100644 --- a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py +++ b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py @@ -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 diff --git a/tests/domain/sap10_calculator/worksheet/test_water_heating.py b/tests/domain/sap10_calculator/worksheet/test_water_heating.py index 81086c46..5a96deb5 100644 --- a/tests/domain/sap10_calculator/worksheet/test_water_heating.py +++ b/tests/domain/sap10_calculator/worksheet/test_water_heating.py @@ -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 diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index 13e9f6e6..aa05c9af 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -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