Model/tests/domain/modelling/test_glazing_recommendation.py
Khalim Conn-Kowlessar 80d617aa5b modelling: glazing overlay models draught-proofing + frame-factor re-lodge
Fitting sealed glazing units changes two things beyond the pane's U/g
that the cascade reads, which the overlay didn't model — leaving the
double/secondary before→after pins ~0.7 SAP short (xfail):

1. Draught-proofing (RdSAP 10 §8.1). Sealed units draught-proof the panes
   they replace, re-lodging the dwelling-level `percent_draughtproofed`
   (cert 001431: 84 → 100). The §2 cascade reads that dwelling-level
   value, so the overlay now carries it. `_recompute_percent_draughtproofed`
   anchors on the lodged before-% — `after = round((round(before%/100 × N)
   + flips) / N × 100)`, N = openable windows (vertical + roof) + doors,
   flips = upgraded panes that were not draught-proofed — so it's robust
   to incomplete window extraction (unchanged openings are already in the
   aggregate). ~0.3 SAP.

2. Frame factor (§6 solar gains). A replacement unit re-lodges its own
   FF=0.70, overriding the pane it replaced — the two "single glazing,
   known data" panes lodge FF 1.00 / 0.50 (one is 6.6 m²), so leaving them
   unchanged understated solar gains by ~+150 kWh space heating. `WindowOverlay`
   now carries `frame_factor`, written flat onto the window. ~0.4 SAP.

Wiring: `EpcSimulation.percent_draughtproofed` + `WindowOverlay.frame_factor`
new fields; `apply_simulations` / `_fold_window` write them; the glazing
generator computes both from the upgraded set and cert 001431's after.

Un-xfails `test_{double,secondary}_glazing_overlay_reproduces_the_relodged_after`
— both now pin SAP/CO2/PE to the relodged after within tolerance. Updates
the two `test_glazing_recommendation` overlay expectations for the new
`frame_factor`. 96 modelling tests pass; zero new pyright errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 08:25:08 +00:00

134 lines
5.1 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, frame_factor=0.70
),
2: WindowOverlay(
glazing_type=5, u_value=1.40, solar_transmittance=0.72, frame_factor=0.70
),
}
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, frame_factor=0.70
),
2: WindowOverlay(
glazing_type=11, u_value=2.90, solar_transmittance=0.85, frame_factor=0.70
),
}