From d11d4df3df398b11f3c050cef7d86702de57ab71 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 17 May 2026 17:55:09 +0000 Subject: [PATCH] slice 18c: description-aware u_wall material fallback (v2.5.0) When wall_construction integer is missing or WALL_UNKNOWN, u_wall now parses the top-level walls[i].description for material keywords (sandstone/limestone/granite/whinstone/cob/system built/timber frame/ solid brick/cavity) before falling through to the cavity-by-age default. Explicit construction codes still win. Threaded through envelope_heat_loss_w_per_k via a joined wall description string off the top-level walls list. --- packages/domain/src/domain/ml/envelope.py | 4 + .../domain/src/domain/ml/rdsap_uvalues.py | 44 +++++++++- .../src/domain/ml/tests/test_envelope.py | 35 ++++++++ .../src/domain/ml/tests/test_rdsap_uvalues.py | 85 +++++++++++++++++++ .../src/domain/ml/tests/test_transform.py | 2 +- packages/domain/src/domain/ml/transform.py | 3 +- 6 files changed, 168 insertions(+), 5 deletions(-) diff --git a/packages/domain/src/domain/ml/envelope.py b/packages/domain/src/domain/ml/envelope.py index 5a4d020c..b43b140e 100644 --- a/packages/domain/src/domain/ml/envelope.py +++ b/packages/domain/src/domain/ml/envelope.py @@ -104,6 +104,7 @@ def _part_heat_loss_w_per_k( window_u_value: float, door_u_value: float, roof_description: Optional[str] = None, + wall_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,6 +142,7 @@ def _part_heat_loss_w_per_k( construction=wall_construction if wall_construction != WALL_UNKNOWN else None, insulation_thickness_mm=wall_ins_thickness, insulation_present=wall_ins_present, + description=wall_description, ) ur = u_roof( country=country, @@ -195,6 +197,7 @@ def envelope_heat_loss_w_per_k( insulated_door_u_value: Optional[float], age_band_for_door: Optional[str] = None, roof_description: Optional[str] = None, + wall_description: Optional[str] = None, ) -> float: """Total envelope heat-loss coefficient (W/K) summed over all building parts. @@ -245,5 +248,6 @@ def envelope_heat_loss_w_per_k( window_u_value=window_u, door_u_value=door_u, roof_description=roof_description, + wall_description=wall_description, ) return total diff --git a/packages/domain/src/domain/ml/rdsap_uvalues.py b/packages/domain/src/domain/ml/rdsap_uvalues.py index df77971e..f9e8864e 100644 --- a/packages/domain/src/domain/ml/rdsap_uvalues.py +++ b/packages/domain/src/domain/ml/rdsap_uvalues.py @@ -201,6 +201,32 @@ _DEFAULT_WALL_BY_AGE: Final[dict[str, int]] = { } +# Surveyor-text -> wall-construction code, evaluated in priority order so that +# "sandstone" beats "stone", "solid brick" beats "brick", etc. Used only as a +# fallback when the cert's `wall_construction` integer is missing or unknown. +_WALL_DESCRIPTION_MARKERS: Final[tuple[tuple[str, int], ...]] = ( + ("sandstone", WALL_STONE_SANDSTONE), + ("limestone", WALL_STONE_SANDSTONE), + ("granite", WALL_STONE_GRANITE), + ("whinstone", WALL_STONE_GRANITE), + ("cob", WALL_COB), + ("system built", WALL_SYSTEM_BUILT), + ("timber frame", WALL_TIMBER_FRAME), + ("solid brick", WALL_SOLID_BRICK), + ("cavity", WALL_CAVITY), +) + + +def _wall_type_from_description(description: Optional[str]) -> Optional[int]: + if description is None: + return None + desc = description.lower() + for marker, code in _WALL_DESCRIPTION_MARKERS: + if marker in desc: + return code + return None + + def u_wall( country: Optional[Country], age_band: Optional[str], @@ -208,17 +234,29 @@ def u_wall( insulation_thickness_mm: Optional[int], *, insulation_present: bool = False, + description: Optional[str] = None, ) -> float: - """RdSAP10 wall U-value in W/m^2K, never null.""" + """RdSAP10 wall U-value in W/m^2K, never null. + + When the cert's `construction` integer is missing or WALL_UNKNOWN, an + optional surveyor `description` (top-level `walls[i].description`) is + parsed for material keywords ("sandstone", "granite", "solid brick", ...) + so the cascade picks the right table instead of falling through to the + cavity-by-age default. Explicit construction codes always win. + """ if country is None and age_band is None and construction is None and insulation_thickness_mm is None and not insulation_present: return 1.5 ctry = country if country is not None else Country.ENG age_idx = _age_index(age_band) band = _AGE_BANDS[age_idx] - wall_type = construction if construction in { + known_types = { WALL_STONE_GRANITE, WALL_STONE_SANDSTONE, WALL_SOLID_BRICK, WALL_CAVITY, WALL_TIMBER_FRAME, WALL_SYSTEM_BUILT, WALL_COB, - } else _DEFAULT_WALL_BY_AGE.get(band, WALL_CAVITY) + } + if construction in known_types: + wall_type = construction + else: + wall_type = _wall_type_from_description(description) or _DEFAULT_WALL_BY_AGE.get(band, WALL_CAVITY) bucket = _insulation_bucket(insulation_thickness_mm, insulation_present) # Country override first. diff --git a/packages/domain/src/domain/ml/tests/test_envelope.py b/packages/domain/src/domain/ml/tests/test_envelope.py index 659c43c0..dd549b6f 100644 --- a/packages/domain/src/domain/ml/tests/test_envelope.py +++ b/packages/domain/src/domain/ml/tests/test_envelope.py @@ -285,6 +285,41 @@ def test_envelope_limited_roof_insulation_description_raises_heat_loss() -> None assert limited_roof - default_roof == pytest.approx(110.0, abs=15.0) +def test_envelope_stone_wall_description_raises_heat_loss_vs_cavity_default() -> None: + # Arrange — construction integer missing on a Victorian (age D) cert. + # _DEFAULT_WALL_BY_AGE picks CAVITY (1.5 W/m^2K uninsulated for D) by + # default; a walls[i].description naming "Sandstone" should resolve to + # stone instead (1.7 W/m^2K). Delta on net wall area ~100 m^2 -> +20 W/K. + main = make_building_part( + identifier="Main Dwelling", + construction_age_band="D", + wall_construction=10, # WALL_UNKNOWN -> envelope passes None to u_wall + 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_wall = 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, + ) + stone_wall = 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, + wall_description="Sandstone or limestone, as built, no insulation (assumed)", + ) + + # Assert — heat loss rises by ~20 W/K (0.2 W/m^2K * 100 m^2 net wall area). + assert stone_wall - default_wall == pytest.approx(20.0, abs=5.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 46d7272f..418c83c4 100644 --- a/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py +++ b/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py @@ -128,6 +128,91 @@ def test_u_wall_falls_back_to_mid_range_default_when_everything_unknown() -> Non assert result == pytest.approx(1.5, abs=0.001) +def test_u_wall_description_sandstone_overrides_cavity_default_for_age_e() -> None: + # Arrange — construction integer is missing on the cert. _DEFAULT_WALL_BY_AGE + # would pick cavity for age E (1.0 W/m^2K uninsulated), but the surveyor's + # walls[i].description clearly identifies sandstone -> 1.7 W/m^2K. + + # Act + result = u_wall( + country=Country.ENG, + age_band="E", + construction=None, + insulation_thickness_mm=None, + description="Sandstone or limestone, as built, no insulation (assumed)", + ) + + # Assert + assert result == pytest.approx(1.7, abs=0.001) + + +def test_u_wall_description_granite_or_whinstone_picks_stone_default() -> None: + # Arrange — Scotland whinstone (granite-family) walls, age D, construction + # null. Should resolve to STONE_GRANITE uninsulated -> 1.7 (age D index 3). + + # Act + result = u_wall( + country=Country.ENG, + age_band="D", + construction=None, + insulation_thickness_mm=None, + description="Granite or whinstone, as built", + ) + + # Assert + assert result == pytest.approx(1.7, abs=0.001) + + +def test_u_wall_description_solid_brick_picks_solid_brick_default() -> None: + # Arrange — construction null, description names solid brick. For age E + # uninsulated solid brick -> 1.7 W/m^2K (vs cavity 1.0). + + # Act + result = u_wall( + country=Country.ENG, + age_band="E", + construction=None, + insulation_thickness_mm=None, + description="Solid brick, as built, no insulation (assumed)", + ) + + # Assert + assert result == pytest.approx(1.7, abs=0.001) + + +def test_u_wall_explicit_construction_beats_description() -> None: + # Arrange — wall_construction integer is cavity (4); ignore any conflicting + # description text. Cavity-as-built age E -> 1.5 W/m^2K, NOT stone's 1.7. + + # Act + result = u_wall( + country=Country.ENG, + age_band="E", + construction=WALL_CAVITY, + insulation_thickness_mm=None, + description="Granite, as built", # ignored + ) + + # Assert + assert result == pytest.approx(1.5, abs=0.001) + + +def test_u_wall_description_unmatched_falls_back_to_age_band_default() -> None: + # Arrange — construction null, description says nothing recognisable. + + # Act + result = u_wall( + country=Country.ENG, + age_band="E", + construction=None, + insulation_thickness_mm=None, + description="something unparseable", + ) + + # Assert — cavity default for age E uninsulated -> 1.5 W/m^2K. + assert result == pytest.approx(1.5, abs=0.001) + + def test_u_wall_uses_rdsap_unknown_thickness_default_of_50mm_when_insulated_but_unknown() -> None: # Arrange — RdSAP10 footnote: if wall is known insulated but thickness unknown, use 50mm row. # System built with 50mm insulation, England, age band G -> 0.35 W/m^2K. diff --git a/packages/domain/src/domain/ml/tests/test_transform.py b/packages/domain/src/domain/ml/tests/test_transform.py index ae9f7980..b314776c 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.4.0" + assert schema.transform_version == "2.5.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 2969344d..750819a7 100644 --- a/packages/domain/src/domain/ml/transform.py +++ b/packages/domain/src/domain/ml/transform.py @@ -902,7 +902,7 @@ class EpcMlTransform: Version 0.1.0 — schema contract only; feature columns added in subsequent slices. """ - VERSION: str = "2.4.0" + VERSION: str = "2.5.0" def schema(self) -> TransformSchema: """The cross-repo ML data contract. @@ -958,6 +958,7 @@ class EpcMlTransform: insulated_door_count=epc.insulated_door_count, insulated_door_u_value=epc.insulated_door_u_value, roof_description=_joined_descriptions(epc.roofs), + wall_description=_joined_descriptions(epc.walls), ) main_heating_code = heating_aggregates.get("primary_sap_main_heating_code") water_code = heating_aggregates.get("water_heating_code")