feat(modelling): recommend_glazing upgrades single-glazed windows to double

The glazing Recommendation Generator (ADR-0022): detect single-glazed
windows (SAP10.2 Table U2 code 1) and emit one "Windows" Recommendation whose
single Option rewrites every single-glazed window to the double-glazing target
pinned from cert 001431's before->after (glazing_type=5, u_value=1.40,
solar_transmittance=0.72). The overlay writes the per-window U/g into
WindowTransmissionDetails because the calculator consumes those directly.
Priced as a flat per-window average x count. No single-glazed windows -> None.

Planning gate (-> secondary) and the before/after cascade pins land next; the
pins are blocked on glazing-label mapper coverage (owned by another agent).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-05 09:17:09 +00:00
parent 3d738bd4c6
commit 8d081cb9d6
2 changed files with 204 additions and 0 deletions

View file

@ -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
beforeafter 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,))

View file

@ -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