mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
feat(modelling): fold loft into the roof dispatcher; thatch routes to loft
Slice 2 (ADR-0021). `recommend_roof_insulation` now owns the loft branch as the fallback — a plain pitched loft, a thatched roof (the covering doesn't block insulating the loft floor), or an unlodged roof type all take loft (joist) insulation at 300 mm when `roof_insulation_thickness == 0`. Sloping is tested first; a no-access roof gets nothing. Retired the standalone `recommend_loft_insulation`; the orchestrator and its tests now call the dispatcher. Pinned: thatch before→after (None→300) reproduces at 1e-4; the existing loft pin still holds through the dispatcher. Behaviour-preserving on the golden cohort (roof measure unchanged: none across all 57) — the dispatch is strictly more precise (won't fire loft on a sloping/no-access roof). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
6484610b6c
commit
7d40cddf3b
6 changed files with 63 additions and 56 deletions
|
|
@ -1,9 +1,13 @@
|
|||
"""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
|
||||
Dispatches the MAIN roof to its single applicable insulation Measure by roof
|
||||
type (ADR-0021): a sloping ceiling (rafters, 100 mm), or — the fallback — a
|
||||
loft / thatched / unlodged pitched roof (joists, 300 mm); a no-access roof gets
|
||||
nothing. 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).
|
||||
No scoring, no persistence — impact is produced later by scoring (ADR-0016).
|
||||
Flat-roof and room-in-roof branches land in later slices. No scoring, no
|
||||
persistence — impact is produced later by scoring (ADR-0016).
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
|
@ -19,12 +23,9 @@ from repositories.product.product_repository import ProductRepository
|
|||
|
||||
_LOFT_MEASURE_TYPE = "loft_insulation"
|
||||
_SLOPING_CEILING_MEASURE_TYPE = "sloping_ceiling_insulation"
|
||||
# RdSAP 10 Table 16: 0 mm lodged roof insulation is an uninsulated loft.
|
||||
# RdSAP 10 Table 16: 0 mm lodged roof insulation is an uninsulated loft. The
|
||||
# Elmhurst mapper resolves "As Built" to 0 for pitched/sloping/loft roofs.
|
||||
_ROOF_UNINSULATED_MM = 0
|
||||
# A roof is uninsulated when its lodged insulation thickness is 0 or absent —
|
||||
# the Elmhurst mapper resolves "As Built" to 0 for pitched/sloping roofs and to
|
||||
# None for flat roofs (ADR-0021).
|
||||
_ROOF_UNINSULATED_THICKNESSES: tuple[Optional[int], ...] = (None, 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).
|
||||
|
|
@ -33,44 +34,6 @@ _RECOMMENDED_LOFT_THICKNESS_MM = 300
|
|||
_RECOMMENDED_SLOPING_CEILING_THICKNESS_MM = 100
|
||||
|
||||
|
||||
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,))
|
||||
|
||||
|
||||
def recommend_roof_insulation(
|
||||
epc: EpcPropertyData, products: ProductRepository
|
||||
) -> Optional[Recommendation]:
|
||||
|
|
@ -85,8 +48,14 @@ def recommend_roof_insulation(
|
|||
)
|
||||
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`.
|
||||
if "sloping ceiling" in roof_type:
|
||||
if main.roof_insulation_thickness not in _ROOF_UNINSULATED_THICKNESSES:
|
||||
if main.roof_insulation_thickness != _ROOF_UNINSULATED_MM:
|
||||
return None
|
||||
return _roof_recommendation(
|
||||
epc,
|
||||
|
|
@ -95,7 +64,19 @@ def recommend_roof_insulation(
|
|||
description="Sloping-ceiling insulation (insulate at the rafters)",
|
||||
thickness_mm=_RECOMMENDED_SLOPING_CEILING_THICKNESS_MM,
|
||||
)
|
||||
return None
|
||||
|
||||
if "no access" in roof_type:
|
||||
return None # the roof void can't be reached to insulate it
|
||||
|
||||
if main.roof_insulation_thickness != _ROOF_UNINSULATED_MM:
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ from domain.modelling.optimisation.optimiser import (
|
|||
from domain.modelling.scoring.package_scorer import PackageScorer, Score
|
||||
from domain.modelling.plan import Plan, PlanMeasure
|
||||
from domain.modelling.recommendation import MeasureOption, Recommendation
|
||||
from domain.modelling.generators.roof_recommendation import recommend_loft_insulation
|
||||
from domain.modelling.generators.roof_recommendation import recommend_roof_insulation
|
||||
from domain.modelling.scenario import Scenario
|
||||
from domain.modelling.scoring.scoring import (
|
||||
MeasureImpact,
|
||||
|
|
@ -203,7 +203,7 @@ def _candidate_recommendations(
|
|||
found = (
|
||||
recommend_cavity_wall(effective_epc, products),
|
||||
recommend_solid_wall(effective_epc, products, planning_restrictions),
|
||||
recommend_loft_insulation(effective_epc, products),
|
||||
recommend_roof_insulation(effective_epc, products),
|
||||
recommend_floor_insulation(effective_epc, products),
|
||||
)
|
||||
return [recommendation for recommendation in found if recommendation is not None]
|
||||
|
|
|
|||
BIN
tests/domain/modelling/fixtures/loft_thatched_001431_after.pdf
Normal file
BIN
tests/domain/modelling/fixtures/loft_thatched_001431_after.pdf
Normal file
Binary file not shown.
BIN
tests/domain/modelling/fixtures/loft_thatched_001431_before.pdf
Normal file
BIN
tests/domain/modelling/fixtures/loft_thatched_001431_before.pdf
Normal file
Binary file not shown.
|
|
@ -28,7 +28,6 @@ from domain.modelling.product import Product
|
|||
from domain.modelling.recommendation import Recommendation
|
||||
from domain.modelling.generators.floor_recommendation import recommend_floor_insulation
|
||||
from domain.modelling.generators.roof_recommendation import (
|
||||
recommend_loft_insulation,
|
||||
recommend_roof_insulation,
|
||||
)
|
||||
from domain.modelling.simulation import BuildingPartOverlay, EpcSimulation
|
||||
|
|
@ -328,7 +327,7 @@ def test_loft_overlay_reproduces_the_relodged_after() -> None:
|
|||
after: EpcPropertyData = parse_recommendation_summary(
|
||||
"loft_001431_after.pdf"
|
||||
)
|
||||
recommendation: Recommendation | None = recommend_loft_insulation(
|
||||
recommendation: Recommendation | None = recommend_roof_insulation(
|
||||
before, _AnyProduct()
|
||||
)
|
||||
assert recommendation is not None
|
||||
|
|
@ -366,6 +365,33 @@ def test_roof_generator_insulates_a_sloping_ceiling_pinning_its_after() -> None:
|
|||
)
|
||||
|
||||
|
||||
def test_roof_generator_insulates_a_thatched_roof_as_loft_pinning_its_after() -> None:
|
||||
# Arrange — a thatched pitched roof. Thatch is NOT excluded: the covering
|
||||
# doesn't block insulating the loft floor, so it takes loft (joist)
|
||||
# insulation, re-lodged None → 300 mm (ADR-0021).
|
||||
before: EpcPropertyData = parse_recommendation_summary(
|
||||
"loft_thatched_001431_before.pdf"
|
||||
)
|
||||
after: EpcPropertyData = parse_recommendation_summary(
|
||||
"loft_thatched_001431_after.pdf"
|
||||
)
|
||||
|
||||
# Act — the dispatcher routes a thatched roof to the loft branch.
|
||||
recommendation: Recommendation | None = recommend_roof_insulation(
|
||||
before, _AnyProduct()
|
||||
)
|
||||
assert recommendation is not None
|
||||
options: dict[str, MeasureOption] = {
|
||||
option.measure_type: option for option in recommendation.options
|
||||
}
|
||||
|
||||
# Assert — one loft Option whose overlay reproduces the after.
|
||||
assert set(options) == {"loft_insulation"}
|
||||
_assert_overlay_reproduces_after(
|
||||
before, after, options["loft_insulation"].overlay
|
||||
)
|
||||
|
||||
|
||||
def test_solid_floor_overlay_reproduces_the_relodged_after() -> None:
|
||||
# Arrange
|
||||
before: EpcPropertyData = parse_recommendation_summary(
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from datatypes.epc.domain.epc_property_data import (
|
|||
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_loft_insulation
|
||||
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,
|
||||
|
|
@ -35,7 +35,7 @@ def test_uninsulated_loft_yields_a_loft_insulation_recommendation() -> None:
|
|||
_part(baseline, BuildingPartIdentifier.MAIN).roof_insulation_thickness = 0
|
||||
|
||||
# Act
|
||||
recommendation: Recommendation | None = recommend_loft_insulation(
|
||||
recommendation: Recommendation | None = recommend_roof_insulation(
|
||||
baseline, _StubProducts()
|
||||
)
|
||||
|
||||
|
|
@ -55,7 +55,7 @@ def test_already_insulated_loft_yields_no_recommendation() -> None:
|
|||
_part(baseline, BuildingPartIdentifier.MAIN).roof_insulation_thickness = 300
|
||||
|
||||
# Act
|
||||
recommendation: Recommendation | None = recommend_loft_insulation(
|
||||
recommendation: Recommendation | None = recommend_roof_insulation(
|
||||
baseline, _StubProducts()
|
||||
)
|
||||
|
||||
|
|
@ -69,7 +69,7 @@ def test_loft_option_carries_cost_from_roof_area_and_product() -> None:
|
|||
_part(baseline, BuildingPartIdentifier.MAIN).roof_insulation_thickness = 0
|
||||
|
||||
# Act
|
||||
recommendation: Recommendation | None = recommend_loft_insulation(
|
||||
recommendation: Recommendation | None = recommend_roof_insulation(
|
||||
baseline, _StubProducts()
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue