From 07dbaa536195d55ac8de6cceb56f4ceb7f34fc86 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 5 Jun 2026 11:23:57 +0000 Subject: [PATCH] feat(modelling): detect single-glazing code 15 + glazing before/after pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the mapper now in main, cert 001431 parses: it lodges four single-glazed windows — codes 1 ("Single") and 15 ("single glazing, known data", a single pane with manufacturer U/g). The generator only detected code 1, so it missed two panes. Detect {1, 15}; set the secondary target to code 11 ("Secondary glazing - Normal emissivity", what the cert re-lodges; score-neutral vs 7 but exact). A deterministic green pin proves the overlay reproduces the after's 14 windows exactly. The full-SAP before->after pins are xfail(strict) tripwires: the overlay nails the windows, but the measure also re-lodges percent_draughtproofed 84->100 (sealed units draught-proof the replaced openings) plus a ~0.4 SAP fabric residual the overlay doesn't model yet — a glazing-measure coupling to close later. Co-Authored-By: Claude Opus 4.8 --- .../generators/glazing_recommendation.py | 21 ++-- .../modelling/test_elmhurst_cascade_pins.py | 95 +++++++++++++++++++ .../modelling/test_glazing_recommendation.py | 6 +- 3 files changed, 110 insertions(+), 12 deletions(-) diff --git a/domain/modelling/generators/glazing_recommendation.py b/domain/modelling/generators/glazing_recommendation.py index 73515381..ac6c371c 100644 --- a/domain/modelling/generators/glazing_recommendation.py +++ b/domain/modelling/generators/glazing_recommendation.py @@ -27,10 +27,12 @@ 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 +# Single-glazing codes — the only windows this generator upgrades. Code 1 is +# bare "Single"/"Single glazing"; code 15 is "single glazing, known data" (a +# single pane with manufacturer U/g lodged — same g_L=0.90 as code 1, per +# RdSAP-21). Both are single-glazed and must be detected, or a cert that lodges +# manufacturer data on its single panes (e.g. 001431 windows 12-13) is missed. +_SINGLE_GLAZED_CODES: Final[frozenset[int]] = frozenset({1, 15}) @dataclass(frozen=True) @@ -57,13 +59,14 @@ _DOUBLE: Final[_GlazingTarget] = _GlazingTarget( 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. +# (gt=11 "Secondary glazing - Normal emissivity", what cert 001431 re-lodges; +# 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, + glazing_type=11, u_value=2.90, solar_transmittance=0.85, ) @@ -81,7 +84,7 @@ def recommend_glazing( single_indices = tuple( index for index, window in enumerate(epc.sap_windows) - if window.glazing_type == _SINGLE_GLAZED + if window.glazing_type in _SINGLE_GLAZED_CODES ) if not single_indices: return None diff --git a/tests/domain/modelling/test_elmhurst_cascade_pins.py b/tests/domain/modelling/test_elmhurst_cascade_pins.py index b9cdb62e..fdf78eda 100644 --- a/tests/domain/modelling/test_elmhurst_cascade_pins.py +++ b/tests/domain/modelling/test_elmhurst_cascade_pins.py @@ -17,6 +17,8 @@ from __future__ import annotations from dataclasses import replace from typing import Final +import pytest + from datatypes.epc.domain.epc_property_data import ( BuildingPartIdentifier, EpcPropertyData, @@ -34,6 +36,8 @@ from domain.geospatial.planning_restrictions import PlanningRestrictions from domain.modelling.generators.solid_wall_recommendation import ( recommend_solid_wall, ) +from domain.modelling.generators.glazing_recommendation import recommend_glazing +from domain.modelling.scoring.overlay_applicator import apply_simulations from domain.modelling.recommendation import MeasureOption from domain.sap10_calculator.calculator import Sap10Calculator, SapResult from repositories.product.product_repository import ProductRepository @@ -442,3 +446,94 @@ def test_suspended_floor_overlay_reproduces_the_relodged_after() -> None: _assert_overlay_reproduces_after( before, after, recommendation.options[0].overlay ) + + +def test_double_glazing_overlay_reproduces_the_relodged_after_windows() -> None: + # The full-SAP pin below is xfail (draught-proofing coupling), but the + # overlay's actual job — turning every single-glazed window into the + # relodged spec — is deterministic and must hold exactly: it proves the + # generator detects BOTH single-glazing codes (1 and 15) on the real cert. + # Arrange + before: EpcPropertyData = parse_recommendation_summary( + "double_glazing_001431_before.pdf" + ) + after: EpcPropertyData = parse_recommendation_summary( + "double_glazing_001431_after.pdf" + ) + recommendation: Recommendation | None = recommend_glazing(before, _AnyProduct()) + assert recommendation is not None + + # Act — apply the overlay to the parsed before. + applied: EpcPropertyData = apply_simulations( + before, [recommendation.options[0].overlay] + ) + + # Assert — every window's glazing_type + lodged U/g matches the after. + def _window_spec(epc: EpcPropertyData) -> list[tuple[object, object, object]]: + specs: list[tuple[object, object, object]] = [] + for window in epc.sap_windows: + details = window.window_transmission_details + specs.append( + ( + window.glazing_type, + details.u_value if details is not None else None, + details.solar_transmittance if details is not None else None, + ) + ) + return specs + + assert _window_spec(applied) == _window_spec(after) + + +_GLAZING_DRAUGHT_COUPLING_REASON: Final[str] = ( + "Blocked on the glazing measure's draught-proofing coupling. The window " + "U/g overlay reproduces the after's 14 windows EXACTLY (all four single-" + "glazed panes — codes 1 and 15 — become the relodged double/secondary " + "spec). The residual ~0.7 SAP is a secondary effect the overlay does not " + "model: replacing the single-glazed (lodged draught_proofed=No) windows " + "with sealed units re-lodges percent_draughtproofed 84->100 (~0.3 SAP) and " + "lowers fabric heat loss by ~+150 kWh space heating (~0.4 SAP) not yet " + "isolated. Flips green once the glazing overlay propagates draught-proofing " + "(and the residual fabric coupling is modelled)." +) + + +@pytest.mark.xfail(strict=True, reason=_GLAZING_DRAUGHT_COUPLING_REASON) +def test_double_glazing_overlay_reproduces_the_relodged_after() -> None: + # Arrange — cert 001431 lodges four single-glazed windows (codes 1 and 15, + # "single glazing, known data"); the after re-lodges every one as double + # (gt=5, U=1.40, g=0.72). + before: EpcPropertyData = parse_recommendation_summary( + "double_glazing_001431_before.pdf" + ) + after: EpcPropertyData = parse_recommendation_summary( + "double_glazing_001431_after.pdf" + ) + recommendation: Recommendation | None = recommend_glazing(before, _AnyProduct()) + assert recommendation is not None + + # Act / Assert + _assert_overlay_reproduces_after( + before, after, recommendation.options[0].overlay + ) + + +@pytest.mark.xfail(strict=True, reason=_GLAZING_DRAUGHT_COUPLING_REASON) +def test_secondary_glazing_overlay_reproduces_the_relodged_after() -> None: + # Arrange — a planning protection forces secondary glazing; the after + # re-lodges every single-glazed window as secondary (gt=11, U=2.90, g=0.85). + before: EpcPropertyData = parse_recommendation_summary( + "secondary_glazing_001431_before.pdf" + ) + after: EpcPropertyData = parse_recommendation_summary( + "secondary_glazing_001431_after.pdf" + ) + recommendation: Recommendation | None = recommend_glazing( + before, _AnyProduct(), PlanningRestrictions(in_conservation_area=True) + ) + assert recommendation is not None + + # Act / Assert + _assert_overlay_reproduces_after( + before, after, recommendation.options[0].overlay + ) diff --git a/tests/domain/modelling/test_glazing_recommendation.py b/tests/domain/modelling/test_glazing_recommendation.py index 111d9b27..993052f0 100644 --- a/tests/domain/modelling/test_glazing_recommendation.py +++ b/tests/domain/modelling/test_glazing_recommendation.py @@ -116,11 +116,11 @@ def test_planning_protection_picks_secondary_glazing_over_double() -> None: ) # Assert — one secondary-glazing Option; each single window upgraded to the - # pinned secondary target (gt=7, U=2.90, g unchanged at 0.85). + # 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=7, u_value=2.90, solar_transmittance=0.85), - 2: WindowOverlay(glazing_type=7, u_value=2.90, solar_transmittance=0.85), + 0: WindowOverlay(glazing_type=11, u_value=2.90, solar_transmittance=0.85), + 2: WindowOverlay(glazing_type=11, u_value=2.90, solar_transmittance=0.85), }