diff --git a/packages/domain/src/domain/ml/envelope.py b/packages/domain/src/domain/ml/envelope.py index 2fa1f9a7..5a4d020c 100644 --- a/packages/domain/src/domain/ml/envelope.py +++ b/packages/domain/src/domain/ml/envelope.py @@ -103,6 +103,7 @@ def _part_heat_loss_w_per_k( door_area_m2: float, window_u_value: float, door_u_value: float, + roof_description: Optional[str] = None, ) -> float: """Heat loss coefficient (W/K) for a single building part: walls + roof + floor + party walls + windows + doors + thermal bridging. @@ -141,7 +142,12 @@ def _part_heat_loss_w_per_k( insulation_thickness_mm=wall_ins_thickness, insulation_present=wall_ins_present, ) - ur = u_roof(country=country, age_band=age_band, insulation_thickness_mm=roof_thickness) + ur = u_roof( + country=country, + age_band=age_band, + insulation_thickness_mm=roof_thickness, + description=roof_description, + ) uf = u_floor( country=country, age_band=age_band, @@ -188,6 +194,7 @@ def envelope_heat_loss_w_per_k( insulated_door_count: int, insulated_door_u_value: Optional[float], age_band_for_door: Optional[str] = None, + roof_description: Optional[str] = None, ) -> float: """Total envelope heat-loss coefficient (W/K) summed over all building parts. @@ -195,6 +202,12 @@ def envelope_heat_loss_w_per_k( dwelling) per RdSAP10 convention -- the cert's window list is not split across extensions. All U-values cascade through `rdsap_uvalues` defaults, so the return is never null. + + `roof_description` carries the worst-case surveyor description across the + top-level `roofs[i]` list (e.g. "Pitched, no insulation"). When the cert + flags a roof as uninsulated, u_roof returns Table 16 0mm/12mm values + instead of the optimistic Table 18 age-band default -- catastrophic + heritage roofs need that correction. """ if not sap_building_parts: return 0.0 @@ -231,5 +244,6 @@ def envelope_heat_loss_w_per_k( door_area_m2=d_area, window_u_value=window_u, door_u_value=door_u, + roof_description=roof_description, ) return total diff --git a/packages/domain/src/domain/ml/rdsap_uvalues.py b/packages/domain/src/domain/ml/rdsap_uvalues.py index 055d3100..df77971e 100644 --- a/packages/domain/src/domain/ml/rdsap_uvalues.py +++ b/packages/domain/src/domain/ml/rdsap_uvalues.py @@ -252,13 +252,32 @@ _ROOF_BY_AGE: Final[dict[str, float]] = { } +_ROOF_NO_INSULATION_MARKERS: Final[tuple[str, ...]] = ( + "no insulation", + "uninsulated", +) +_ROOF_LIMITED_INSULATION_MARKERS: Final[tuple[str, ...]] = ( + "limited insulation", +) + + def u_roof( country: Optional[Country], age_band: Optional[str], insulation_thickness_mm: Optional[int], + description: Optional[str] = None, ) -> float: - """RdSAP10 roof U-value in W/m^2K, never null. Defaults via Table 18 when - thickness unknown; uses Table 16 column (1) joist values when known.""" + """RdSAP10 roof U-value in W/m^2K, never null. + + Resolution order: + 1. Explicit `insulation_thickness_mm` → Table 16 column (1) joist row. + 2. Surveyor `description` text (top-level `roofs[i].description`) flagging + uninsulated / limited-insulation roofs → Table 16 0mm / 12mm rows. + Table 18 age-band defaults assume joist insulation ≥100 mm, which is + wrong for catastrophic heritage roofs the EPC explicitly describes + as uninsulated. + 3. Table 18 age-band default. + """ if insulation_thickness_mm is not None: # nearest tabulated thickness <= supplied u = _ROOF_BY_THICKNESS[0][1] @@ -266,6 +285,12 @@ def u_roof( if insulation_thickness_mm >= t: u = val return u + if description is not None: + desc = description.lower() + if any(marker in desc for marker in _ROOF_NO_INSULATION_MARKERS): + return _ROOF_BY_THICKNESS[0][1] # 2.30 W/m^2K + if any(marker in desc for marker in _ROOF_LIMITED_INSULATION_MARKERS): + return _ROOF_BY_THICKNESS[1][1] # 1.50 W/m^2K (12mm row) if age_band is None: return 0.4 return _ROOF_BY_AGE.get(age_band.upper(), 0.4) diff --git a/packages/domain/src/domain/ml/tests/test_envelope.py b/packages/domain/src/domain/ml/tests/test_envelope.py index 443f101b..659c43c0 100644 --- a/packages/domain/src/domain/ml/tests/test_envelope.py +++ b/packages/domain/src/domain/ml/tests/test_envelope.py @@ -217,6 +217,74 @@ def test_envelope_increases_with_windows_and_doors() -> None: assert with_openings > no_openings +def test_envelope_uninsulated_roof_description_raises_heat_loss() -> None: + # Arrange — Catastrophic heritage roof: top-level roofs[i].description says + # "no insulation". Without the flag the Table 18 age-G default of 0.40 W/m^2K + # under-states heat loss; with it u_roof returns 2.30 W/m^2K so the envelope + # rises by roughly (2.30-0.40)*100 = 190 W/K for a 100 m^2 roof. + main = make_building_part( + identifier="Main Dwelling", + construction_age_band="G", + 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=5.0, heat_loss_perimeter_m=40.0, floor=0, + ) + ], + ) + + # Act + default_roof = envelope_heat_loss_w_per_k( + sap_building_parts=[main], country_code="ENG", window_total_area_m2=0.0, + window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None, + ) + uninsulated_roof = envelope_heat_loss_w_per_k( + sap_building_parts=[main], country_code="ENG", window_total_area_m2=0.0, + window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None, + roof_description="Pitched, no insulation (assumed)", + ) + + # Assert — heat loss jumps by ~190 W/K (1.90 W/m^2K * 100 m^2 roof area). + assert uninsulated_roof - default_roof == pytest.approx(190.0, abs=15.0) + + +def test_envelope_limited_roof_insulation_description_raises_heat_loss() -> None: + # Arrange — "limited insulation" maps to u_roof=1.50; delta vs Table 18 + # age-G default of 0.40 is 1.10 W/m^2K * 100 m^2 = 110 W/K. + main = make_building_part( + identifier="Main Dwelling", + construction_age_band="G", + 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=5.0, heat_loss_perimeter_m=40.0, floor=0, + ) + ], + ) + + # Act + default_roof = envelope_heat_loss_w_per_k( + sap_building_parts=[main], country_code="ENG", window_total_area_m2=0.0, + window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None, + ) + limited_roof = envelope_heat_loss_w_per_k( + sap_building_parts=[main], country_code="ENG", window_total_area_m2=0.0, + window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None, + roof_description="Roof room(s), limited insulation", + ) + + # Assert + assert limited_roof - default_roof == pytest.approx(110.0, abs=15.0) + + def test_envelope_never_null_even_with_missing_fields() -> None: # Arrange — minimal building part with most fields unspecified. main = make_building_part( 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 de997e6e..46d7272f 100644 --- a/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py +++ b/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py @@ -178,6 +178,85 @@ def test_u_roof_unknown_age_band_falls_back_to_mid_range() -> None: assert result == pytest.approx(0.4, abs=0.001) +def test_u_roof_description_no_insulation_overrides_age_band_default() -> None: + # Arrange — surveyor description on a Victorian roof says uninsulated; + # Table 18 age-B default (0.40) is far too optimistic. Table 16 row 0mm + # joist insulation is 2.30 W/m^2K. + + # Act + result = u_roof( + country=Country.ENG, + age_band="B", + insulation_thickness_mm=None, + description="Pitched, no insulation (assumed)", + ) + + # Assert + assert result == pytest.approx(2.30, abs=0.001) + + +def test_u_roof_description_limited_insulation_overrides_age_band_default() -> None: + # Arrange — "limited insulation" maps to Table 16 row 12mm -> 1.50 W/m^2K. + + # Act + result = u_roof( + country=Country.ENG, + age_band="D", + insulation_thickness_mm=None, + description="Roof room(s), limited insulation", + ) + + # Assert + assert result == pytest.approx(1.50, abs=0.001) + + +def test_u_roof_description_uninsulated_synonym_also_triggers_high_u() -> None: + # Arrange — surveyor writes "uninsulated" (no space) instead of "no insulation". + + # Act + result = u_roof( + country=Country.ENG, + age_band="C", + insulation_thickness_mm=None, + description="Flat, uninsulated", + ) + + # Assert + assert result == pytest.approx(2.30, abs=0.001) + + +def test_u_roof_description_well_insulated_does_not_override_default() -> None: + # Arrange — description says "insulated"; do NOT override the Table 18 + # age-G default of 0.40 with a penalty. + + # Act + result = u_roof( + country=Country.ENG, + age_band="G", + insulation_thickness_mm=None, + description="Pitched, insulated at rafters", + ) + + # Assert + assert result == pytest.approx(0.40, abs=0.001) + + +def test_u_roof_explicit_thickness_beats_description() -> None: + # Arrange — when surveyor measured 200mm joist insulation, Table 16 wins + # regardless of any description text. 200mm -> 0.21 W/m^2K. + + # Act + result = u_roof( + country=Country.ENG, + age_band="B", + insulation_thickness_mm=200, + description="No insulation", # ignored because thickness is explicit + ) + + # Assert + assert result == pytest.approx(0.21, abs=0.001) + + # ----- Floors ----- diff --git a/packages/domain/src/domain/ml/tests/test_transform.py b/packages/domain/src/domain/ml/tests/test_transform.py index 1503ffdb..ae9f7980 100644 --- a/packages/domain/src/domain/ml/tests/test_transform.py +++ b/packages/domain/src/domain/ml/tests/test_transform.py @@ -36,7 +36,7 @@ def test_transform_advertises_version_and_target_columns() -> None: # Assert assert isinstance(schema, TransformSchema) - assert schema.transform_version == "2.3.0" + assert schema.transform_version == "2.4.0" assert schema.transform_version == EpcMlTransform.VERSION assert set(schema.target_columns.keys()) == set(_EXPECTED_TARGET_DTYPES.keys()) for target_name, expected_dtype in _EXPECTED_TARGET_DTYPES.items(): diff --git a/packages/domain/src/domain/ml/transform.py b/packages/domain/src/domain/ml/transform.py index 4dfa4493..2969344d 100644 --- a/packages/domain/src/domain/ml/transform.py +++ b/packages/domain/src/domain/ml/transform.py @@ -16,6 +16,7 @@ import pandas as pd from datatypes.epc.domain.epc import Epc from datatypes.epc.domain.epc_property_data import ( + EnergyElement, EpcPropertyData, SapBuildingPart, SapEnergySource, @@ -901,7 +902,7 @@ class EpcMlTransform: Version 0.1.0 — schema contract only; feature columns added in subsequent slices. """ - VERSION: str = "2.3.0" + VERSION: str = "2.4.0" def schema(self) -> TransformSchema: """The cross-repo ML data contract. @@ -956,6 +957,7 @@ class EpcMlTransform: door_count=epc.door_count, insulated_door_count=epc.insulated_door_count, insulated_door_u_value=epc.insulated_door_u_value, + roof_description=_joined_descriptions(epc.roofs), ) main_heating_code = heating_aggregates.get("primary_sap_main_heating_code") water_code = heating_aggregates.get("water_heating_code") @@ -1406,6 +1408,22 @@ def _truthy_yn(value: Any) -> Optional[bool]: return None +def _joined_descriptions(elements: list[EnergyElement]) -> Optional[str]: + """Concatenate `description` text across an `EnergyElement` list. + + Used so envelope_heat_loss_w_per_k can spot worst-case markers ("no + insulation" / "limited insulation") across every roof / wall / floor entry + on the cert, since those are top-level lists not keyed by building part. + Returns None when the list is empty so callers can short-circuit. + """ + if not elements: + return None + parts = [e.description for e in elements if e.description] + if not parts: + return None + return " | ".join(parts) + + def _ground_floor(part: SapBuildingPart) -> Optional[Any]: """Pick the ground-floor `SapFloorDimension` (floor==0) for a building part.