mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
234 lines
9.1 KiB
Python
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
|