From 4219ef9d8b28be526d40f7832af18e97a6e9601e Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 17 Jun 2026 18:06:29 +0000 Subject: [PATCH] =?UTF-8?q?Overlay=20landlord=20property-type=20and=20buil?= =?UTF-8?q?t-form=20corrections=20onto=20the=20Effective=20EPC=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds whole-dwelling property_type/built_form to EpcSimulation (folded by apply_simulations) and maps those override components. property_type drives party-wall heat loss + ASHP/solar/wall eligibility, so a landlord correction now moves both the SAP calc and the measure menu; built_form has no calculator consumer today (feeds the ML transform). Written as the landlord text value (park-home check is text-only). Refines ADR-0032 dec-4. Co-Authored-By: Claude Opus 4.8 (1M context) --- domain/epc/attribute_overlay.py | 29 ++++++++++++ .../modelling/scoring/overlay_applicator.py | 4 ++ domain/modelling/simulation.py | 5 +++ .../property/landlord_override_overlays.py | 13 ++++-- tests/domain/epc/test_attribute_overlay.py | 44 +++++++++++++++++++ .../test_property_landlord_overlay.py | 11 +++++ .../test_landlord_override_overlays.py | 27 +++++++++--- 7 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 domain/epc/attribute_overlay.py create mode 100644 tests/domain/epc/test_attribute_overlay.py diff --git a/domain/epc/attribute_overlay.py b/domain/epc/attribute_overlay.py new file mode 100644 index 00000000..bdfa0627 --- /dev/null +++ b/domain/epc/attribute_overlay.py @@ -0,0 +1,29 @@ +"""Map a Landlord-Override property-type / built-form value to a Simulation +Overlay (ADR-0032). + +These are whole-dwelling categorical corrections, not building-part fabric — so +the overlay sets the top-level `EpcSimulation.property_type` / `built_form` +rather than a `BuildingPartOverlay`. The landlord value is written as-is (text): +`property_type` consumers are tolerant of text, and the calculator's park-home +check is text-only (`"park home"`). `property_type` drives party-wall heat loss +and ASHP/solar/wall eligibility; `built_form` has no calculator consumer today +(it feeds the ML transform + reporting). `"Unknown"` resolves to no overlay. +""" + +from __future__ import annotations + +from typing import Optional + +from domain.modelling.simulation import EpcSimulation + + +def property_type_overlay_for(value: str, building_part: int) -> Optional[EpcSimulation]: + if not value or value == "Unknown": + return None + return EpcSimulation(property_type=value) + + +def built_form_overlay_for(value: str, building_part: int) -> Optional[EpcSimulation]: + if not value or value == "Unknown": + return None + return EpcSimulation(built_form=value) diff --git a/domain/modelling/scoring/overlay_applicator.py b/domain/modelling/scoring/overlay_applicator.py index d47d84a7..9c83724a 100644 --- a/domain/modelling/scoring/overlay_applicator.py +++ b/domain/modelling/scoring/overlay_applicator.py @@ -59,6 +59,10 @@ def apply_simulations( _fold_secondary_heating(result, simulation.secondary_heating) if simulation.solar is not None: _fold_solar(result, simulation.solar) + if simulation.property_type is not None: + result.property_type = simulation.property_type + if simulation.built_form is not None: + result.built_form = simulation.built_form return result diff --git a/domain/modelling/simulation.py b/domain/modelling/simulation.py index c4708e7a..624826f6 100644 --- a/domain/modelling/simulation.py +++ b/domain/modelling/simulation.py @@ -223,3 +223,8 @@ class EpcSimulation: heating: Optional[HeatingOverlay] = None secondary_heating: Optional[SecondaryHeatingOverlay] = None solar: Optional[SolarOverlay] = None + # Whole-dwelling categorical corrections from a Landlord Override (ADR-0032). + # Measures never set these; a landlord may correct the lodged property type / + # built form (property_type drives party-wall heat loss + measure eligibility). + property_type: Optional[str] = None + built_form: Optional[str] = None diff --git a/repositories/property/landlord_override_overlays.py b/repositories/property/landlord_override_overlays.py index dc998e04..b8e9199d 100644 --- a/repositories/property/landlord_override_overlays.py +++ b/repositories/property/landlord_override_overlays.py @@ -12,17 +12,24 @@ from __future__ import annotations from typing import Callable, Optional +from domain.epc.attribute_overlay import ( + built_form_overlay_for, + property_type_overlay_for, +) from domain.epc.roof_type_overlay import roof_overlay_for from domain.epc.wall_type_overlay import wall_overlay_for from domain.modelling.simulation import EpcSimulation from repositories.property.property_overrides_reader import ResolvedPropertyOverrides -# Each fabric component maps its override value (+ building part) to an overlay, -# or None when the value isn't resolvable. property_type / built_form_type carry -# no SAP weight at the EpcPropertyData layer (ADR-0032), so they have no mapper. +# Each override component maps its value (+ building part) to an overlay, or None +# when the value isn't resolvable. Fabric (wall/roof) folds onto building parts; +# property_type / built_form_type are whole-dwelling categorical corrections +# (ADR-0032 — property_type drives party-wall heat loss + measure eligibility). _COMPONENT_OVERLAYS: dict[str, Callable[[str, int], Optional[EpcSimulation]]] = { "wall_type": wall_overlay_for, "roof_type": roof_overlay_for, + "property_type": property_type_overlay_for, + "built_form_type": built_form_overlay_for, } diff --git a/tests/domain/epc/test_attribute_overlay.py b/tests/domain/epc/test_attribute_overlay.py new file mode 100644 index 00000000..f890875f --- /dev/null +++ b/tests/domain/epc/test_attribute_overlay.py @@ -0,0 +1,44 @@ +"""Landlord property-type / built-form → whole-dwelling Simulation Overlay (ADR-0032). + +The landlord value is written as-is onto the top-level EpcSimulation fields; +"Unknown" resolves to no overlay. +""" + +from __future__ import annotations + +import pytest + +from domain.epc.attribute_overlay import ( + built_form_overlay_for, + property_type_overlay_for, +) + + +def test_property_type_override_sets_the_whole_dwelling_property_type() -> None: + # Act + simulation = property_type_overlay_for("House", 0) + + # Assert + assert simulation is not None + assert simulation.property_type == "House" + + +def test_built_form_override_sets_the_whole_dwelling_built_form() -> None: + # Act + simulation = built_form_overlay_for("Semi-Detached", 0) + + # Assert + assert simulation is not None + assert simulation.built_form == "Semi-Detached" + + +@pytest.mark.parametrize("value", ["Unknown", ""]) +def test_unknown_property_type_produces_no_overlay(value: str) -> None: + # Act / Assert + assert property_type_overlay_for(value, 0) is None + + +@pytest.mark.parametrize("value", ["Unknown", ""]) +def test_unknown_built_form_produces_no_overlay(value: str) -> None: + # Act / Assert + assert built_form_overlay_for(value, 0) is None diff --git a/tests/domain/property/test_property_landlord_overlay.py b/tests/domain/property/test_property_landlord_overlay.py index d4006b1b..a3cfa3b3 100644 --- a/tests/domain/property/test_property_landlord_overlay.py +++ b/tests/domain/property/test_property_landlord_overlay.py @@ -16,6 +16,7 @@ from datatypes.epc.domain.epc_property_data import ( BuildingPartIdentifier, EpcPropertyData, ) +from domain.epc.attribute_overlay import property_type_overlay_for from domain.epc.wall_type_overlay import wall_overlay_for from domain.property.property import Property, PropertyIdentity @@ -60,6 +61,16 @@ def test_effective_epc_folds_the_wall_override_onto_the_main_part() -> None: assert main.wall_insulation_type == 3 +def test_effective_epc_reflects_a_property_type_override() -> None: + # Arrange — the landlord corrects the dwelling's property type to House. + overlay = property_type_overlay_for("House", 0) + assert overlay is not None + prop = Property(identity=_identity(), epc=_epc(), landlord_overrides=[overlay]) + + # Act / Assert — the Effective EPC carries the corrected property type. + assert prop.effective_epc.property_type == "House" + + def test_effective_epc_is_the_lodged_epc_when_there_are_no_overrides() -> None: # Arrange — a Property with an EPC and no Landlord Overrides. prop = Property(identity=_identity(), epc=_epc()) diff --git a/tests/repositories/property/test_landlord_override_overlays.py b/tests/repositories/property/test_landlord_override_overlays.py index a2eb695b..20c79c1f 100644 --- a/tests/repositories/property/test_landlord_override_overlays.py +++ b/tests/repositories/property/test_landlord_override_overlays.py @@ -29,18 +29,35 @@ def test_roof_type_row_produces_a_roof_overlay() -> None: assert main.roof_insulation_thickness == 300 -def test_wall_and_roof_rows_each_produce_an_overlay() -> None: - # Arrange +def test_each_resolvable_component_produces_an_overlay() -> None: + # Arrange — wall, roof, property_type, built_form all resolvable. overrides = ResolvedPropertyOverrides( rows=( ResolvedPropertyOverride("wall_type", 0, "Solid brick, with internal insulation"), ResolvedPropertyOverride("roof_type", 0, "Pitched, 300 mm loft insulation"), - ResolvedPropertyOverride("property_type", 0, "House"), # not overlaid + ResolvedPropertyOverride("property_type", 0, "House"), + ResolvedPropertyOverride("built_form_type", 0, "Semi-Detached"), ) ) # Act overlays = overlays_from(overrides) - # Assert — wall + roof map; property_type is skipped. - assert len(overlays) == 2 + # Assert + assert len(overlays) == 4 + + +def test_unresolvable_rows_are_skipped() -> None: + # Arrange — an "Unknown" property type and an unmapped wall material. + overrides = ResolvedPropertyOverrides( + rows=( + ResolvedPropertyOverride("property_type", 0, "Unknown"), + ResolvedPropertyOverride("wall_type", 0, "Basement wall, as built"), + ) + ) + + # Act + overlays = overlays_from(overrides) + + # Assert + assert overlays == []