mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
3d738bd4c6
commit
8d081cb9d6
2 changed files with 204 additions and 0 deletions
101
domain/modelling/generators/glazing_recommendation.py
Normal file
101
domain/modelling/generators/glazing_recommendation.py
Normal 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
|
||||
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,))
|
||||
103
tests/domain/modelling/test_glazing_recommendation.py
Normal file
103
tests/domain/modelling/test_glazing_recommendation.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue