mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
186 lines
8.1 KiB
Python
186 lines
8.1 KiB
Python
"""The roof Recommendation Generator.
|
|
|
|
Dispatches the MAIN roof to its single applicable insulation Measure by roof
|
|
type (ADR-0021): a sloping or vaulted ceiling (rafters, 100 mm), a flat roof
|
|
(200 mm), or — the fallback — a loft / thatched / unlodged pitched roof
|
|
(joists, 300 mm); a no-access roof gets nothing. The pitched branches resolve
|
|
a lodged sentinel thickness ("ND" / nothing) to the age band's as-built state
|
|
(ADR-0047). 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).
|
|
The room-in-roof branch lands in a later slice. No scoring, no persistence —
|
|
impact is produced later by scoring (ADR-0016).
|
|
"""
|
|
|
|
from typing import Final, Optional, Union
|
|
|
|
from datatypes.epc.domain.epc_property_data import (
|
|
BuildingPartIdentifier,
|
|
EpcPropertyData,
|
|
)
|
|
from domain.building_geometry import roof_area
|
|
from domain.modelling.measure_type import MeasureType
|
|
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 = MeasureType.LOFT_INSULATION
|
|
_SLOPING_CEILING_MEASURE_TYPE = MeasureType.SLOPING_CEILING_INSULATION
|
|
# 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
|
|
# Age bands whose as-built pitched roof is uninsulated (ADR-0047: A-D
|
|
# uninsulated, E-F limited insulation, G+ insulated) — the resolution for a
|
|
# cert lodging the "ND" (Not Defined) sentinel or no thickness at all.
|
|
_AS_BUILT_UNINSULATED_AGE_BANDS: Final[frozenset[str]] = frozenset(
|
|
{"A", "B", "C", "D"}
|
|
)
|
|
|
|
|
|
def _pitched_roof_is_uninsulated(
|
|
thickness: Union[str, int, None], age_band: Optional[str]
|
|
) -> bool:
|
|
"""The pitched-roof uninsulated trigger, sentinel-aware (ADR-0047).
|
|
|
|
`roof_insulation_thickness` lodges numerics AND string sentinels; the
|
|
sentinels are data, not noise, and must never make an undefined cert MORE
|
|
eligible than an explicitly-lodged one. An explicit 0 is uninsulated; the
|
|
"ND" (Not Defined) sentinel — or nothing lodged — resolves to the age
|
|
band's as-built state (only A-D built without pitched-roof insulation).
|
|
Anything else ("NI" = insulation present at unknown thickness per RdSAP 10
|
|
§5.11.4, or a measured depth) means insulation is there. Eligibility only
|
|
— the calculator resolves the same sentinels separately for the U-cascade
|
|
(deliberate two-home split, ADR-0047)."""
|
|
if isinstance(thickness, int):
|
|
return thickness == 0
|
|
if thickness is None or thickness == "ND":
|
|
return (
|
|
age_band is not None
|
|
and age_band.upper() in _AS_BUILT_UNINSULATED_AGE_BANDS
|
|
)
|
|
return False
|
|
_FLAT_ROOF_MEASURE_TYPE = MeasureType.FLAT_ROOF_INSULATION
|
|
# Recommended flat-roof depth (mm); Elmhurst re-lodges 200 mm (ADR-0021).
|
|
_RECOMMENDED_FLAT_ROOF_THICKNESS_MM = 200
|
|
|
|
|
|
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
|
|
)
|
|
|
|
# Room-in-roof safety guard (ADR-0021): a room-in-roof carries its
|
|
# insulation on its own sloping/stud/gable surfaces (RdSAP 10 §3.10, Table
|
|
# 17/18), which the loft/sloping overlay's flat `roof_insulation_thickness`
|
|
# bump cannot model. Without this guard a RR with an uninsulated loft would
|
|
# fall through to the loft fallback and mis-recommend loft insulation.
|
|
# Defer until a dedicated RR branch lands.
|
|
if main.sap_room_in_roof is not None:
|
|
return None
|
|
|
|
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`.
|
|
# A vaulted ceiling follows the slope like a sloping ceiling (no loft
|
|
# void) — same disjunction the calculator's `is_sloping_ceiling` uses
|
|
# (heat_transmission.py), so generator and scorer read one story (ADR-0047).
|
|
if "sloping ceiling" in roof_type or "vaulted" in roof_type:
|
|
if not _pitched_roof_is_uninsulated(
|
|
main.roof_insulation_thickness, main.construction_age_band
|
|
):
|
|
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,
|
|
)
|
|
|
|
if "flat" in roof_type:
|
|
# A flat roof lodges no thickness when uninsulated ("As Built" → None
|
|
# on the Elmhurst path); a lodged thickness means it's already done.
|
|
if main.roof_insulation_thickness is not None:
|
|
return None
|
|
return _roof_recommendation(
|
|
epc,
|
|
products,
|
|
measure_type=_FLAT_ROOF_MEASURE_TYPE,
|
|
description="Flat-roof insulation",
|
|
thickness_mm=_RECOMMENDED_FLAT_ROOF_THICKNESS_MM,
|
|
)
|
|
|
|
if "no access" in roof_type:
|
|
return None # the roof void can't be reached to insulate it
|
|
|
|
# Sentinel resolution needs a KNOWN pitched/thatched roof (ADR-0047). An
|
|
# unlodged type reaching this fallback can be a party ceiling — gov-API
|
|
# codes 6/7 ("(another dwelling above)" / insulated thatch) map the type
|
|
# to None and real code-7 certs lodge "ND" — with zero roof heat loss
|
|
# (RdSAP 10 Table 18), so only an explicit 0 may trigger it.
|
|
known_pitched: bool = "pitched" in roof_type or "thatch" in roof_type
|
|
uninsulated: bool = (
|
|
_pitched_roof_is_uninsulated(
|
|
main.roof_insulation_thickness, main.construction_age_band
|
|
)
|
|
if known_pitched
|
|
else main.roof_insulation_thickness == 0
|
|
)
|
|
if not uninsulated:
|
|
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(
|
|
epc: EpcPropertyData,
|
|
products: ProductRepository,
|
|
*,
|
|
measure_type: MeasureType,
|
|
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,))
|