mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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>
134 lines
5.1 KiB
Python
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
|
|
),
|
|
}
|