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",