From ff36014e6f8ee4db5e6826f5ed476bffb2895153 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 14 Jul 2026 16:33:34 +0000 Subject: [PATCH] Map PasHub window glazing_type labels to SAP10 cascade codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_map_sap_window` copied the raw `glazing_type` survey label; the calculator's isinstance-int guards read it as non-int and fell to the double-pre-2002 U=2.8 default for every window — over-counting heat loss on the ~852 windows the survey dates to 2002-2021 (RdSAP Table 24 U=2.0). Add `_pashub_glazing_type_int` mapping the surveyed labels to the SAP10 cascade glazing codes the calculator reads for window U (`_GLAZING_CODE_TO_UWINDOW`) and solar g (`_G_PERPENDICULAR_BY_GLAZING_TYPE`): before 2002 -> 3, 2002-2021 -> 2, unknown install date -> 3 (pre-2002 default), post-2022 -> 13. Strict-raises `UnmappedPasHubLabel` on an unknown label; blank passes through as "". Completes the fabric stack (party-wall/wall-construction/insulation/glazing): cohort MAE 2.79 -> 2.66, within-0.5 11.4% -> 12.9%, signed -0.04 -> +0.33. Closes #1562 Co-Authored-By: Claude Opus 4.8 (1M context) --- datatypes/epc/domain/mapper.py | 33 +++++++++++- .../epc/domain/tests/test_from_site_notes.py | 51 ++++++++++++++++--- 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 3ccd88fe4..b88454333 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -5989,7 +5989,7 @@ def _map_sap_window(window: Window) -> SapWindow: glazing_gap=window.glazing_gap, orientation=window.orientation, window_type=window.window_type, - glazing_type=window.glazing_type, + glazing_type=_pashub_glazing_type_int(window.glazing_type), window_width=window.width_m, window_height=window.height_m, draught_proofed=window.draught_proofed, @@ -7191,6 +7191,37 @@ def _pashub_wall_insulation_type_int(label: Optional[str]) -> Union[int, str]: return _PASHUB_WALL_INSULATION_TYPE_TO_SAP10[key] +# PasHub surveyed window `glazing_type` label → SAP10 cascade glazing code that +# the calculator reads for both the window U (`_GLAZING_CODE_TO_UWINDOW` / +# u_window, RdSAP 10 Table 24) and the solar g-value +# (`_G_PERPENDICULAR_BY_GLAZING_TYPE`, Table 6b). The install-year band is +# load-bearing on U (double pre-2002 = 2.8, 2002-2021 = 2.0, post-2022 = 1.4); +# an unknown install date defaults to the conservative pre-2002 code (per the +# `_API_GLAZING_TYPE_TO_TRANSMISSION` "pre-2002 / unknown install date" pairing). +_PASHUB_GLAZING_TYPE_TO_SAP10: Dict[str, int] = { + "Double glazing installed before 2002": 3, # double pre-2002 (U 2.8) + "Double glazing installed between 2002 - 2021": 2, # double 2002-2022 (U 2.0) + "Double glazing, Unknown install date": 3, # unknown → pre-2002 default (U 2.8) + "Double glazing installed during or after 2022": 13, # double 2022+ (U 1.4) +} + + +def _pashub_glazing_type_int(label: Optional[str]) -> Union[int, str]: + """Resolve a PasHub surveyed window `glazing_type` label to its SAP10 cascade + glazing code at the mapper boundary. A blank label is "no lodging" and passes + through as the empty string (the field is `Union[int, str]`; the calculator's + isinstance-int guards then fall to their double-glazed defaults); a non-empty + label the lookup does not cover strict-raises `UnmappedPasHubLabel` so the + coverage gap is fixed here rather than silently pinning every window to the + double-pre-2002 U=2.8 default downstream (#1562).""" + if label is None or not label.strip(): + return "" + key = label.strip() + if key not in _PASHUB_GLAZING_TYPE_TO_SAP10: + raise UnmappedPasHubLabel("glazing type", label) + return _PASHUB_GLAZING_TYPE_TO_SAP10[key] + + def _resolve_elmhurst_underfloor_subtype( main_floor: ElmhurstFloorDetails, main_age_band: str, diff --git a/datatypes/epc/domain/tests/test_from_site_notes.py b/datatypes/epc/domain/tests/test_from_site_notes.py index e579079a2..be334e293 100644 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -151,6 +151,43 @@ class TestWallInsulationTypeCoding: EpcPropertyDataMapper.from_site_notes(survey) +class TestGlazingTypeCoding: + """`from_site_notes` must int-code the PAS Hub window `glazing_type` label to + the SAP10 cascade glazing code the calculator reads (`_GLAZING_CODE_TO_UWINDOW` + for the window U, `_G_PERPENDICULAR_BY_GLAZING_TYPE` for solar g), not copy the + raw label. A raw label reads as `None`, so every window falls to the double- + pre-2002 U=2.8 default — over-counting heat loss on the ~852/1669 windows the + survey dates to 2002-2021 (which are U=2.0) (Guinness cohort, issue #1562). + """ + + @pytest.mark.parametrize( + "label, expected_code", + [ + ("Double glazing installed before 2002", 3), # double pre-2002, U 2.8 + ("Double glazing installed between 2002 - 2021", 2), # double 2002-2022, U 2.0 + ("Double glazing, Unknown install date", 3), # unknown → pre-2002 default, U 2.8 + ("Double glazing installed during or after 2022", 13), # double 2022+, U 1.4 + ("", ""), # no lodging — passes through (calc nulls it) + ], + ) + def test_label_maps_to_sap_code( + self, label: str, expected_code: "int | str" + ) -> None: + data = load("pashub_rdsap_site_notes_example1.json") + for window in data["windows"]: + window["glazing_type"] = label + survey = from_dict(PasHubRdSapSiteNotes, data) + result = EpcPropertyDataMapper.from_site_notes(survey) + assert result.sap_windows[0].glazing_type == expected_code + + def test_unknown_label_strict_raises(self) -> None: + data = load("pashub_rdsap_site_notes_example1.json") + data["windows"][0]["glazing_type"] = "Quadruple glazing, vacuum" + survey = from_dict(PasHubRdSapSiteNotes, data) + with pytest.raises(UnmappedPasHubLabel): + EpcPropertyDataMapper.from_site_notes(survey) + + class TestFromSiteNotesExample1: """ Fixture: pashub_rdsap_site_notes_example1.json @@ -318,9 +355,9 @@ class TestFromSiteNotesExample1: assert result.sap_windows[0].orientation == "South East" def test_window_glazing_type(self, result: EpcPropertyData) -> None: - assert ( - result.sap_windows[0].glazing_type == "Double glazing, Unknown install date" - ) + # windows[].glazing_type: "Double glazing, Unknown install date" + # → SAP10 cascade code 3 (double pre-2002 default, U 2.8) + assert result.sap_windows[0].glazing_type == 3 # --- building parts --- @@ -522,7 +559,7 @@ class TestFromSiteNotesExample1: glazing_gap="16 mm or more", orientation="South East", window_type="Window", - glazing_type="Double glazing, Unknown install date", + glazing_type=3, # "Double glazing, Unknown install date" → cascade code 3 window_width=1.0, window_height=1.36, draught_proofed=True, @@ -535,7 +572,7 @@ class TestFromSiteNotesExample1: glazing_gap="16 mm or more", orientation="South East", window_type="Window", - glazing_type="Double glazing, Unknown install date", + glazing_type=3, # "Double glazing, Unknown install date" → cascade code 3 window_width=0.96, window_height=1.33, draught_proofed=True, @@ -548,7 +585,7 @@ class TestFromSiteNotesExample1: glazing_gap="16 mm or more", orientation="North West", window_type="Window", - glazing_type="Double glazing, Unknown install date", + glazing_type=3, # "Double glazing, Unknown install date" → cascade code 3 window_width=0.96, window_height=1.04, draught_proofed=True, @@ -561,7 +598,7 @@ class TestFromSiteNotesExample1: glazing_gap="16 mm or more", orientation="North West", window_type="Window", - glazing_type="Double glazing, Unknown install date", + glazing_type=3, # "Double glazing, Unknown install date" → cascade code 3 window_width=0.97, window_height=1.02, draught_proofed=True,