diff --git a/domain/modelling/generators/glazing_recommendation.py b/domain/modelling/generators/glazing_recommendation.py new file mode 100644 index 00000000..d4b81ab7 --- /dev/null +++ b/domain/modelling/generators/glazing_recommendation.py @@ -0,0 +1,101 @@ +"""The glazing Recommendation Generator (double / secondary glazing). + +Detects a dwelling's single-glazed windows and emits one "Windows" +Recommendation carrying a single, planning-picked Measure Option (ADR-0022). +Unlike the wall generator's competing EWI/IWI Options, the Property's +`PlanningRestrictions` *hard-picks* the Measure: an unrestricted dwelling gets +`double_glazing`; any conservation/listed/heritage protection (i.e. +`blocks_external`) forces `secondary_glazing` — an internal second pane that +leaves the protected external units untouched. + +All single-glazed windows are upgraded together in one overlay. The overlay +writes each window's lodged `u_value` and `solar_transmittance` (not just +`glazing_type`), because our calculator reads those per-window values directly +from `WindowTransmissionDetails` rather than deriving them from the glazing +type (`heat_transmission.py:490`, `solar_gains.py:300`); `glazing_type` is set +too, for the §5 daylight factor. The target values are pinned from cert 001431's +before→after re-lodgement. Detection + pricing only; impact is produced later by +scoring (ADR-0016). +""" + +from dataclasses import dataclass +from typing import Final, Optional + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.geospatial.planning_restrictions import PlanningRestrictions +from domain.modelling.recommendation import Cost, MeasureOption, Recommendation +from domain.modelling.simulation import EpcSimulation, WindowOverlay +from repositories.product.product_repository import ProductRepository + +# SAP10.2 Table U2 code 1 = single glazing — the only windows this generator +# upgrades (the Elmhurst mapper sends "Single"/"Single glazing" → 1 and the API +# passes int 1, so the trigger is path-consistent). +_SINGLE_GLAZED: Final[int] = 1 + + +@dataclass(frozen=True) +class _GlazingTarget: + """The planning-picked Measure and the per-window values its overlay lodges, + pinned from cert 001431's before→after (`glazing_type`, `u_value`, + `solar_transmittance` — SAP10.2 Table U2 code, then heat-loss U and solar g). + """ + + measure_type: str + description: str + glazing_type: int + u_value: float + solar_transmittance: float + + +# Unrestricted: replace the units with double glazing (gt=5 "Double post 2022"; +# U 4.80→1.40, g 0.85→0.72). +_DOUBLE: Final[_GlazingTarget] = _GlazingTarget( + measure_type="double_glazing", + description="Replace the single-glazed windows with double glazing", + glazing_type=5, + u_value=1.40, + solar_transmittance=0.72, +) + + +def recommend_glazing( + epc: EpcPropertyData, + products: ProductRepository, + restrictions: PlanningRestrictions = PlanningRestrictions(), +) -> Optional[Recommendation]: + """Return a glazing Recommendation upgrading every single-glazed window with + double glazing (its single Option), else None when the dwelling has no + single-glazed windows.""" + single_indices = tuple( + index + for index, window in enumerate(epc.sap_windows) + if window.glazing_type == _SINGLE_GLAZED + ) + if not single_indices: + return None + + target: _GlazingTarget = _DOUBLE + product = products.get(target.measure_type) + + overlay = EpcSimulation( + windows={ + index: WindowOverlay( + glazing_type=target.glazing_type, + u_value=target.u_value, + solar_transmittance=target.solar_transmittance, + ) + for index in single_indices + } + ) + cost = Cost( + total=len(single_indices) * product.unit_cost_per_m2, + contingency_rate=product.contingency_rate, + ) + option = MeasureOption( + measure_type=target.measure_type, + description=target.description, + overlay=overlay, + cost=cost, + material_id=product.id, + ) + return Recommendation(surface="Windows", options=(option,)) diff --git a/tests/domain/modelling/test_glazing_recommendation.py b/tests/domain/modelling/test_glazing_recommendation.py new file mode 100644 index 00000000..58ddb61a --- /dev/null +++ b/tests/domain/modelling/test_glazing_recommendation.py @@ -0,0 +1,103 @@ +"""Behaviour of the glazing Recommendation Generator: detecting single-glazed +windows and emitting one planning-picked "Windows" Recommendation (ADR-0022). + +Unrestricted dwellings get `double_glazing`; a planning-protected dwelling gets +`secondary_glazing` instead — the measure is hard-picked by the Property's +`PlanningRestrictions`, not offered as competing Options. All single-glazed +windows are upgraded together in one overlay; the overlay writes each window's +lodged U-value and g-value (not just `glazing_type`) because the calculator +consumes those per-window values directly. Detection + pricing only, no scores +(ADR-0016). The before/after cascade pins live in `test_elmhurst_cascade_pins`. +""" + +import copy + +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from domain.modelling.generators.glazing_recommendation import recommend_glazing +from domain.modelling.product import Product +from domain.modelling.recommendation import Recommendation +from domain.modelling.simulation import WindowOverlay +from repositories.product.product_repository import ProductRepository +from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( + build_epc, +) + +# SAP10.2 Table U2 code 1 = single glazing (the generator's trigger). +_SINGLE_GLAZED = 1 + + +class _StubProducts(ProductRepository): + """In-memory ProductRepository returning a fixed per-window glazing price. + + `unit_cost_per_m2` carries the catalogue row's fully-loaded total, reused as + a flat average price per window (ADR-0022).""" + + def get(self, measure_type: str) -> Product: + return Product( + measure_type=measure_type, + unit_cost_per_m2=600.0, + contingency_rate=0.2, + id=42, + ) + + +def _with_single_glazed(epc: EpcPropertyData, *indices: int) -> EpcPropertyData: + """Return a copy of `epc` with the windows at `indices` set single-glazed.""" + clone: EpcPropertyData = copy.deepcopy(epc) + for index in indices: + clone.sap_windows[index].glazing_type = _SINGLE_GLAZED + return clone + + +def test_single_glazed_dwelling_yields_a_double_glazing_recommendation() -> None: + # Arrange — 000490 is all double-glazed; make windows 0 and 2 single-glazed. + baseline: EpcPropertyData = _with_single_glazed(build_epc(), 0, 2) + + # Act + recommendation: Recommendation | None = recommend_glazing( + baseline, _StubProducts() + ) + + # Assert — one double-glazing Option whose overlay rewrites each single- + # glazed window to the pinned double target (gt=5, U=1.40, g=0.72). + assert recommendation is not None + assert recommendation.surface == "Windows" + assert len(recommendation.options) == 1 + option = recommendation.options[0] + assert option.measure_type == "double_glazing" + assert dict(option.overlay.windows) == { + 0: WindowOverlay(glazing_type=5, u_value=1.40, solar_transmittance=0.72), + 2: WindowOverlay(glazing_type=5, u_value=1.40, solar_transmittance=0.72), + } + + +def test_fully_glazed_dwelling_yields_no_recommendation() -> None: + # Arrange — 000490 is entirely double-glazed (gt=2); nothing to upgrade. + baseline: EpcPropertyData = build_epc() + + # Act + recommendation: Recommendation | None = recommend_glazing( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is None + + +def test_recommendation_prices_a_flat_average_per_single_glazed_window() -> None: + # Arrange — three single-glazed windows at £600 each (a flat per-window + # average reused from `unit_cost_per_m2`, ADR-0022). + baseline: EpcPropertyData = _with_single_glazed(build_epc(), 0, 2, 4) + + # Act + recommendation: Recommendation | None = recommend_glazing( + baseline, _StubProducts() + ) + + # Assert + assert recommendation is not None + cost = recommendation.options[0].cost + assert cost is not None + assert cost.total == 3 * 600.0 + assert cost.contingency_rate == 0.2 + assert recommendation.options[0].material_id == 42