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:
Jun-te Kim 2026-06-17 17:15:50 +00:00
parent 5939520b0d
commit 0305241ad3
6 changed files with 203 additions and 7 deletions

View 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))
)
}
)

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View file

@ -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