Model/tests/domain/modelling/test_glazing_recommendation.py
Khalim Conn-Kowlessar 07dbaa5361 feat(modelling): detect single-glazing code 15 + glazing before/after pins
With the mapper now in main, cert 001431 parses: it lodges four single-glazed
windows — codes 1 ("Single") and 15 ("single glazing, known data", a single
pane with manufacturer U/g). The generator only detected code 1, so it missed
two panes. Detect {1, 15}; set the secondary target to code 11 ("Secondary
glazing - Normal emissivity", what the cert re-lodges; score-neutral vs 7 but
exact).

A deterministic green pin proves the overlay reproduces the after's 14 windows
exactly. The full-SAP before->after pins are xfail(strict) tripwires: the
overlay nails the windows, but the measure also re-lodges percent_draughtproofed
84->100 (sealed units draught-proof the replaced openings) plus a ~0.4 SAP
fabric residual the overlay doesn't model yet — a glazing-measure coupling to
close later.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 11:23:57 +00:00

126 lines
5 KiB
Python

"""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.geospatial.planning_restrictions import PlanningRestrictions
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
def test_planning_protection_picks_secondary_glazing_over_double() -> None:
# Arrange — a conservation area blocks external work, so the external units
# can't be replaced; an internal secondary pane is the picked Measure.
baseline: EpcPropertyData = _with_single_glazed(build_epc(), 0, 2)
restrictions = PlanningRestrictions(in_conservation_area=True)
# Act
recommendation: Recommendation | None = recommend_glazing(
baseline, _StubProducts(), restrictions
)
# Assert — one secondary-glazing Option; each single window upgraded to the
# pinned secondary target (gt=11, U=2.90, g unchanged at 0.85).
assert recommendation is not None
option = recommendation.options[0]
assert option.measure_type == "secondary_glazing"
assert dict(option.overlay.windows) == {
0: WindowOverlay(glazing_type=11, u_value=2.90, solar_transmittance=0.85),
2: WindowOverlay(glazing_type=11, u_value=2.90, solar_transmittance=0.85),
}