From 276dd1a5003a0a0f731bdb89170d4c016203057d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 5 Jun 2026 09:18:17 +0000 Subject: [PATCH] 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 --- .../generators/glazing_recommendation.py | 20 ++++++++++++---- .../modelling/test_glazing_recommendation.py | 23 +++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/domain/modelling/generators/glazing_recommendation.py b/domain/modelling/generators/glazing_recommendation.py index d4b81ab7..73515381 100644 --- a/domain/modelling/generators/glazing_recommendation.py +++ b/domain/modelling/generators/glazing_recommendation.py @@ -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( diff --git a/tests/domain/modelling/test_glazing_recommendation.py b/tests/domain/modelling/test_glazing_recommendation.py index 58ddb61a..111d9b27 100644 --- a/tests/domain/modelling/test_glazing_recommendation.py +++ b/tests/domain/modelling/test_glazing_recommendation.py @@ -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), + }