mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Overlay landlord property-type and built-form corrections onto the Effective EPC 🟩
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) <noreply@anthropic.com>
This commit is contained in:
parent
0305241ad3
commit
4219ef9d8b
7 changed files with 125 additions and 8 deletions
29
domain/epc/attribute_overlay.py
Normal file
29
domain/epc/attribute_overlay.py
Normal file
|
|
@ -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)
|
||||||
|
|
@ -59,6 +59,10 @@ def apply_simulations(
|
||||||
_fold_secondary_heating(result, simulation.secondary_heating)
|
_fold_secondary_heating(result, simulation.secondary_heating)
|
||||||
if simulation.solar is not None:
|
if simulation.solar is not None:
|
||||||
_fold_solar(result, simulation.solar)
|
_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
|
return result
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -223,3 +223,8 @@ class EpcSimulation:
|
||||||
heating: Optional[HeatingOverlay] = None
|
heating: Optional[HeatingOverlay] = None
|
||||||
secondary_heating: Optional[SecondaryHeatingOverlay] = None
|
secondary_heating: Optional[SecondaryHeatingOverlay] = None
|
||||||
solar: Optional[SolarOverlay] = 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
|
||||||
|
|
|
||||||
|
|
@ -12,17 +12,24 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import Callable, Optional
|
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.roof_type_overlay import roof_overlay_for
|
||||||
from domain.epc.wall_type_overlay import wall_overlay_for
|
from domain.epc.wall_type_overlay import wall_overlay_for
|
||||||
from domain.modelling.simulation import EpcSimulation
|
from domain.modelling.simulation import EpcSimulation
|
||||||
from repositories.property.property_overrides_reader import ResolvedPropertyOverrides
|
from repositories.property.property_overrides_reader import ResolvedPropertyOverrides
|
||||||
|
|
||||||
# Each fabric component maps its override value (+ building part) to an overlay,
|
# Each override component maps its value (+ building part) to an overlay, or None
|
||||||
# or None when the value isn't resolvable. property_type / built_form_type carry
|
# when the value isn't resolvable. Fabric (wall/roof) folds onto building parts;
|
||||||
# no SAP weight at the EpcPropertyData layer (ADR-0032), so they have no mapper.
|
# 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]]] = {
|
_COMPONENT_OVERLAYS: dict[str, Callable[[str, int], Optional[EpcSimulation]]] = {
|
||||||
"wall_type": wall_overlay_for,
|
"wall_type": wall_overlay_for,
|
||||||
"roof_type": roof_overlay_for,
|
"roof_type": roof_overlay_for,
|
||||||
|
"property_type": property_type_overlay_for,
|
||||||
|
"built_form_type": built_form_overlay_for,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
44
tests/domain/epc/test_attribute_overlay.py
Normal file
44
tests/domain/epc/test_attribute_overlay.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -16,6 +16,7 @@ from datatypes.epc.domain.epc_property_data import (
|
||||||
BuildingPartIdentifier,
|
BuildingPartIdentifier,
|
||||||
EpcPropertyData,
|
EpcPropertyData,
|
||||||
)
|
)
|
||||||
|
from domain.epc.attribute_overlay import property_type_overlay_for
|
||||||
from domain.epc.wall_type_overlay import wall_overlay_for
|
from domain.epc.wall_type_overlay import wall_overlay_for
|
||||||
from domain.property.property import Property, PropertyIdentity
|
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
|
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:
|
def test_effective_epc_is_the_lodged_epc_when_there_are_no_overrides() -> None:
|
||||||
# Arrange — a Property with an EPC and no Landlord Overrides.
|
# Arrange — a Property with an EPC and no Landlord Overrides.
|
||||||
prop = Property(identity=_identity(), epc=_epc())
|
prop = Property(identity=_identity(), epc=_epc())
|
||||||
|
|
|
||||||
|
|
@ -29,18 +29,35 @@ def test_roof_type_row_produces_a_roof_overlay() -> None:
|
||||||
assert main.roof_insulation_thickness == 300
|
assert main.roof_insulation_thickness == 300
|
||||||
|
|
||||||
|
|
||||||
def test_wall_and_roof_rows_each_produce_an_overlay() -> None:
|
def test_each_resolvable_component_produces_an_overlay() -> None:
|
||||||
# Arrange
|
# Arrange — wall, roof, property_type, built_form all resolvable.
|
||||||
overrides = ResolvedPropertyOverrides(
|
overrides = ResolvedPropertyOverrides(
|
||||||
rows=(
|
rows=(
|
||||||
ResolvedPropertyOverride("wall_type", 0, "Solid brick, with internal insulation"),
|
ResolvedPropertyOverride("wall_type", 0, "Solid brick, with internal insulation"),
|
||||||
ResolvedPropertyOverride("roof_type", 0, "Pitched, 300 mm loft 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
|
# Act
|
||||||
overlays = overlays_from(overrides)
|
overlays = overlays_from(overrides)
|
||||||
|
|
||||||
# Assert — wall + roof map; property_type is skipped.
|
# Assert
|
||||||
assert len(overlays) == 2
|
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 == []
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue