feat(modelling): roof (loft) Recommendation Generator + roof-area geometry

recommend_loft_insulation(epc, products) detects an uninsulated main loft
(SapBuildingPart.roof_insulation_thickness == 0) and emits a
Recommendation("Roof") with one loft_insulation Option carrying the overlay
(roof_insulation_thickness = 270 mm, the recommended top-up) and a priced
Cost (roof area x the Product's fully-loaded unit cost + contingency).

- building_geometry.roof_area(epc, identifier): the part's greatest
  per-storey floor area (RdSAP 10 §3.8). Pinned 14.85 m^2 on 000490 MAIN.
- BuildingPartOverlay gains roof_insulation_thickness; the generic Overlay
  Applicator writes it with NO change (validated by the tracer) — the
  deep-module field-fold paying off.
- loft_insulation contingency (0.10) added.

Progress on #1158 (generator + geometry); end-to-end + Elmhurst pin pending
the orchestrator (#1157) and the parser fix. Four behaviour tests
(geometry pin; detect / none / cost). pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-03 09:05:38 +00:00
parent d02b7348a6
commit 3c87be8e1e
6 changed files with 172 additions and 1 deletions

View file

@ -31,3 +31,17 @@ def gross_heat_loss_wall_area(
for fd in part.sap_floor_dimensions
)
return round(area, 2)
def roof_area(epc: EpcPropertyData, identifier: BuildingPartIdentifier) -> float:
"""Roof area of one building part, in m^2. Per RdSAP10 §3.8 the roof area is
the greatest of the part's per-storey floor areas (not the top-floor area,
which can be smaller)."""
part = next(
candidate
for candidate in epc.sap_building_parts
if candidate.identifier is identifier
)
return round(
max(fd.total_floor_area_m2 for fd in part.sap_floor_dimensions), 2
)

View file

@ -7,6 +7,7 @@ extended as each measure type lands.
_CONTINGENCY_RATES: dict[str, float] = {
"cavity_wall_insulation": 0.10,
"loft_insulation": 0.10,
}

View file

@ -0,0 +1,61 @@
"""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
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).
"""
from typing import Optional
from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData,
)
from domain.building_geometry import roof_area
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
from repositories.product.product_repository import ProductRepository
_LOFT_MEASURE_TYPE = "loft_insulation"
# RdSAP 10 Table 16: 0 mm lodged roof insulation is an uninsulated loft.
_ROOF_UNINSULATED_MM = 0
# Recommended loft-insulation depth (mm) — the building-regs standard top-up.
_RECOMMENDED_LOFT_THICKNESS_MM = 270
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,
)
return Recommendation(surface="Roof", options=(option,))

View file

@ -22,6 +22,7 @@ class BuildingPartOverlay:
"""
wall_insulation_type: Optional[int] = None
roof_insulation_thickness: Optional[int] = None
def _no_building_parts() -> dict[BuildingPartIdentifier, BuildingPartOverlay]:

View file

@ -0,0 +1,81 @@
"""Behaviour of the roof Recommendation Generator: detecting an uninsulated
loft and emitting a Recommendation whose Measure Option carries the loft-
insulation Simulation Overlay and a priced Cost. Mirrors the wall generator.
"""
from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData,
SapBuildingPart,
)
from domain.modelling.overlay_applicator import apply_simulations
from domain.modelling.product import Product
from domain.modelling.recommendation import Recommendation
from domain.modelling.roof_recommendation import recommend_loft_insulation
from repositories.product.product_repository import ProductRepository
from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import (
build_epc,
)
class _StubProducts(ProductRepository):
def get(self, measure_type: str) -> Product:
return Product(
measure_type=measure_type, unit_cost_per_m2=30.0, contingency_rate=0.10
)
def _part(epc: EpcPropertyData, identifier: BuildingPartIdentifier) -> SapBuildingPart:
return next(p for p in epc.sap_building_parts if p.identifier is identifier)
def test_uninsulated_loft_yields_a_loft_insulation_recommendation() -> None:
# Arrange
baseline: EpcPropertyData = build_epc()
_part(baseline, BuildingPartIdentifier.MAIN).roof_insulation_thickness = 0
# Act
recommendation: Recommendation | None = recommend_loft_insulation(
baseline, _StubProducts()
)
# Assert
assert recommendation is not None
assert recommendation.surface == "Roof"
assert len(recommendation.options) == 1
option = recommendation.options[0]
assert option.measure_type == "loft_insulation"
simulated: EpcPropertyData = apply_simulations(baseline, [option.overlay])
assert _part(simulated, BuildingPartIdentifier.MAIN).roof_insulation_thickness == 270
def test_already_insulated_loft_yields_no_recommendation() -> None:
# Arrange
baseline: EpcPropertyData = build_epc() # MAIN roof already 300 mm
_part(baseline, BuildingPartIdentifier.MAIN).roof_insulation_thickness = 300
# Act
recommendation: Recommendation | None = recommend_loft_insulation(
baseline, _StubProducts()
)
# Assert
assert recommendation is None
def test_loft_option_carries_cost_from_roof_area_and_product() -> None:
# Arrange
baseline: EpcPropertyData = build_epc() # MAIN roof area 14.85 m^2
_part(baseline, BuildingPartIdentifier.MAIN).roof_insulation_thickness = 0
# Act
recommendation: Recommendation | None = recommend_loft_insulation(
baseline, _StubProducts()
)
# Assert
assert recommendation is not None
cost = recommendation.options[0].cost
assert cost is not None
assert abs(cost.total - 14.85 * 30.0) <= 0.01
assert abs(cost.contingency_rate - 0.10) <= 1e-9

View file

@ -2,7 +2,7 @@
reusable outside the SAP calculator (e.g. for Modelling cost quantities)."""
from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier
from domain.building_geometry import gross_heat_loss_wall_area
from domain.building_geometry import gross_heat_loss_wall_area, roof_area
from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import (
build_epc,
)
@ -20,3 +20,16 @@ def test_gross_heat_loss_wall_area_sums_perimeter_times_height_per_storey() -> N
# Assert
assert abs(area - 45.93) <= 0.01
def test_roof_area_is_the_parts_greatest_floor_area() -> None:
# Arrange
# RdSAP10 §3.8: roof area is the greatest of the floor areas on each
# level. 000490 MAIN has two floors of 14.85 m^2, so the roof is 14.85.
epc = build_epc()
# Act
area: float = roof_area(epc, BuildingPartIdentifier.MAIN)
# Assert
assert abs(area - 14.85) <= 0.01