diff --git a/packages/domain/src/domain/ml/rdsap_uvalues.py b/packages/domain/src/domain/ml/rdsap_uvalues.py index 1929ba3c..a931cc7b 100644 --- a/packages/domain/src/domain/ml/rdsap_uvalues.py +++ b/packages/domain/src/domain/ml/rdsap_uvalues.py @@ -45,6 +45,28 @@ 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`. + + "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). + """ + if description is None: + return False + desc = description.lower() + if "no insulation" in desc: + return False + return "insulated" in desc or "partial insulation" in desc + + # --------------------------------------------------------------------------- # Country # --------------------------------------------------------------------------- @@ -323,9 +345,9 @@ def u_wall( wall_type = construction else: 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 + if wall_type == WALL_CAVITY and ( + wall_insulation_type == WALL_INSULATION_FILLED_CAVITY + or _cavity_described_as_filled(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 78a1601f..be26510b 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,80 @@ 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_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 + # description "Cavity wall, as built, insulated (assumed)". The + # assessor is saying: this cavity is filled, but I haven't measured + # the thickness. Spec footnote on Table 6 covers this: "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" — but + # legacy convention (used by the production recommendation engine) + # is to route this to the Filled-cavity row, U = 0.7 at A-E. We + # follow the legacy convention here for parity with the cert assessor. + + # Act + result = u_wall( + country=Country.ENG, + age_band="E", + construction=WALL_CAVITY, + insulation_thickness_mm=None, + insulation_present=False, # type=4 maps to wall_ins_present=False + wall_insulation_type=4, + description="Cavity wall, as built, insulated (assumed)", + ) + + # Assert + assert result == pytest.approx(0.7, abs=0.001) + + +def test_u_wall_cavity_as_built_no_insulation_stays_at_table6_cavity_as_built_row() -> None: + # Arrange — the same wall_insulation_type=4 ("as-built / assumed") + # cert population also contains 686 "Cavity wall, as built, no + # insulation (assumed)" entries which must continue to route to the + # Cavity-as-built row of Table 6 (U=1.5 at band E). The "no + # insulation" substring marker takes precedence over the + # "insulated"-substring filled-cavity rule, so this case is + # disambiguated from "Cavity wall, as built, insulated (assumed)". + + # Act + result = u_wall( + country=Country.ENG, + age_band="E", + construction=WALL_CAVITY, + insulation_thickness_mm=None, + insulation_present=False, + wall_insulation_type=4, + description="Cavity wall, as built, no insulation (assumed)", + ) + + # Assert + assert result == pytest.approx(1.5, abs=0.001) + + +def test_u_wall_cavity_as_built_partial_insulation_routes_to_filled_cavity_row() -> None: + # Arrange — 147 corpus certs lodge "Cavity wall, as built, partial + # insulation (assumed)" with wall_insulation_type=4. The legacy + # production map (recommendations/rdsap_tables.py:753) routes these + # to "Filled cavity" — same destination as the "insulated (assumed)" + # case. We match that interpretation for parity with the cert + # assessor and the production recommendation engine. + + # Act + result = u_wall( + country=Country.ENG, + age_band="D", # 1950-1966 — typical partial-fill retrofit cohort + construction=WALL_CAVITY, + insulation_thickness_mm=None, + insulation_present=False, + wall_insulation_type=4, + description="Cavity wall, as built, partial insulation (assumed)", + ) + + # Assert — Filled-cavity row at band D = 0.7 W/m²K. + assert result == pytest.approx(0.7, abs=0.001) + + def test_u_wall_description_without_transmittance_phrase_routes_through_cascade() -> None: # Arrange — the measured-U dispatcher must only fire when the # description contains the "thermal transmittance" phrase. The 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 da0437c0..55536824 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,50 @@ from domain.sap.worksheet.heat_transmission import ( ) +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 / + # assumed), and walls[0].description = "Cavity wall, as built, + # insulated (assumed)". The assessor has determined the cavity is + # filled but hasn't lodged a thickness. Without the description-based + # dispatcher, the cascade would return U=1.5; with it, the Filled- + # cavity row of Table 6 applies: U=0.7 at band E. + # Geometry: 100 m² floor, 40 m perimeter, 2.5 m height, single storey + # → gross_wall = 100 m². walls_w_per_k expected = 0.7 × 100 = 70 W/K. + main = make_building_part( + identifier="Main Dwelling", + 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, + ), + ], + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=100.0, + country_code="ENG", + sap_building_parts=[main], + ) + epc.walls = [ + EnergyElement( + description="Cavity wall, as built, insulated (assumed)", + energy_efficiency_rating=4, + environmental_efficiency_rating=4, + ), + ] + + # Act + result = heat_transmission_from_cert(epc) + + # Assert + assert result.walls_w_per_k == pytest.approx(70.0, abs=1.0) + + def test_walls_description_measured_transmittance_overrides_construction_cascade() -> None: # Arrange — a full-SAP (not RdSAP) cert lodges the wall U-value # directly in walls[i].description ("Average thermal transmittance