diff --git a/packages/domain/src/domain/ml/rdsap_uvalues.py b/packages/domain/src/domain/ml/rdsap_uvalues.py index a931cc7b..353cf267 100644 --- a/packages/domain/src/domain/ml/rdsap_uvalues.py +++ b/packages/domain/src/domain/ml/rdsap_uvalues.py @@ -45,19 +45,20 @@ def _measured_u_from_description(description: Optional[str]) -> Optional[float]: return None -def _cavity_described_as_filled(description: Optional[str]) -> bool: - """RdSAP encodes "as-built / assumed" insulation as - `wall_insulation_type = 4`, which our cascade would otherwise treat - as uninsulated. The description disambiguates: "Cavity wall, as - built, insulated (assumed)" or "...partial insulation (assumed)" - means the assessor has determined the cavity is filled (or partly - filled), without lodging the thickness. Both route to the Filled- - cavity row of Table 6, matching the legacy production recommendation - engine's interpretation in `recommendations/rdsap_tables.py`. +def _described_as_insulated(description: Optional[str]) -> bool: + """True when the surveyor description asserts insulation despite the + `wall_insulation_type=4` ("as-built / assumed") code saying + otherwise. Looks for "insulated" or "partial insulation" substrings, + with "no insulation" taking precedence as a hard negation. - "no insulation" markers take precedence to avoid the substring - "insulated" matching "no insulation" (it doesn't here, but the - explicit negative check makes the contract clear). + Two consumers: + - `u_wall` uses this to route cavity walls to the Filled-cavity row + of Table 6 (in lieu of the bucketed cascade). + - `heat_transmission_from_cert` uses this to set `wall_ins_present` + for non-cavity walls so the 50 mm bucket routing fires per the + RdSAP 10 Table 6 footnote ("If a wall is known to have additional + insulation but the insulation thickness is unknown, use the row + in the table for 50 mm insulation"). """ if description is None: return False @@ -145,11 +146,18 @@ def _age_index(age_band: Optional[str]) -> int: def _insulation_bucket(thickness_mm: Optional[int], insulation_present: bool) -> int: """Pick the nearest tabulated insulation column (0/50/100/150/200 mm). - Spec §6.3: when wall is known insulated but thickness unknown, use the - 50 mm row. + RdSAP 10 Table 6 footnote: "If a wall is known to have additional + insulation but the insulation thickness is unknown, use the row in + the table for 50 mm insulation". The cert encodes "thickness + unknown" as either a missing field (`thickness_mm=None`) or the "NI" + sentinel which `_parse_thickness_mm` returns as 0. Both must route + to the 50 mm bucket when `insulation_present=True`; when not + present, the as-built (bucket 0) row applies regardless. """ + if insulation_present and (thickness_mm is None or thickness_mm == 0): + return 50 if thickness_mm is None: - return 50 if insulation_present else 0 + return 0 if thickness_mm < 25: return 0 if thickness_mm < 75: @@ -347,7 +355,7 @@ def u_wall( wall_type = _wall_type_from_description(description) or _DEFAULT_WALL_BY_AGE.get(band, WALL_CAVITY) if wall_type == WALL_CAVITY and ( wall_insulation_type == WALL_INSULATION_FILLED_CAVITY - or _cavity_described_as_filled(description) + or _described_as_insulated(description) ): return _CAVITY_FILLED_ENG[age_idx] bucket = _insulation_bucket(insulation_thickness_mm, insulation_present) diff --git a/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py b/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py index be26510b..0ff57f22 100644 --- a/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py +++ b/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py @@ -80,6 +80,29 @@ def test_u_wall_description_with_malformed_transmittance_falls_through_to_cascad assert result == pytest.approx(0.60, abs=0.001) +def test_u_wall_solid_brick_with_ni_thickness_uses_50mm_row_per_table6_footnote() -> None: + # Arrange — 685 corpus certs lodge solid-brick walls with + # wall_insulation_type ∈ {1 external, 3 internal} and + # wall_insulation_thickness="NI" (Not Indicated). RdSAP 10 Table 6 + # footnote: "If a wall is known to have additional insulation but + # the insulation thickness is unknown, use the row in the table for + # 50 mm insulation." Our `_parse_thickness_mm("NI")` returns 0, which + # combined with `insulation_present=True` must now route to the 50 mm + # bucket (U=0.55 at A-E), not the as-built bucket (U=1.7). + + # Act + result = u_wall( + country=Country.ENG, + age_band="B", + construction=WALL_SOLID_BRICK, + insulation_thickness_mm=0, + insulation_present=True, + ) + + # Assert — Stone/solid brick with 50 mm row at band B = 0.55. + assert result == pytest.approx(0.55, abs=0.001) + + def test_u_wall_cavity_as_built_insulated_assumed_routes_to_filled_cavity_row() -> None: # Arrange — 1 171 corpus certs (~4% of scanned bulk) lodge # wall_insulation_type=4 ("as-built / assumed") together with the diff --git a/packages/domain/src/domain/sap/worksheet/heat_transmission.py b/packages/domain/src/domain/sap/worksheet/heat_transmission.py index d4d49f40..f3b75431 100644 --- a/packages/domain/src/domain/sap/worksheet/heat_transmission.py +++ b/packages/domain/src/domain/sap/worksheet/heat_transmission.py @@ -29,6 +29,7 @@ from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapBuildingP from domain.ml.rdsap_uvalues import ( Country, WALL_UNKNOWN, + _described_as_insulated, thermal_bridging_y, u_door, u_floor, @@ -197,7 +198,15 @@ def heat_transmission_from_cert( wall_construction = _int_or_none(part.wall_construction) wall_ins_type = _int_or_none(part.wall_insulation_type) wall_ins_thickness = _parse_thickness_mm(part.wall_insulation_thickness) - wall_ins_present = wall_ins_type is not None and wall_ins_type != _WALL_INSULATION_NONE + # Per RdSAP 10 Table 6 footnote, a wall with "insulated (assumed)" + # or "partial insulation (assumed)" in its description has retrofit + # insulation the assessor hasn't measured the thickness of — even + # when wall_insulation_type=4 ("as-built / assumed"). Treat as + # present so the 50 mm bucket routes correctly. + wall_ins_present = ( + (wall_ins_type is not None and wall_ins_type != _WALL_INSULATION_NONE) + or _described_as_insulated(wall_description) + ) party_construction = _int_or_none(part.party_wall_construction) roof_thickness = _parse_thickness_mm(getattr(part, "roof_insulation_thickness", None)) floor_ins_thickness = _parse_thickness_mm(getattr(part, "floor_insulation_thickness", None)) diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py b/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py index 55536824..d216ecd0 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py +++ b/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py @@ -31,6 +31,96 @@ from domain.sap.worksheet.heat_transmission import ( ) +def test_solid_brick_as_built_insulated_assumed_uses_50mm_row_per_table6_footnote() -> None: + # Arrange — 128 corpus certs lodge solid-brick walls with + # wall_insulation_type=4 ("as-built / assumed") AND description + # "Solid brick, as built, insulated (assumed)". The description + # signals retrofit insulation that the assessor hasn't measured the + # thickness of; RdSAP 10 Table 6 footnote routes this to the 50 mm + # row. Without the description signal, type=4 alone would set + # wall_ins_present=False and the cascade would return the as-built + # U=1.7. With it, U = 0.55 at band B. + # Geometry: 100 m² floor, 40 m perimeter, 2.5 m height, single + # storey → gross_wall = 100 m². walls_w_per_k expected = 0.55 × 100 + # = 55 W/K. + main = make_building_part( + identifier="Main Dwelling", + construction_age_band="B", + 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, + ), + ], + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=100.0, + country_code="ENG", + sap_building_parts=[main], + ) + epc.walls = [ + EnergyElement( + description="Solid brick, as built, insulated (assumed)", + energy_efficiency_rating=3, + environmental_efficiency_rating=3, + ), + ] + + # Act + result = heat_transmission_from_cert(epc) + + # Assert + assert result.walls_w_per_k == pytest.approx(55.0, abs=1.0) + + +def test_solid_brick_as_built_no_insulation_assumed_stays_at_table6_as_built_row() -> None: + # Arrange — the dominant solid-brick population (4 911 corpus certs) + # lodges "Solid brick, as built, no insulation (assumed)" with + # wall_insulation_type=4. The description-based ins_present override + # must NOT false-positive on the "insulation" substring inside + # "no insulation". This regression test asserts the override + # respects the "no insulation" negation marker so this population + # continues to route to the Solid-brick-as-built row of Table 6 + # (U=1.7 at band B). + + main = make_building_part( + identifier="Main Dwelling", + construction_age_band="B", + 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, + ), + ], + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=100.0, + country_code="ENG", + sap_building_parts=[main], + ) + epc.walls = [ + EnergyElement( + description="Solid brick, as built, no insulation (assumed)", + energy_efficiency_rating=2, + environmental_efficiency_rating=2, + ), + ] + + # Act + result = heat_transmission_from_cert(epc) + + # Assert — U=1.7 × 100 m² gross wall = 170 W/K. + assert result.walls_w_per_k == pytest.approx(170.0, abs=1.0) + + def test_cavity_as_built_insulated_assumed_uses_filled_cavity_row() -> None: # Arrange — the modal RdSAP encoding for a retrofitted-cavity dwelling: # wall_construction=4 (cavity), wall_insulation_type=4 (as-built /