feat(modelling): roof dispatcher insulates a flat roof

Slice 3 (ADR-0021). The dispatcher gains a flat-roof branch: a "flat"
roof_construction_type with no lodged thickness (uninsulated → None on the
Elmhurst path) gets a single flat_roof_insulation Option whose overlay raises
roof_insulation_thickness to 200 mm — tested before the loft fallback so a flat
roof's None doesn't trip the loft trigger. Pinned against the Elmhurst
before→after cert at 1e-4. Golden cohort roof firing unchanged (none across 57).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-04 21:16:00 +00:00
parent 7d40cddf3b
commit 13b18ce9fb
4 changed files with 42 additions and 0 deletions

View file

@ -32,6 +32,9 @@ _ROOF_UNINSULATED_MM = 0
_RECOMMENDED_LOFT_THICKNESS_MM = 300
# Recommended sloping-ceiling depth (mm); Elmhurst re-lodges 100 mm (ADR-0021).
_RECOMMENDED_SLOPING_CEILING_THICKNESS_MM = 100
_FLAT_ROOF_MEASURE_TYPE = "flat_roof_insulation"
# Recommended flat-roof depth (mm); Elmhurst re-lodges 200 mm (ADR-0021).
_RECOMMENDED_FLAT_ROOF_THICKNESS_MM = 200
def recommend_roof_insulation(
@ -65,6 +68,19 @@ def recommend_roof_insulation(
thickness_mm=_RECOMMENDED_SLOPING_CEILING_THICKNESS_MM,
)
if "flat" in roof_type:
# A flat roof lodges no thickness when uninsulated ("As Built" → None
# on the Elmhurst path); a lodged thickness means it's already done.
if main.roof_insulation_thickness is not None:
return None
return _roof_recommendation(
epc,
products,
measure_type=_FLAT_ROOF_MEASURE_TYPE,
description="Flat-roof insulation",
thickness_mm=_RECOMMENDED_FLAT_ROOF_THICKNESS_MM,
)
if "no access" in roof_type:
return None # the roof void can't be reached to insulate it

View file

@ -392,6 +392,32 @@ def test_roof_generator_insulates_a_thatched_roof_as_loft_pinning_its_after() ->
)
def test_roof_generator_insulates_a_flat_roof_pinning_its_after() -> None:
# Arrange — a flat roof, uninsulated (As Built → None on the Elmhurst path);
# the re-lodged after raises it to 200 mm (ADR-0021).
before: EpcPropertyData = parse_recommendation_summary(
"flat_roof_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"flat_roof_001431_after.pdf"
)
# Act
recommendation: Recommendation | None = recommend_roof_insulation(
before, _AnyProduct()
)
assert recommendation is not None
options: dict[str, MeasureOption] = {
option.measure_type: option for option in recommendation.options
}
# Assert — one flat-roof Option whose overlay reproduces the after.
assert set(options) == {"flat_roof_insulation"}
_assert_overlay_reproduces_after(
before, after, options["flat_roof_insulation"].overlay
)
def test_solid_floor_overlay_reproduces_the_relodged_after() -> None:
# Arrange
before: EpcPropertyData = parse_recommendation_summary(