From a04329770d35e5d87aaef7379cf09bf0dcd1a06f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 9 Jun 2026 10:23:25 +0000 Subject: [PATCH] fix(glazing): map single/secondary/triple glazing per RdSAP 10 Table 24 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- datatypes/epc/domain/mapper.py | 51 ++++++++++- .../domain/tests/test_from_rdsap_schema.py | 87 +++++++++++++++++++ 2 files changed, 135 insertions(+), 3 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 0c7a0e76..1f55d4e3 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -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), } diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index 04d9ff64..8cadcc6b 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -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)