mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
Overlay more wall materials and roof loft-insulation depth from landlord overrides 🟩
Extends WallType coverage to timber/stone/system-built/cob/park-home/curtain and adds RoofType "Pitched, N mm loft insulation" -> roof_insulation_thickness. The "(assumed) insulated"/"partial" wall states stay deferred (ambiguous code, needs Elmhurst validation per ADR-0032); property_type/built_form carry no SAP weight. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5939520b0d
commit
0305241ad3
6 changed files with 203 additions and 7 deletions
41
domain/epc/roof_type_overlay.py
Normal file
41
domain/epc/roof_type_overlay.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"""Map a Landlord-Override `RoofType` value to a roof Simulation Overlay (ADR-0032).
|
||||
|
||||
The calculator derives the roof U-value from the building part's loft-insulation
|
||||
depth, so a `roof_type` override moves the score only via
|
||||
`BuildingPartOverlay.roof_insulation_thickness` (mm). The resolvable family is
|
||||
the explicit `"Pitched, N mm loft insulation"` values — N is parsed out.
|
||||
Everything else (flat roofs, room-in-roof, "Unknown loft insulation",
|
||||
"Another Premises Above" — a flat with a dwelling above, no roof to insulate) has
|
||||
no clean loft depth, so it produces no overlay.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier
|
||||
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
|
||||
|
||||
_LOFT_MM = re.compile(r"(\d+)\+?\s*mm loft insulation")
|
||||
|
||||
|
||||
def roof_overlay_for(
|
||||
roof_type_value: str, building_part: int
|
||||
) -> Optional[EpcSimulation]:
|
||||
match = _LOFT_MM.search(roof_type_value)
|
||||
if match is None:
|
||||
return None
|
||||
|
||||
identifier = (
|
||||
BuildingPartIdentifier.MAIN
|
||||
if building_part == 0
|
||||
else BuildingPartIdentifier.extension(building_part)
|
||||
)
|
||||
return EpcSimulation(
|
||||
building_parts={
|
||||
identifier: BuildingPartOverlay(
|
||||
roof_insulation_thickness=int(match.group(1))
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
@ -19,8 +19,16 @@ from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
|
|||
|
||||
# RdSAP `wall_construction` codes by material prefix (domain/sap10_ml/rdsap_uvalues.py).
|
||||
_MATERIAL_CONSTRUCTION: dict[str, int] = {
|
||||
"Cavity wall": 4,
|
||||
"Granite or whin": 1,
|
||||
"Sandstone": 2,
|
||||
"Solid brick": 3,
|
||||
"Cavity wall": 4,
|
||||
"Timber frame": 5,
|
||||
"System built": 6,
|
||||
"Cob": 7,
|
||||
"Park home wall": 8,
|
||||
"Curtain wall": 9,
|
||||
"Curtain Wall": 9,
|
||||
}
|
||||
|
||||
# RdSAP `wall_insulation_type` codes by insulation-state suffix
|
||||
|
|
|
|||
|
|
@ -10,18 +10,29 @@ override produces an overlay only where a component mapping exists and resolves
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Optional
|
||||
|
||||
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
|
||||
|
||||
_WALL_TYPE = "wall_type"
|
||||
# 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.
|
||||
_COMPONENT_OVERLAYS: dict[str, Callable[[str, int], Optional[EpcSimulation]]] = {
|
||||
"wall_type": wall_overlay_for,
|
||||
"roof_type": roof_overlay_for,
|
||||
}
|
||||
|
||||
|
||||
def overlays_from(overrides: ResolvedPropertyOverrides) -> list[EpcSimulation]:
|
||||
overlays: list[EpcSimulation] = []
|
||||
for row in overrides.rows:
|
||||
if row.override_component == _WALL_TYPE:
|
||||
overlay = wall_overlay_for(row.override_value, row.building_part)
|
||||
if overlay is not None:
|
||||
overlays.append(overlay)
|
||||
mapper = _COMPONENT_OVERLAYS.get(row.override_component)
|
||||
if mapper is None:
|
||||
continue
|
||||
overlay = mapper(row.override_value, row.building_part)
|
||||
if overlay is not None:
|
||||
overlays.append(overlay)
|
||||
return overlays
|
||||
|
|
|
|||
61
tests/domain/epc/test_roof_type_overlay.py
Normal file
61
tests/domain/epc/test_roof_type_overlay.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"""The Landlord-Override `RoofType` → roof Simulation Overlay mapping (ADR-0032).
|
||||
|
||||
Only the explicit `"Pitched, N mm loft insulation"` family resolves — its loft
|
||||
depth maps to `roof_insulation_thickness`. Roofs with no clean loft depth
|
||||
(flat, room-in-roof, "Unknown", a dwelling above) produce no overlay.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier
|
||||
from domain.epc.roof_type_overlay import roof_overlay_for
|
||||
|
||||
|
||||
def test_pitched_loft_depth_maps_to_roof_insulation_thickness() -> None:
|
||||
# Act
|
||||
simulation = roof_overlay_for("Pitched, 300 mm loft insulation", 0)
|
||||
|
||||
# Assert
|
||||
assert simulation is not None
|
||||
overlay = simulation.building_parts[BuildingPartIdentifier.MAIN]
|
||||
assert overlay.roof_insulation_thickness == 300
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("roof_type_value", "expected_mm"),
|
||||
[
|
||||
("Pitched, 75 mm loft insulation", 75),
|
||||
("Pitched, 0 mm loft insulation", 0),
|
||||
("Pitched, 400+ mm loft insulation", 400),
|
||||
],
|
||||
)
|
||||
def test_each_loft_depth_is_parsed(roof_type_value: str, expected_mm: int) -> None:
|
||||
# Act
|
||||
simulation = roof_overlay_for(roof_type_value, 0)
|
||||
|
||||
# Assert
|
||||
assert simulation is not None
|
||||
assert simulation.building_parts[
|
||||
BuildingPartIdentifier.MAIN
|
||||
].roof_insulation_thickness == expected_mm
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"roof_type_value",
|
||||
[
|
||||
"Another Premises Above",
|
||||
"Pitched, Unknown loft insulation",
|
||||
"Flat, insulated",
|
||||
"",
|
||||
],
|
||||
)
|
||||
def test_roof_without_a_clean_loft_depth_produces_no_overlay(
|
||||
roof_type_value: str,
|
||||
) -> None:
|
||||
# Act
|
||||
simulation = roof_overlay_for(roof_type_value, 0)
|
||||
|
||||
# Assert
|
||||
assert simulation is None
|
||||
|
|
@ -51,6 +51,29 @@ def test_material_and_state_decompose_to_their_gov_codes(
|
|||
assert overlay.wall_insulation_type == insulation
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("wall_type_value", "construction"),
|
||||
[
|
||||
("Timber frame, as built, no insulation (assumed)", 5),
|
||||
("Granite or whin, as built, no insulation (assumed)", 1),
|
||||
("Sandstone, as built, no insulation (assumed)", 2),
|
||||
("System built, as built, no insulation (assumed)", 6),
|
||||
("Cob, with internal insulation", 7),
|
||||
],
|
||||
)
|
||||
def test_more_wall_materials_decompose_to_their_construction_code(
|
||||
wall_type_value: str, construction: int
|
||||
) -> None:
|
||||
# Act
|
||||
simulation = wall_overlay_for(wall_type_value, 0)
|
||||
|
||||
# Assert
|
||||
assert simulation is not None
|
||||
assert simulation.building_parts[BuildingPartIdentifier.MAIN].wall_construction == (
|
||||
construction
|
||||
)
|
||||
|
||||
|
||||
def test_overlay_targets_the_extension_building_part() -> None:
|
||||
# Act — building_part 1 is the first extension.
|
||||
simulation = wall_overlay_for("Solid brick, with internal insulation", 1)
|
||||
|
|
@ -62,7 +85,13 @@ def test_overlay_targets_the_extension_building_part() -> None:
|
|||
|
||||
@pytest.mark.parametrize(
|
||||
"wall_type_value",
|
||||
["Unknown", "Granite or whin, as built, no insulation (assumed)", ""],
|
||||
[
|
||||
"Unknown",
|
||||
# material maps, but the "(assumed) insulated" state is deferred (ADR-0032
|
||||
# — its wall_insulation_type code needs Elmhurst validation), so still None.
|
||||
"Solid brick, as built, insulated (assumed)",
|
||||
"",
|
||||
],
|
||||
)
|
||||
def test_unresolvable_wall_type_produces_no_overlay(wall_type_value: str) -> None:
|
||||
# Act
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
"""Mapping resolved overrides → Simulation Overlays (ADR-0032).
|
||||
|
||||
`overlays_from` turns the faithful value-space snapshot into the domain overlays
|
||||
that fold onto the lodged EPC — per component, partial, skipping unmapped rows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier
|
||||
from repositories.property.landlord_override_overlays import overlays_from
|
||||
from repositories.property.property_overrides_reader import (
|
||||
ResolvedPropertyOverride,
|
||||
ResolvedPropertyOverrides,
|
||||
)
|
||||
|
||||
|
||||
def test_roof_type_row_produces_a_roof_overlay() -> None:
|
||||
# Arrange
|
||||
overrides = ResolvedPropertyOverrides(
|
||||
rows=(ResolvedPropertyOverride("roof_type", 0, "Pitched, 300 mm loft insulation"),)
|
||||
)
|
||||
|
||||
# Act
|
||||
overlays = overlays_from(overrides)
|
||||
|
||||
# Assert
|
||||
assert len(overlays) == 1
|
||||
main = overlays[0].building_parts[BuildingPartIdentifier.MAIN]
|
||||
assert main.roof_insulation_thickness == 300
|
||||
|
||||
|
||||
def test_wall_and_roof_rows_each_produce_an_overlay() -> None:
|
||||
# Arrange
|
||||
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
|
||||
)
|
||||
)
|
||||
|
||||
# Act
|
||||
overlays = overlays_from(overrides)
|
||||
|
||||
# Assert — wall + roof map; property_type is skipped.
|
||||
assert len(overlays) == 2
|
||||
Loading…
Add table
Reference in a new issue