From d7cecf45f59a15ef8af6ad3b31ca3aa5f575dacb Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 28 May 2026 17:01:27 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.41:=20GOV.UK=20RdSAP=2021=20glazi?= =?UTF-8?q?ng-type=20code=201=20=E2=86=92=20DG=20pre-2002=20cascade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the cohort-2 API-path +0.42..+0.44 cluster (certs 0300/9380 closed to <1e-4; cert 1536 partially closed +0.4445 → +0.0015 — a sub-2e-3 secondary tail remains for Slice S0380.42). Root cause: per `datatypes/epc/domain/epc_codes.csv` the GOV.UK API schema RdSAP-Schema-21.0.0 defines `glazed_type=1` as "double glazing installed before 2002 in EAW, 2003 in SCT, 2006 NI". Three cohort-2 certs (0300/1536/9380) lodge this code with `glazing_gap=16+` and description "Fully double glazed" — but the API mapper passed the raw code straight through to SapWindow.glazing_type, and: 1. `_api_glazing_transmission` had no (1, "16+") entry, so the U-value lookup returned None and the cascade defaulted to U=2.5 instead of the spec-correct U=2.7 (RdSAP 10 Table 24 row 2, PVC/wooden frame, 16+ gap = 2.7). 2. The cascade's `_G_LIGHT_BY_GLAZING_CODE` table is keyed on the SAP 10.2 Table 6b enum (the Elmhurst extractor produces this enum via `_ELMHURST_GLAZING_LABEL_TO_SAP10`), where code 1 means "single glazed" (g_L=0.90). Passing RdSAP 21 code 1 straight through gave the cascade the wrong g_L for the daylight factor calculation, off by 0.90 vs spec 0.80. Both gaps closed in one slice because they're the same misinterpretation: - `_API_GLAZING_TYPE_TO_TRANSMISSION` + `_API_GLAZING_TYPE_GAP_TO_ TRANSMISSION` now alias code 1 as a schema sibling of code 3 — both resolve to RdSAP 10 Table 24 row 2 ("DG pre-2002 / unknown install date"). Per-gap entries cover the full 6mm=3.1 / 12mm=2.8 / 16+=2.7 row; type-only fallback uses the 12mm default U=2.8. - New `_API_TO_SAP10_CASCADE_GLAZING_CODE = {1: 2}` remap is applied in `_api_sap_window` AFTER the U-value lookup, so SapWindow.glazing_ type carries the SAP 10.2 cascade enum (code 2 = DG pre-2002 air- filled, g_L=0.80) while the U lookup stays keyed on the raw GOV.UK API code. The cohort-1 codes 2/3/13/14 already coincide with the cascade table's intended SAP 10.2 g_L values, so no remap entry required for them; only divergent codes get a remap. Test impact: - Cohort-2 API path: 34/38 → 36/38 at 1e-4 (0300 +4.8e-5; 9380 -5e-6 both move from _COHORT_2_API_OPEN to _COHORT_2_API_CLOSED). - Cert 1536 pin updated from 66.337334 to 65.894324; ws Δ now +0.0015 (was +0.4445) — same root-cause fix dominated, residual tail is distinct-cause work for the next slice. - Cert 2102 unchanged (-6.30 residual, secondary-heating routing gap). - Cohort-1 (9 ASHP certs) unaffected: 9/9 still < 1e-4 on both paths. Test suite: 750 pass + 0 fail. Pyright net-zero per touched file. Spec citations: - RdSAP-Schema-21.0.0 glazed_type=1 → datatypes/epc/domain/epc_codes.csv - RdSAP 10 Specification §8.2 Table 24 (p.49) row 2 "Double glazed: Installed England/Wales before 2002 / Scotland before 2003 / N. Ireland before 2006" — U=2.7 (PVC/wooden, 16+ gap). - SAP 10.2 Table 6b: DG air-filled g_L=0.80 (vs single 0.90). Co-Authored-By: Claude Opus 4.7 --- .../tests/test_summary_pdf_mapper_chain.py | 12 +++- datatypes/epc/domain/mapper.py | 60 ++++++++++++++++--- 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py index ab57b2ee..1b4ba5f6 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -1902,6 +1902,7 @@ _COHORT_2_API_CLOSED: list[tuple[str, float]] = [ ("0036-6325-1100-0063-1226", 62.7471), ("0100-5141-0522-4696-3463", 85.8332), ("0200-3155-0122-2602-3563", 80.8674), + ("0300-2403-2650-2206-0235", 76.6541), # S0380.41 closure ("0310-2763-5450-2506-3501", 78.3593), ("0320-2126-2150-2326-6161", 71.7224), ("0320-2756-8640-2296-1101", 89.9458), @@ -1930,6 +1931,7 @@ _COHORT_2_API_CLOSED: list[tuple[str, float]] = [ ("7836-3125-0600-0526-2202", 80.1792), ("9036-0824-3500-0420-8222", 84.2727), ("9370-3060-1205-3546-4204", 87.8687), + ("9380-2957-7490-2595-3141", 74.5902), # S0380.41 closure ("9421-3045-3205-1646-6200", 87.4495), ("9796-3058-6205-0346-9200", 90.1318), ("9836-7525-9500-0575-1202", 75.2223), @@ -1956,9 +1958,13 @@ _COHORT_2_API_CLOSED: list[tuple[str, float]] = [ # API mapper likely lodges the secondary fuel differently. Probe # the API JSON's `secondary_heating` block first. _COHORT_2_API_OPEN: list[tuple[str, float, float]] = [ - ("0300-2403-2650-2206-0235", 76.6541, 77.084454), - ("1536-9325-5100-0433-1226", 65.8928, 66.337334), - ("9380-2957-7490-2595-3141", 74.5902, 75.010196), + # S0380.41 partially closed this cert: residual moved from +0.4445 + # to +0.0015 via the same RdSAP 21 → SAP 10.2 glazing-type alias + # that closed 0300/9380 cleanly. A sub-2e-3 secondary tail remains + # — likely a windows-area Decimal-rounding boundary case in the + # cert's specific dimensions; investigate per the cohort closure + # convention in Slice S0380.42. + ("1536-9325-5100-0433-1226", 65.8928, 65.894324), ("2102-3018-0205-7886-5204", 63.8732, 57.570156), ] diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index b1122f09..bd82719f 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -2303,17 +2303,26 @@ def _api_sheltered_sides(built_form: object) -> Optional[int]: # Ext1 lodges glazing_type=13 → manufacturer DG post-2022 Argon # U=1.4 / g=0.72, vs cascade default U=2.5). # -# Codes observed across the 10 golden fixtures: 2 (DG England/Wales -# 2002 or later, pre-2022), 3 (Main DG pre-2002), 13 (Ext1 post-2022 -# Argon). The wider SAP10.2 glazing-type enum (4-12, 14+) is not yet -# mapped — incremental coverage as new fixtures surface them. +# Codes observed across the 10 cohort-1 golden fixtures: 2 (DG England/ +# Wales 2002 or later, pre-2022), 3 (Main DG pre-2002), 13 (Ext1 +# post-2022 Argon). Cohort-2 (Slice S0380.39) added code 1 — observed +# in 3 certs (0300/1536/9380) all lodging gap=16+ and description +# "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. # -# Spec source: RdSAP 10 Table 24 "Window characteristics" page 79 — +# 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 # (type, gap)-keyed lookup picks the spec-correct entry when the gap # is lodged, falling back to the type-only default for missing gaps. _API_GLAZING_TYPE_TO_TRANSMISSION: Dict[int, tuple[float, float, float]] = { # (u_value, solar_transmittance/g_⊥, frame_factor) + 1: (2.8, 0.76, 0.70), # Double glazed, pre-2002 / unknown install + # date — Table 24 row 2 (PVC/wooden), 12mm + # gap default. Schema sibling of code 3. 2: (2.0, 0.72, 0.70), # Double glazed, England/Wales 2002+ (pre-2022) 3: (2.8, 0.76, 0.70), # Double glazed, pre-2002 (12mm gap default) 13: (1.4, 0.72, 0.70), # Double glazed, Argon-filled post-2022 @@ -2336,7 +2345,11 @@ _API_GLAZING_TYPE_TO_TRANSMISSION: Dict[int, tuple[float, float, float]] = { _API_GLAZING_TYPE_GAP_TO_TRANSMISSION: Dict[ tuple[int, object], tuple[float, float, float] ] = { - # Double glazed, pre-2002 — Table 24 row 2 (PVC/wooden frame): + # Double glazed, pre-2002 / unknown install date — Table 24 row 2 + # (PVC/wooden frame). Codes 1 and 3 alias the same Table 24 row: + (1, 6): (3.1, 0.76, 0.70), + (1, 12): (2.8, 0.76, 0.70), + (1, "16+"): (2.7, 0.76, 0.70), (3, 6): (3.1, 0.76, 0.70), (3, 12): (2.8, 0.76, 0.70), (3, "16+"): (2.7, 0.76, 0.70), @@ -2357,12 +2370,43 @@ def _api_glazing_transmission( return _API_GLAZING_TYPE_TO_TRANSMISSION.get(glazing_type) +# GOV.UK RdSAP 21 `glazing_type` integer → SAP 10.2 Table 6b cascade +# glazing-type integer. The cascade's `_G_LIGHT_BY_GLAZING_CODE` table +# (domain/sap10_calculator/worksheet/internal_gains.py) is keyed on the +# SAP 10.2 enum that the Elmhurst extractor produces via +# `_ELMHURST_GLAZING_LABEL_TO_SAP10` — so the API-side glazing_type must +# be canonicalised to that same enum before storage on SapWindow. +# +# Per datatypes/epc/domain/epc_codes.csv (RdSAP-Schema-21.0.0): +# - RdSAP 21 code 1 = "double glazing installed before 2002 in EAW, +# 2003 in SCT, 2006 NI" — semantically matches SAP 10.2 Table 6b +# "DG air-filled pre-2002" (cascade code 2, g_L=0.80). +# +# The cohort-1 codes 2, 3, 13, 14 already coincide with the cascade +# table's intended SAP 10.2 g_L values, so no remap entry is required +# for them. Only divergent codes (RdSAP 21 ≠ cascade table) need a +# remap — incremental coverage as new fixtures surface them. +_API_TO_SAP10_CASCADE_GLAZING_CODE: Dict[int, int] = { + 1: 2, # RdSAP 21 DG pre-2002 → cascade DG (g_L=0.80, not single 0.90) +} + + +def _api_cascade_glazing_type(api_glazing_type: int) -> int: + """Canonicalise an API-lodged RdSAP 21 glazing-type code to the SAP + 10.2 Table 6b cascade enum that `_G_LIGHT_BY_GLAZING_CODE` reads. + Pass-through for codes already coincident with the cascade table.""" + return _API_TO_SAP10_CASCADE_GLAZING_CODE.get(api_glazing_type, api_glazing_type) + + def _api_sap_window(w: Any) -> SapWindow: """Build a `SapWindow` from one API schema sap_windows entry, routing the glazing-type + glazing-gap pair through the spec lookup so DG pre-2002 windows pick up the gap-specific U (RdSAP 10 Table 24 row 2: 6mm=3.1 / 12mm=2.8 / 16+=2.7) instead - of the type-only default.""" + of the type-only default. SapWindow.glazing_type is canonicalised + to the SAP 10.2 Table 6b cascade enum so the cascade's daylight g_L + lookup picks the spec-correct value (e.g. RdSAP 21 code 1 = DG + pre-2002, cascade g_L=0.80, not single-glazed 0.90).""" transmission = _api_glazing_transmission(w.glazing_type, w.glazing_gap) frame_factor: Optional[float] = w.frame_factor if frame_factor is None and transmission is not None: @@ -2387,7 +2431,7 @@ def _api_sap_window(w: Any) -> SapWindow: orientation=w.orientation, window_type=w.window_type, frame_factor=frame_factor, - glazing_type=w.glazing_type, + glazing_type=_api_cascade_glazing_type(w.glazing_type), window_width=_measurement_value(w.window_width), window_height=_measurement_value(w.window_height), draught_proofed=w.draught_proofed == "true",