mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Expand half of the recommendation_materials retirement (ADR-0017). A Plan Measure installs a single Product, so thread its catalogue id end to end — Product.id -> MeasureOption.material_id -> PlanMeasure.material_id -> recommendation.material_id — replacing the per-material BOM child table with one nullable column on the row. ProductPostgresRepository reads the id from MaterialRow; the four fabric generators set it on their Option; the orchestrator carries it onto the Plan Measure; the mirror declares + maps the column. Optional throughout (the JSON stopgap catalogue carries no ids -> NULL). The multi-measure integration test now pins each persisted measure's material_id to its seeded MaterialRow id. Migration spec (live column must be added before this deploys; contraction is the owner's next step) in docs/migrations/recommendation-material-id.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
64 lines
2.4 KiB
Python
64 lines
2.4 KiB
Python
"""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). 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
|
|
|
|
|
|
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,))
|