From 49c9f8181c3936bf0fdefdcc3487334412609a2f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 6 Jul 2026 11:50:45 +0000 Subject: [PATCH] =?UTF-8?q?The=20historic=20roof=20description=20condition?= =?UTF-8?q?s=20the=20cohort=20by=20form=20family=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../epc_prediction/historic_conditioning.py | 1 + domain/epc_prediction/prediction_target.py | 4 +++ .../test_comparable_properties.py | 29 +++++++++++++++++++ .../test_historic_conditioning.py | 28 ++++++++++++++++++ 4 files changed, 62 insertions(+) diff --git a/domain/epc_prediction/historic_conditioning.py b/domain/epc_prediction/historic_conditioning.py index 82fb082b4..5e92762c5 100644 --- a/domain/epc_prediction/historic_conditioning.py +++ b/domain/epc_prediction/historic_conditioning.py @@ -109,6 +109,7 @@ class HistoricConditioning: construction_age_band: Optional[str] main_fuel: Optional[int] total_floor_area_m2: Optional[float] + roof_form: Optional[str] = None def _wall_construction(description: str) -> Optional[int]: diff --git a/domain/epc_prediction/prediction_target.py b/domain/epc_prediction/prediction_target.py index 93a91fc6e..30014e9e3 100644 --- a/domain/epc_prediction/prediction_target.py +++ b/domain/epc_prediction/prediction_target.py @@ -40,6 +40,10 @@ class PredictionTarget: construction_age_band: Optional[str] = None main_fuel: Optional[int] = None total_floor_area_m2: Optional[float] = None + # The roof FORM family ("pitched" / "flat" / "dwelling_above" / + # "premises_above") — the API's roof_construction codes group by form, + # so the filter matches families, never exact codes (ADR-0054 amendment). + roof_form: Optional[str] = None @dataclass(frozen=True) diff --git a/tests/domain/epc_prediction/test_comparable_properties.py b/tests/domain/epc_prediction/test_comparable_properties.py index 9bf903718..06a266d39 100644 --- a/tests/domain/epc_prediction/test_comparable_properties.py +++ b/tests/domain/epc_prediction/test_comparable_properties.py @@ -33,6 +33,7 @@ def _comparable( construction_age_band: Optional[str] = None, main_fuel: Optional[int] = None, total_floor_area_m2: Optional[float] = None, + roof_construction: Optional[int] = None, ) -> ComparableProperty: """A ComparableProperty carrying only the fields under test (opaque EpcPropertyData with property_type / built_form / main wall set — the partial-instance idiom).""" @@ -44,6 +45,8 @@ def _comparable( main.wall_construction = wall_construction if construction_age_band is not None: main.construction_age_band = construction_age_band + if roof_construction is not None: + main.roof_construction = roof_construction epc.sap_building_parts = [main] if main_fuel is not None: detail: MainHeatingDetail = object.__new__(MainHeatingDetail) @@ -306,6 +309,32 @@ def test_historic_age_band_conditions_within_one_band() -> None: } +def test_historic_roof_form_conditions_the_cohort_by_family() -> None: + # Arrange — the expired cert observed a pitched roof. The API's + # roof_construction codes group into FORM families (empirical sweep: + # 4/5/8 = pitched, 1 = flat), so all pitched-family comparables match and + # the flat one drops (ADR-0054 as amended). + target = PredictionTarget(postcode="LS6 1AA", property_type="2", roof_form="pitched") + candidates = [ + _comparable(property_type="2", roof_construction=4, certificate_number="P4a"), + _comparable(property_type="2", roof_construction=4, certificate_number="P4b"), + _comparable(property_type="2", roof_construction=5, certificate_number="P5"), + _comparable(property_type="2", roof_construction=8, certificate_number="P8"), + _comparable(property_type="2", roof_construction=4, certificate_number="P4c"), + _comparable(property_type="2", roof_construction=1, certificate_number="F1"), + ] + + # Act + result: ComparableProperties = select_comparables( + target, candidates, minimum_cohort=5 + ) + + # Assert — the whole pitched family survives; the flat roof drops. + assert {c.certificate_number for c in result.members} == { + "P4a", "P4b", "P4c", "P5", "P8" + } + + def test_floor_area_band_relaxes_when_too_few_match() -> None: # Arrange — only one comparable inside the ±5% band (< k=2): the band must # relax rather than starve the cohort (graceful degradation, ADR-0029). diff --git a/tests/domain/epc_prediction/test_historic_conditioning.py b/tests/domain/epc_prediction/test_historic_conditioning.py index 886319b64..d57c6eb6c 100644 --- a/tests/domain/epc_prediction/test_historic_conditioning.py +++ b/tests/domain/epc_prediction/test_historic_conditioning.py @@ -29,6 +29,7 @@ def test_resolves_stable_attributes_into_cohort_code_spaces(): property_type="House", built_form="Semi-Detached", walls_description="Cavity wall, as built, no insulation (assumed)", + roof_description="Pitched, 100 mm loft insulation", construction_age_band="England and Wales: 1930-1949", main_fuel="mains gas (not community)", total_floor_area="84", @@ -42,11 +43,37 @@ def test_resolves_stable_attributes_into_cohort_code_spaces(): assert conditioning.property_type == "0" assert conditioning.built_form == "2" assert conditioning.wall_construction == 4 + assert conditioning.roof_form == "pitched" assert conditioning.construction_age_band == "C" assert conditioning.main_fuel == 26 assert conditioning.total_floor_area_m2 == 84.0 +def test_roof_descriptions_resolve_to_form_families(): + # Arrange / Act / Assert — the FORM half of the description (before the + # comma) maps to a family, because the API's roof_construction codes group + # that way (7,974-cert co-occurrence sweep: 1=Flat 98%, 4/5/8=Pitched + # 88-99%, 3=another dwelling above 100%; 7/9=another premises above per + # the #1452 suppression fix). Insulation state is volatile and ignored. + cases = { + "Pitched, 250 mm loft insulation": "pitched", + "Pitched, no insulation (assumed)": "pitched", + "Flat, insulated (assumed)": "flat", + "(another dwelling above)": "dwelling_above", + "(another premises above)": "premises_above", + } + for text, family in cases.items(): + record = _hist(roof_description=text) + assert conditioning_from_historic(record).roof_form == family, text + # Unpinned forms (roof rooms, thatched) must not guess. + assert conditioning_from_historic( + _hist(roof_description="Roof room(s), insulated") + ).roof_form is None + assert conditioning_from_historic( + _hist(roof_description="Thatched, with additional insulation") + ).roof_form is None + + def test_legacy_register_fuel_descriptions_resolve(): # Arrange / Act / Assert — the old register lodged pre-RdSAP-17 fuels with # a "backwards compatibility" rider or a SAP-style prefix; they name the @@ -84,6 +111,7 @@ def test_unresolvable_values_degrade_to_none(): assert conditioning.property_type is None assert conditioning.built_form is None assert conditioning.wall_construction is None + assert conditioning.roof_form is None assert conditioning.construction_age_band is None assert conditioning.main_fuel is None assert conditioning.total_floor_area_m2 is None