feat(modelling): planning protection picks secondary over double glazing

Slice 3 of the glazing generator (ADR-0022): a conservation/listed/heritage
protection (PlanningRestrictions.blocks_external) hard-picks secondary_glazing
instead of double_glazing -- an internal second pane, since the external units
can't be replaced on a protected building. Each single-glazed window upgrades
to the secondary target pinned from cert 001431 (glazing_type=7, u_value=2.90,
solar_transmittance=0.85 -- the outer single pane still drives solar gain).

The before/after cascade pins for both measures remain deferred behind the
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:18:17 +00:00
parent 8d081cb9d6
commit 276dd1a500
2 changed files with 39 additions and 4 deletions

View file

@ -56,6 +56,17 @@ _DOUBLE: Final[_GlazingTarget] = _GlazingTarget(
u_value=1.40,
solar_transmittance=0.72,
)
# Protected (conservation/listed/heritage): fit an internal secondary pane
# (gt=7 "Secondary glazing"; U→2.90, g unchanged at 0.85 — the existing outer
# single pane still drives solar gain). The external units can't be replaced on
# a protected/over-looked building, so this is the planning-picked Measure.
_SECONDARY: Final[_GlazingTarget] = _GlazingTarget(
measure_type="secondary_glazing",
description="Fit secondary glazing to the single-glazed windows",
glazing_type=7,
u_value=2.90,
solar_transmittance=0.85,
)
def recommend_glazing(
@ -63,9 +74,10 @@ def recommend_glazing(
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."""
"""Return a glazing Recommendation upgrading every single-glazed window — its
single planning-picked Option (double glazing, or secondary glazing where a
planning protection blocks replacing the external units) else None when
the dwelling has no single-glazed windows."""
single_indices = tuple(
index
for index, window in enumerate(epc.sap_windows)
@ -74,7 +86,7 @@ def recommend_glazing(
if not single_indices:
return None
target: _GlazingTarget = _DOUBLE
target: _GlazingTarget = _SECONDARY if restrictions.blocks_external else _DOUBLE
product = products.get(target.measure_type)
overlay = EpcSimulation(

View file

@ -13,6 +13,7 @@ consumes those per-window values directly. Detection + pricing only, no scores
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
@ -101,3 +102,25 @@ def test_recommendation_prices_a_flat_average_per_single_glazed_window() -> 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=7, 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=7, u_value=2.90, solar_transmittance=0.85),
2: WindowOverlay(glazing_type=7, u_value=2.90, solar_transmittance=0.85),
}