feat(modelling): fold loft into the roof dispatcher; thatch routes to loft

Slice 2 (ADR-0021). `recommend_roof_insulation` now owns the loft branch as the
fallback — a plain pitched loft, a thatched roof (the covering doesn't block
insulating the loft floor), or an unlodged roof type all take loft (joist)
insulation at 300 mm when `roof_insulation_thickness == 0`. Sloping is tested
first; a no-access roof gets nothing. Retired the standalone
`recommend_loft_insulation`; the orchestrator and its tests now call the
dispatcher.

Pinned: thatch before→after (None→300) reproduces at 1e-4; the existing loft pin
still holds through the dispatcher. Behaviour-preserving on the golden cohort
(roof measure unchanged: none across all 57) — the dispatch is strictly more
precise (won't fire loft on a sloping/no-access roof).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-04 21:12:01 +00:00
parent 6484610b6c
commit 7d40cddf3b
6 changed files with 63 additions and 56 deletions

View file

@ -1,9 +1,13 @@
"""The roof Recommendation Generator.
Detects an uninsulated loft on an EpcPropertyData and emits a Recommendation
whose Measure Option carries the loft-insulation Simulation Overlay and a priced
Dispatches the MAIN roof to its single applicable insulation Measure by roof
type (ADR-0021): a sloping ceiling (rafters, 100 mm), or the fallback a
loft / thatched / unlodged pitched roof (joists, 300 mm); a no-access roof gets
nothing. Each emits one "Roof" Recommendation whose Option carries the
insulation Simulation Overlay (raising `roof_insulation_thickness`) and a priced
Cost (roof area x the Product's fully-loaded unit cost, with its contingency).
No scoring, no persistence impact is produced later by scoring (ADR-0016).
Flat-roof and room-in-roof branches land in later slices. No scoring, no
persistence impact is produced later by scoring (ADR-0016).
"""
from typing import Optional
@ -19,12 +23,9 @@ from repositories.product.product_repository import ProductRepository
_LOFT_MEASURE_TYPE = "loft_insulation"
_SLOPING_CEILING_MEASURE_TYPE = "sloping_ceiling_insulation"
# RdSAP 10 Table 16: 0 mm lodged roof insulation is an uninsulated loft.
# RdSAP 10 Table 16: 0 mm lodged roof insulation is an uninsulated loft. The
# Elmhurst mapper resolves "As Built" to 0 for pitched/sloping/loft roofs.
_ROOF_UNINSULATED_MM = 0
# A roof is uninsulated when its lodged insulation thickness is 0 or absent —
# the Elmhurst mapper resolves "As Built" to 0 for pitched/sloping roofs and to
# None for flat roofs (ADR-0021).
_ROOF_UNINSULATED_THICKNESSES: tuple[Optional[int], ...] = (None, 0)
# Recommended loft-insulation depth (mm). Elmhurst re-lodges a loft-insulation
# measure at 300 mm; pinning the before→after cascade (000490/001431) requires
# the overlay to match that depth exactly (see test_elmhurst_cascade_pins).
@ -33,44 +34,6 @@ _RECOMMENDED_LOFT_THICKNESS_MM = 300
_RECOMMENDED_SLOPING_CEILING_THICKNESS_MM = 100
def recommend_loft_insulation(
epc: EpcPropertyData, products: ProductRepository
) -> Optional[Recommendation]:
"""Return a loft-insulation Recommendation for the main roof when it is
uninsulated, else None. The Option's cost is the roof area priced at the
Product's fully-loaded unit cost, with its contingency."""
main = next(
part
for part in epc.sap_building_parts
if part.identifier is BuildingPartIdentifier.MAIN
)
if main.roof_insulation_thickness != _ROOF_UNINSULATED_MM:
return None
product = products.get(_LOFT_MEASURE_TYPE)
area: float = roof_area(epc, BuildingPartIdentifier.MAIN)
cost = Cost(
total=area * product.unit_cost_per_m2,
contingency_rate=product.contingency_rate,
)
option = MeasureOption(
measure_type=_LOFT_MEASURE_TYPE,
description="Loft insulation (top up to recommended depth)",
overlay=EpcSimulation(
building_parts={
BuildingPartIdentifier.MAIN: BuildingPartOverlay(
roof_insulation_thickness=_RECOMMENDED_LOFT_THICKNESS_MM
)
}
),
cost=cost,
material_id=product.id,
)
return Recommendation(surface="Roof", options=(option,))
def recommend_roof_insulation(
epc: EpcPropertyData, products: ProductRepository
) -> Optional[Recommendation]:
@ -85,8 +48,14 @@ def recommend_roof_insulation(
)
roof_type: str = (main.roof_construction_type or "").lower()
# Dispatch by roof type (ADR-0021). Order matters: a sloping ceiling is
# tested before the loft fallback, and "no access" before it too, because
# "no access to loft" contains "loft". Loft is the fallback — it covers a
# plain pitched loft, a thatched roof (the covering doesn't block insulating
# the loft floor), and an unlodged roof type (the modal UK case), matching
# the pre-dispatcher behaviour of firing on `roof_insulation_thickness == 0`.
if "sloping ceiling" in roof_type:
if main.roof_insulation_thickness not in _ROOF_UNINSULATED_THICKNESSES:
if main.roof_insulation_thickness != _ROOF_UNINSULATED_MM:
return None
return _roof_recommendation(
epc,
@ -95,7 +64,19 @@ def recommend_roof_insulation(
description="Sloping-ceiling insulation (insulate at the rafters)",
thickness_mm=_RECOMMENDED_SLOPING_CEILING_THICKNESS_MM,
)
return None
if "no access" in roof_type:
return None # the roof void can't be reached to insulate it
if main.roof_insulation_thickness != _ROOF_UNINSULATED_MM:
return None
return _roof_recommendation(
epc,
products,
measure_type=_LOFT_MEASURE_TYPE,
description="Loft insulation (top up to recommended depth)",
thickness_mm=_RECOMMENDED_LOFT_THICKNESS_MM,
)
def _roof_recommendation(

View file

@ -18,7 +18,7 @@ from domain.modelling.optimisation.optimiser import (
from domain.modelling.scoring.package_scorer import PackageScorer, Score
from domain.modelling.plan import Plan, PlanMeasure
from domain.modelling.recommendation import MeasureOption, Recommendation
from domain.modelling.generators.roof_recommendation import recommend_loft_insulation
from domain.modelling.generators.roof_recommendation import recommend_roof_insulation
from domain.modelling.scenario import Scenario
from domain.modelling.scoring.scoring import (
MeasureImpact,
@ -203,7 +203,7 @@ def _candidate_recommendations(
found = (
recommend_cavity_wall(effective_epc, products),
recommend_solid_wall(effective_epc, products, planning_restrictions),
recommend_loft_insulation(effective_epc, products),
recommend_roof_insulation(effective_epc, products),
recommend_floor_insulation(effective_epc, products),
)
return [recommendation for recommendation in found if recommendation is not None]

View file

@ -28,7 +28,6 @@ from domain.modelling.product import Product
from domain.modelling.recommendation import Recommendation
from domain.modelling.generators.floor_recommendation import recommend_floor_insulation
from domain.modelling.generators.roof_recommendation import (
recommend_loft_insulation,
recommend_roof_insulation,
)
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
@ -328,7 +327,7 @@ def test_loft_overlay_reproduces_the_relodged_after() -> None:
after: EpcPropertyData = parse_recommendation_summary(
"loft_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_loft_insulation(
recommendation: Recommendation | None = recommend_roof_insulation(
before, _AnyProduct()
)
assert recommendation is not None
@ -366,6 +365,33 @@ def test_roof_generator_insulates_a_sloping_ceiling_pinning_its_after() -> None:
)
def test_roof_generator_insulates_a_thatched_roof_as_loft_pinning_its_after() -> None:
# Arrange — a thatched pitched roof. Thatch is NOT excluded: the covering
# doesn't block insulating the loft floor, so it takes loft (joist)
# insulation, re-lodged None → 300 mm (ADR-0021).
before: EpcPropertyData = parse_recommendation_summary(
"loft_thatched_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"loft_thatched_001431_after.pdf"
)
# Act — the dispatcher routes a thatched roof to the loft branch.
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 loft Option whose overlay reproduces the after.
assert set(options) == {"loft_insulation"}
_assert_overlay_reproduces_after(
before, after, options["loft_insulation"].overlay
)
def test_solid_floor_overlay_reproduces_the_relodged_after() -> None:
# Arrange
before: EpcPropertyData = parse_recommendation_summary(

View file

@ -11,7 +11,7 @@ from datatypes.epc.domain.epc_property_data import (
from domain.modelling.scoring.overlay_applicator import apply_simulations
from domain.modelling.product import Product
from domain.modelling.recommendation import Recommendation
from domain.modelling.generators.roof_recommendation import recommend_loft_insulation
from domain.modelling.generators.roof_recommendation import recommend_roof_insulation
from repositories.product.product_repository import ProductRepository
from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import (
build_epc,
@ -35,7 +35,7 @@ def test_uninsulated_loft_yields_a_loft_insulation_recommendation() -> None:
_part(baseline, BuildingPartIdentifier.MAIN).roof_insulation_thickness = 0
# Act
recommendation: Recommendation | None = recommend_loft_insulation(
recommendation: Recommendation | None = recommend_roof_insulation(
baseline, _StubProducts()
)
@ -55,7 +55,7 @@ def test_already_insulated_loft_yields_no_recommendation() -> None:
_part(baseline, BuildingPartIdentifier.MAIN).roof_insulation_thickness = 300
# Act
recommendation: Recommendation | None = recommend_loft_insulation(
recommendation: Recommendation | None = recommend_roof_insulation(
baseline, _StubProducts()
)
@ -69,7 +69,7 @@ def test_loft_option_carries_cost_from_roof_area_and_product() -> None:
_part(baseline, BuildingPartIdentifier.MAIN).roof_insulation_thickness = 0
# Act
recommendation: Recommendation | None = recommend_loft_insulation(
recommendation: Recommendation | None = recommend_roof_insulation(
baseline, _StubProducts()
)