fix(glazing): map single/secondary/triple glazing per RdSAP 10 Table 24

The API glazing-transmission table mapped only the double-glazing codes
[1,2,3,13,14]; single (5/15), secondary (4/11/12) and triple (6/8/9/10)
glazing codes returned None from _api_glazing_transmission, so the cascade
silently routed them to the u_window all-None default U=2.5 instead of their
RdSAP 10 Table 24 (spec p.50) value. Single glazing (U=4.8) was the worst:
modelled at half its true heat loss → systematic over-rate (cert 0370-2933,
7 single-glazed windows, +17 SAP).

Extended _API_GLAZING_TYPE_TO_TRANSMISSION + the gap-keyed override table
with the Table 24 (U, g, frame-factor) rows for every RdSAP-21 glazing code
(single 4.8/g0.85; secondary normal-E 2.9 / low-E 2.2 /g0.85; triple
pre-2002 2.4/2.1/2.0 by gap, 2002-2022 2.0, all g0.68/0.72; known-data
codes 7/8 alias their family default). 94 corpus certs carry an unmapped
glazing code (code 5 = 79); they sat at 32% within-0.5 vs 54.9% baseline.

Eval: within-0.5 54.90% -> 56.66% (net +16 certs: 22 in, 6 offsetting-error
out), within-1.0 70.2 -> 71.9%, mean|err| 1.224 -> 1.203, 909 computed / 0
raises. Spec-applied uniformly per the determinism principle. 7 AAA tests,
goldens + gate green, pyright net-zero (38=38).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-09 10:23:25 +00:00
parent c8f0753142
commit a04329770d
2 changed files with 135 additions and 3 deletions

View file

@ -2865,9 +2865,10 @@ def _api_mechanical_ventilation_kind(mechanical_ventilation: object) -> Optional
# "Fully double glazed" with a worksheet-resolved U=2.7. Per Table 24
# row 2 (DG pre-2002, gap 16+, PVC/wooden) the spec answer is U=2.7,
# so GOV.UK API code 1 is a schema sibling of code 3 (both alias the
# "DG pre-2002 / unknown install date" row). The wider SAP10.2
# glazing-type enum (4-12, 15+) is not yet mapped — incremental
# coverage as new fixtures surface them.
# "DG pre-2002 / unknown install date" row). The full RdSAP-21
# glazing-type enum (single / secondary / triple, codes 4-12 + 15) is
# now mapped from Table 24 below — single-glazed windows previously fell
# through to the cascade default U=2.5 instead of their spec 4.8.
#
# Spec source: RdSAP 10 Table 24 "Window characteristics" page 49 —
# DG pre-2002 spec U varies by gap (6mm=3.1, 12mm=2.8, 16+=2.7); the
@ -2889,6 +2890,34 @@ _API_GLAZING_TYPE_TO_TRANSMISSION: Dict[int, tuple[float, float, float]] = {
# product family. Cert 0380 lodges code
# 14 on all windows; worksheet uses U=1.4
# = post-curtain 1.3258.)
#
# SINGLE / SECONDARY / TRIPLE glazing — RdSAP 10 Table 24 (spec p.50).
# Previously unmapped (`_api_glazing_transmission` returned None) so the
# cascade silently routed e.g. a single-glazed window to the u_window
# all-None default 2.5 instead of its true 4.8 — over-rating single-
# glazed dwellings (cert 0370-2933, 7 single windows, +17 SAP). Codes
# per datatypes/epc/domain/epc_codes.csv `glazed_type` (RdSAP-Schema-21).
4: (2.9, 0.85, 0.70), # Secondary glazing, unknown data → Table 24
# Secondary "Normal emissivity" default (2.9).
5: (4.8, 0.85, 0.70), # Single glazing — Table 24 "Single / Any
# period" (PVC/wooden 4.8, g 0.85).
6: (2.1, 0.68, 0.70), # Triple glazed, unknown install date — Table
# 24 Triple pre-2002 12mm-gap default (2.1).
7: (2.8, 0.76, 0.70), # Double glazed, known data — no measured U on
# the reduced-data path → double pre-2002 /
# unknown-date family default (2.8), as code 3.
8: (2.1, 0.68, 0.70), # Triple glazed, known data → triple unknown-
# date family default (2.1), as code 6.
9: (2.0, 0.72, 0.70), # Triple glazed, 2002-2022 — Table 24 "Double
# or triple, 2002+ (pre-2022), any gap" (2.0).
10: (2.1, 0.68, 0.70), # Triple glazed, pre-2002 — Table 24 Triple
# pre-2002 12mm-gap default (2.1).
11: (2.9, 0.85, 0.70), # Secondary glazing, normal emissivity —
# Table 24 Secondary "Normal emissivity" (2.9).
12: (2.2, 0.85, 0.70), # Secondary glazing, low emissivity — Table 24
# Secondary "Low emissivity" (2.2).
15: (4.8, 0.85, 0.70), # Single glazing, known data → Single row
# (4.8) when no measured U is lodged, as code 5.
}
@ -2908,6 +2937,22 @@ _API_GLAZING_TYPE_GAP_TO_TRANSMISSION: Dict[
(3, 6): (3.1, 0.76, 0.70),
(3, 12): (2.8, 0.76, 0.70),
(3, "16+"): (2.7, 0.76, 0.70),
# Double glazed, known data (code 7) — aliases the double pre-2002 /
# unknown-date Table 24 row (same as codes 1/3) when no measured U.
(7, 6): (3.1, 0.76, 0.70),
(7, 12): (2.8, 0.76, 0.70),
(7, "16+"): (2.7, 0.76, 0.70),
# Triple glazed pre-2002 / unknown / known-data (codes 6/8/10) —
# Table 24 Triple pre-2002 row varies by gap (6mm=2.4, 12mm=2.1, 16+=2.0).
(6, 6): (2.4, 0.68, 0.70),
(6, 12): (2.1, 0.68, 0.70),
(6, "16+"): (2.0, 0.68, 0.70),
(8, 6): (2.4, 0.68, 0.70),
(8, 12): (2.1, 0.68, 0.70),
(8, "16+"): (2.0, 0.68, 0.70),
(10, 6): (2.4, 0.68, 0.70),
(10, 12): (2.1, 0.68, 0.70),
(10, "16+"): (2.0, 0.68, 0.70),
}

