mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
d02b7348a6
commit
3c87be8e1e
6 changed files with 172 additions and 1 deletions
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ extended as each measure type lands.
|
|||
|
||||
_CONTINGENCY_RATES: dict[str, float] = {
|
||||
"cavity_wall_insulation": 0.10,
|
||||
"loft_insulation": 0.10,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
61
domain/modelling/roof_recommendation.py
Normal file
61
domain/modelling/roof_recommendation.py
Normal 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,))
|
||||
|
|
@ -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]:
|
||||
|
|
|
|||
81
tests/domain/modelling/test_roof_recommendation.py
Normal file
81
tests/domain/modelling/test_roof_recommendation.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue