feat(modelling): detect single-glazing code 15 + glazing before/after pins

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-05 11:23:57 +00:00
parent 2c36a8e1d6
commit 07dbaa5361
3 changed files with 110 additions and 12 deletions

View file

@ -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

View file

@ -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
)

View file

@ -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),
}