View file

@ -1101,3 +1101,90 @@ class TestApiRoofConstructionCode:
# Assert
assert result == "Pitched, sloping ceiling"
class TestApiGlazingTransmissionTable24:
"""`_api_glazing_transmission` must resolve the SINGLE / SECONDARY /
TRIPLE glazing RdSAP-21 enum codes to their RdSAP 10 Table 24 (spec
page 50) (U, g, frame-factor) not leave them unmapped (None), which
silently routed single-glazed windows to the cascade default U=2.5
instead of their true 4.8, over-rating single-glazed dwellings (cert
0370-2933, 7 single-glazed windows, +17 SAP)."""
def test_single_glazing_code_5_is_table_24_u_4p8(self) -> None:
# Arrange — RdSAP 21 glazing_type 5 = "single glazing"; Table 24
# row "Single / Any period" → U 4.8 (PVC/wooden), g 0.85.
from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage]
# Act
result = _api_glazing_transmission(5, None)
# Assert
assert result == (4.8, 0.85, 0.70)
def test_single_glazing_known_data_code_15_is_table_24_u_4p8(self) -> None:
# Arrange — code 15 = "single glazing, known data"; same Table 24
# Single row when no measured U is lodged on the reduced-data path.
from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage]
# Act
result = _api_glazing_transmission(15, None)
# Assert
assert result == (4.8, 0.85, 0.70)
def test_secondary_glazing_normal_emissivity_code_11_is_u_2p9(self) -> None:
# Arrange — code 11 = "secondary glazing, normal emissivity";
# Table 24 Secondary "Normal emissivity" row → U 2.9, g 0.85.
from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage]
# Act
result = _api_glazing_transmission(11, None)
# Assert
assert result == (2.9, 0.85, 0.70)
def test_secondary_glazing_low_emissivity_code_12_is_u_2p2(self) -> None:
# Arrange — code 12 = "secondary glazing, low emissivity"; Table 24
# Secondary "Low emissivity" row → U 2.2, g 0.85.
from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage]
# Act
result = _api_glazing_transmission(12, None)
# Assert
assert result == (2.2, 0.85, 0.70)
def test_triple_glazing_2002_to_2022_code_9_is_u_2p0(self) -> None:
# Arrange — code 9 = "triple glazing, installed 2002-2022"; Table 24
# "Double or triple, 2002+ (pre-2022), any gap" → U 2.0, g 0.72.
from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage]
# Act
result = _api_glazing_transmission(9, None)
# Assert
assert result == (2.0, 0.72, 0.70)
def test_triple_glazing_pre_2002_code_10_default_gap_is_u_2p1(self) -> None:
# Arrange — code 10 = "triple glazing, pre-2002"; Table 24 Triple
# pre-2002 12mm-gap default → U 2.1, g 0.68.
from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage]
# Act
result = _api_glazing_transmission(10, None)
# Assert
assert result == (2.1, 0.68, 0.70)
def test_double_glazing_2002_plus_code_2_unchanged(self) -> None:
# Arrange — regression guard: the already-mapped double-glazing
# 2002+ entry (U 2.0, g 0.72) is untouched by the single/secondary/
# triple extension.
from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage]
# Act
result = _api_glazing_transmission(2, None)
# Assert
assert result == (2.0, 0.72, 0.70)