feat(modelling): recommend_roof_insulation insulates a sloping ceiling

Slice 1 of the roof-insulation generator (ADR-0021). New `recommend_roof_insulation`
dispatcher keys on the `roof_construction_type` string: a "sloping ceiling" roof
that is uninsulated (roof_insulation_thickness 0/None) gets a single
`sloping_ceiling_insulation` Option whose overlay raises roof_insulation_thickness
to 100 mm. Pinned against the Elmhurst before→after cert 001431 at 1e-4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-04 21:02:06 +00:00
parent 0c7ce634db
commit 6484610b6c
4 changed files with 98 additions and 1 deletions

View file

@ -18,12 +18,19 @@ from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
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.
_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).
_RECOMMENDED_LOFT_THICKNESS_MM = 300
# Recommended sloping-ceiling depth (mm); Elmhurst re-lodges 100 mm (ADR-0021).
_RECOMMENDED_SLOPING_CEILING_THICKNESS_MM = 100
def recommend_loft_insulation(
@ -62,3 +69,63 @@ def recommend_loft_insulation(
material_id=product.id,
)
return Recommendation(surface="Roof", options=(option,))
def recommend_roof_insulation(
epc: EpcPropertyData, products: ProductRepository
) -> Optional[Recommendation]:
"""Return the single roof-insulation Recommendation for the MAIN roof,
dispatched by roof type (ADR-0021): a sloping ceiling is insulated at the
rafters to 100 mm. Returns None when the roof type has no applicable measure
or the roof is already insulated."""
main = next(
part
for part in epc.sap_building_parts
if part.identifier is BuildingPartIdentifier.MAIN
)
roof_type: str = (main.roof_construction_type or "").lower()
if "sloping ceiling" in roof_type:
if main.roof_insulation_thickness not in _ROOF_UNINSULATED_THICKNESSES:
return None
return _roof_recommendation(
epc,
products,
measure_type=_SLOPING_CEILING_MEASURE_TYPE,
description="Sloping-ceiling insulation (insulate at the rafters)",
thickness_mm=_RECOMMENDED_SLOPING_CEILING_THICKNESS_MM,
)
return None
def _roof_recommendation(
epc: EpcPropertyData,
products: ProductRepository,
*,
measure_type: str,
description: str,
thickness_mm: int,
) -> Recommendation:
"""Build a single-Option "Roof" Recommendation: the measure's insulation
overlay (raising `roof_insulation_thickness` to the recommended depth)
priced at the roof area."""
product = products.get(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=measure_type,
description=description,
overlay=EpcSimulation(
building_parts={
BuildingPartIdentifier.MAIN: BuildingPartOverlay(
roof_insulation_thickness=thickness_mm
)
}
),
cost=cost,
material_id=product.id,
)
return Recommendation(surface="Roof", options=(option,))

View file

@ -27,7 +27,10 @@ from domain.modelling.scoring.package_scorer import PackageScorer, Score
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
from domain.modelling.generators.roof_recommendation import (
recommend_loft_insulation,
recommend_roof_insulation,
)
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
from domain.modelling.generators.wall_recommendation import recommend_cavity_wall
from domain.geospatial.planning_restrictions import PlanningRestrictions
@ -336,6 +339,33 @@ def test_loft_overlay_reproduces_the_relodged_after() -> None:
)
def test_roof_generator_insulates_a_sloping_ceiling_pinning_its_after() -> None:
# Arrange — a pitched roof with an uninsulated sloping ceiling; the re-lodged
# after raises its insulation from As Built to 100 mm (ADR-0021).
before: EpcPropertyData = parse_recommendation_summary(
"sloping_ceiling_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"sloping_ceiling_001431_after.pdf"
)
# Act — the dispatcher detects "sloping ceiling" and offers the sloping
# measure (not loft).
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 sloping-ceiling Option whose overlay reproduces the after.
assert set(options) == {"sloping_ceiling_insulation"}
_assert_overlay_reproduces_after(
before, after, options["sloping_ceiling_insulation"].overlay
)
def test_solid_floor_overlay_reproduces_the_relodged_after() -> None:
# Arrange
before: EpcPropertyData = parse_recommendation_summary(