Model/tests/domain/modelling/test_roof_recommendation.py
Khalim Conn-Kowlessar 0efae77fd2 An unlodged roof type with ND thickness gets no roof recommendation 🟥
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 11:18:55 +00:00

234 lines
9.1 KiB
Python

"""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,
SapRoomInRoof,
)
from domain.modelling.scoring.overlay_applicator import apply_simulations
from domain.modelling.product import Product
from domain.modelling.recommendation import Recommendation
from domain.modelling.generators.roof_recommendation import recommend_roof_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_roof_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 == 300
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_roof_insulation(
baseline, _StubProducts()
)
# Assert
assert recommendation is None
def test_room_in_roof_yields_no_recommendation_pending_a_dedicated_branch() -> None:
# Arrange — an uninsulated loft the fallback would otherwise top up, but the
# part is a room-in-roof. The simple loft/sloping overlay can't model RR
# insulation (its sloping/stud/gable surfaces carry their own U-values via
# Table 17/18), so the generator must defer rather than mis-fire loft.
baseline: EpcPropertyData = build_epc()
main: SapBuildingPart = _part(baseline, BuildingPartIdentifier.MAIN)
main.roof_insulation_thickness = 0
main.sap_room_in_roof = SapRoomInRoof(floor_area=9.0, construction_age_band="D")
# Act
recommendation: Recommendation | None = recommend_roof_insulation(
baseline, _StubProducts()
)
# Assert
assert recommendation is None
def test_vaulted_ceiling_with_nd_thickness_on_pre_1950_dwelling_yields_sloping_ceiling_insulation() -> None:
# Arrange — the 742265 shape (ADR-0047): a "Pitched (vaulted ceiling)" MAIN
# roof lodging the "ND" sentinel on an age-band-B dwelling. ND resolves to
# the age band's as-built state (A-D: uninsulated), and a vaulted ceiling
# has no loft void, so the one applicable Measure is rafter insulation.
baseline: EpcPropertyData = build_epc()
main: SapBuildingPart = _part(baseline, BuildingPartIdentifier.MAIN)
main.roof_construction_type = "Pitched (vaulted ceiling)"
main.roof_insulation_thickness = "ND"
main.construction_age_band = "B"
# Act
recommendation: Recommendation | None = recommend_roof_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 == "sloping_ceiling_insulation"
simulated: EpcPropertyData = apply_simulations(baseline, [option.overlay])
assert (
_part(simulated, BuildingPartIdentifier.MAIN).roof_insulation_thickness == 100
)
def test_vaulted_ceiling_with_nd_thickness_on_limited_insulation_age_band_yields_no_recommendation() -> None:
# Arrange — same vaulted-ND shape, but age band E: as-built carries limited
# insulation (ADR-0047), and ND must never make an undefined cert MORE
# eligible than an explicit thin lodgement (which is not eligible today).
baseline: EpcPropertyData = build_epc()
main: SapBuildingPart = _part(baseline, BuildingPartIdentifier.MAIN)
main.roof_construction_type = "Pitched (vaulted ceiling)"
main.roof_insulation_thickness = "ND"
main.construction_age_band = "E"
# Act
recommendation: Recommendation | None = recommend_roof_insulation(
baseline, _StubProducts()
)
# Assert
assert recommendation is None
def test_vaulted_ceiling_with_ni_thickness_yields_no_recommendation() -> None:
# Arrange — "NI" means insulation PRESENT at unknown thickness (RdSAP 10
# §5.11.4: score as 50 mm), never "no insulation" — even on an age band
# whose as-built state is uninsulated (ADR-0047, the ND ≠ NI distinction).
baseline: EpcPropertyData = build_epc()
main: SapBuildingPart = _part(baseline, BuildingPartIdentifier.MAIN)
main.roof_construction_type = "Pitched (vaulted ceiling)"
main.roof_insulation_thickness = "NI"
main.construction_age_band = "B"
# Act
recommendation: Recommendation | None = recommend_roof_insulation(
baseline, _StubProducts()
)
# Assert
assert recommendation is None
def test_vaulted_ceiling_with_measured_thickness_yields_no_recommendation() -> None:
# Arrange — an explicitly-lodged depth means the rafters are already
# insulated; the sentinel-aware trigger must not loosen the numeric rule.
baseline: EpcPropertyData = build_epc()
main: SapBuildingPart = _part(baseline, BuildingPartIdentifier.MAIN)
main.roof_construction_type = "Pitched (vaulted ceiling)"
main.roof_insulation_thickness = 100
main.construction_age_band = "B"
# Act
recommendation: Recommendation | None = recommend_roof_insulation(
baseline, _StubProducts()
)
# Assert
assert recommendation is None
def test_loft_roof_with_nd_thickness_on_pre_1950_dwelling_yields_loft_insulation() -> None:
# Arrange — the loft fallback shares the sentinel semantics (ADR-0047):
# "ND" on an age-band-C pitched loft resolves to as-built uninsulated, so
# the joist measure fires just as it does for an explicit 0. The lodged
# type is the gov-API code-4 string — sentinel resolution needs a KNOWN
# pitched roof (an unlodged type could be a party ceiling; see below).
baseline: EpcPropertyData = build_epc()
main: SapBuildingPart = _part(baseline, BuildingPartIdentifier.MAIN)
main.roof_construction_type = "Pitched (slates/tiles), access to loft"
main.roof_insulation_thickness = "ND"
main.construction_age_band = "C"
# Act
recommendation: Recommendation | None = recommend_roof_insulation(
baseline, _StubProducts()
)
# Assert
assert recommendation is not None
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 == 300
)
def test_unlodged_roof_type_with_nd_thickness_yields_no_recommendation() -> None:
# Arrange — a part with NO lodged roof type reaching the loft fallback can
# be a party ceiling ("(another dwelling above)", gov-API codes 6/7 map the
# type to None and real code-7 certs lodge "ND"): zero roof heat loss, so
# insulating it contradicts the dwelling. Sentinel resolution therefore
# requires a KNOWN pitched/thatched roof; an unlodged type keeps the
# explicit-0-only trigger (ADR-0047).
baseline: EpcPropertyData = build_epc()
main: SapBuildingPart = _part(baseline, BuildingPartIdentifier.MAIN)
main.roof_construction_type = None
main.roof_insulation_thickness = "ND"
main.construction_age_band = "B"
# Act
recommendation: Recommendation | None = recommend_roof_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_roof_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