From 0e9bceb2d4860ea75438fc2db1496b6c0a7c719b Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Wed, 1 Jul 2026 17:10:58 +0000 Subject: [PATCH] Honour single-glazed ND certs on every reduced-field synthesis seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pre-SAP10 RdSAP cert that omits per-window records lodges glazing as a `multiple_glazing_type` code plus a `window.description` and a `multiple_glazed_proportion`. When the numeric code is the "ND" (Not Defined) sentinel, the reduced-field synthesis defaulted to the DG-modal code 2 (double) — ignoring the two register signals that say otherwise. `_normalize_sap_schema_16_x` already honours an explicit "Single glazed" description for 16.x certs (rewriting multiple_glazing_type -> 5), but that rule was never propagated to the 17.0 / 17.1 / 18.0 / 19.0 / 20.0.0 synthesis seams — they all blindly applied ND -> 2. So a genuinely single-glazed dwelling was: 1. scored as double-glazed, overstating its baseline SAP, and 2. hidden from the glazing generator (which upgrades only single-glazed windows), producing no glazing recommendation. Reproduced on cert 2780-3922-3202-6042-9200 (uprn 10033526327), a single-glazed community-heated flat lodging multiple_glazing_type="ND", window "Single glazed", multiple_glazed_proportion 0 — mapped to four double-glazed windows, no glazing measure offered. Extend the 16.x rule to all five reduced-field seams via one shared resolver: on the ND fallback, when the description says "single" or the multiple-glazed proportion is 0, synthesise single glazing (`_api_cascade_glazing_type(5) == 1`, SAP10 cascade single, Table 24 U~4.8) — the exact single code the glazing generator's {1, 15} set matches — else keep each seam's DG-modal default. Refines ADR-0028's ND handling. Tests: all five seams synthesise single from a "Single glazed" + 0% proportion cert; the double-glazed control keeps the DG-modal default; plus unit pins on the resolver. Co-Authored-By: Claude Opus 4.8 (1M context) --- datatypes/epc/domain/mapper.py | 80 ++++++++++++- .../domain/tests/test_from_rdsap_schema.py | 106 ++++++++++++++++++ 2 files changed, 181 insertions(+), 5 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index c4b9e5c9b..e3e0df9f0 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -125,6 +125,7 @@ from datatypes.epc.schema.rdsap_schema_21_0_1 import ( RdSapSchema21_0_1, EnergyElement as EnergyElement_21_0_1, ) +from datatypes.epc.schema.common import DescriptionV1 from domain.sap10_calculator.tables.pcdb import heat_pump_record from datatypes.epc.surveys.elmhurst_site_notes import ( AlternativeWall as ElmhurstAlternativeWall, @@ -4677,6 +4678,55 @@ def _synthesise_reduced_field_windows( ] +# ADR-0028 (single-glazed honouring): a reduced-field cert whose numeric +# `multiple_glazing_type` is the "ND" (Not Defined) sentinel still carries two +# register signals for the *actual* glazing — the human `window.description` and +# `multiple_glazed_proportion`. When those say single-glazed, honour them rather +# than the DG-modal default: defaulting a genuinely single-glazed dwelling to +# double overstates its baseline SAP AND hides it from the glazing generator +# (which upgrades only single-glazed windows — the "no glazing recommendation" +# bug on cert 2780-3922-3202-6042-9200 / uprn 10033526327, a single-glazed flat +# lodging multiple_glazing_type="ND", window "Single glazed", proportion 0). +# `_normalize_sap_schema_16_x` already applies this inline for 16.x certs +# (rewriting multiple_glazing_type→5); this shared resolver extends the same rule +# to every reduced-field synthesis seam (17.0/17.1/18.0/19.0/20.0.0). Gov-API +# code 5 is single glazing → `_api_cascade_glazing_type(5) == 1` (SAP10 cascade +# single, Table 24 U≈4.8) — exactly the single code the glazing generator's +# single-glazed set {1, 15} matches. +_API_SINGLE_GLAZING_TYPE: int = 5 + + +def _reduced_field_is_single_glazed( + window_description: Union[str, DescriptionV1], + multiple_glazed_proportion: int, +) -> bool: + """Whether an "ND"-glazed reduced-field cert is really single-glazed, from the + two signals the gov register still lodges: a 0% multiple-glazed proportion, or + an explicit "single" in the window description.""" + if multiple_glazed_proportion == 0: + return True + text = ( + window_description + if isinstance(window_description, str) + else window_description.value + ) + return "single" in text.lower() + + +def _reduced_field_nd_glazing_type( + window_description: Union[str, DescriptionV1], + multiple_glazed_proportion: int, + dg_modal_default: int, +) -> int: + """Resolve the synthesised `SapWindow.glazing_type` for an "ND" (numeric- + undefined) reduced-field cert: the SAP10 cascade single code (1) when the + cert's own description / proportion say single-glazed, else the seam's + DG-modal default. See the note above.""" + if _reduced_field_is_single_glazed(window_description, multiple_glazed_proportion): + return _api_cascade_glazing_type(_API_SINGLE_GLAZING_TYPE) + return dg_modal_default + + # ADR-0028: multiple_glazing_type "ND" (Not Defined) → the DG-modal # default (cascade code 2 → daylight g_L 0.80), as for 18.0/19.0/17.x. _RDSAP20_ND_GLAZING_TYPE: int = 2 @@ -4693,7 +4743,11 @@ def _synthesise_20_0_0_sap_windows(schema: RdSapSchema20_0_0) -> List[SapWindow] glazing_type = ( _api_cascade_glazing_type(mgt) if isinstance(mgt, int) - else _RDSAP20_ND_GLAZING_TYPE + else _reduced_field_nd_glazing_type( + schema.window.description, + schema.multiple_glazed_proportion, + _RDSAP20_ND_GLAZING_TYPE, + ) ) return _synthesise_reduced_field_windows( schema.glazed_area, @@ -4719,7 +4773,11 @@ def _synthesise_18_0_sap_windows(schema: RdSapSchema18_0) -> List[SapWindow]: glazing_type = ( _api_cascade_glazing_type(mgt) if isinstance(mgt, int) - else _RDSAP18_ND_GLAZING_TYPE + else _reduced_field_nd_glazing_type( + schema.window.description, + schema.multiple_glazed_proportion, + _RDSAP18_ND_GLAZING_TYPE, + ) ) return _synthesise_reduced_field_windows( schema.glazed_area, schema.total_floor_area, glazing_type @@ -4747,7 +4805,11 @@ def _synthesise_19_0_sap_windows(schema: RdSapSchema19_0) -> List[SapWindow]: glazing_type = ( _api_cascade_glazing_type(mgt) if isinstance(mgt, int) - else _RDSAP19_0_ND_GLAZING_TYPE + else _reduced_field_nd_glazing_type( + schema.window.description, + schema.multiple_glazed_proportion, + _RDSAP19_0_ND_GLAZING_TYPE, + ) ) return _synthesise_reduced_field_windows( schema.glazed_area, schema.total_floor_area, glazing_type @@ -4776,7 +4838,11 @@ def _synthesise_17_0_sap_windows(schema: RdSapSchema17_0) -> List[SapWindow]: glazing_type = ( _api_cascade_glazing_type(mgt) if isinstance(mgt, int) - else _RDSAP17_0_ND_GLAZING_TYPE + else _reduced_field_nd_glazing_type( + schema.window.description, + schema.multiple_glazed_proportion, + _RDSAP17_0_ND_GLAZING_TYPE, + ) ) return _synthesise_reduced_field_windows( schema.glazed_area, schema.total_floor_area, glazing_type @@ -4798,7 +4864,11 @@ def _synthesise_17_1_sap_windows(schema: RdSapSchema17_1) -> List[SapWindow]: glazing_type = ( _api_cascade_glazing_type(mgt) if isinstance(mgt, int) - else _RDSAP17_1_ND_GLAZING_TYPE + else _reduced_field_nd_glazing_type( + schema.window.description, + schema.multiple_glazed_proportion, + _RDSAP17_1_ND_GLAZING_TYPE, + ) ) return _synthesise_reduced_field_windows( schema.glazed_area, schema.total_floor_area, glazing_type diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index 40d179e9e..7d68b4a1f 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -2882,3 +2882,109 @@ def test_rdsap_19_0_21_0_0_construct_sap_ventilation_block( result = mapper(schema) assert result.sap_ventilation.sheltered_sides == 1 + + +# --------------------------------------------------------------------------- +# ADR-0028 single-glazed honouring on the reduced-field "ND" fallback. +# +# A reduced-field cert whose numeric `multiple_glazing_type` is the "ND" (Not +# Defined) sentinel used to default to double glazing (cascade code 2) on every +# pre-SAP10 synthesis seam — over-rating a genuinely single-glazed dwelling AND +# hiding it from the glazing generator (which upgrades only single-glazed +# windows). The 16.x normaliser already honoured an explicit "Single glazed" +# window description; these tests pin that same rule now firing on the 17.0 / +# 17.1 / 18.0 / 19.0 / 20.0.0 seams. Motivating cert: 2780-3922-3202-6042-9200 +# (uprn 10033526327), a single-glazed community-heated flat lodging +# multiple_glazing_type="ND", window "Single glazed", multiple_glazed_proportion 0. +# --------------------------------------------------------------------------- + +_REDUCED_FIELD_SEAMS = [ + (RdSapSchema17_0, EpcPropertyDataMapper.from_rdsap_schema_17_0, "17_0.json"), + (RdSapSchema17_1, EpcPropertyDataMapper.from_rdsap_schema_17_1, "17_1.json"), + (RdSapSchema18_0, EpcPropertyDataMapper.from_rdsap_schema_18_0, "18_0.json"), + (RdSapSchema19_0, EpcPropertyDataMapper.from_rdsap_schema_19_0, "19_0.json"), + (RdSapSchema20_0_0, EpcPropertyDataMapper.from_rdsap_schema_20_0_0, "20_0_0.json"), +] + + +@pytest.mark.parametrize("schema_cls, mapper, fixture", _REDUCED_FIELD_SEAMS) +def test_nd_glazing_with_single_glazed_description_synthesises_single( + schema_cls: Any, mapper: Any, fixture: str +) -> None: + # Arrange — force the ND + single-glazed shape onto the fixture, dropping any + # per-window array so the reduced-field synthesis seam runs. + data = load(fixture) + data["multiple_glazing_type"] = "ND" + data["multiple_glazed_proportion"] = 0 + data["window"] = { + "description": "Single glazed", + "energy_efficiency_rating": 1, + "environmental_efficiency_rating": 1, + } + data.pop("sap_windows", None) + + # Act + result = mapper(from_dict(schema_cls, data)) + + # Assert — synthesised as single (SAP10 cascade code 1), the code the glazing + # generator's single-glazed set {1, 15} matches, NOT the old double default 2. + assert result.sap_windows + assert all(w.glazing_type == 1 for w in result.sap_windows) + + +@pytest.mark.parametrize("schema_cls, mapper, fixture", _REDUCED_FIELD_SEAMS) +def test_nd_glazing_without_single_signal_keeps_double_modal_default( + schema_cls: Any, mapper: Any, fixture: str +) -> None: + # Arrange — ND with a double-glazed description and a non-zero multiple-glazed + # proportion: the honouring must NOT fire, so the DG-modal default stands. + data = load(fixture) + data["multiple_glazing_type"] = "ND" + data["multiple_glazed_proportion"] = 100 + data["window"] = { + "description": "Fully double glazed", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3, + } + data.pop("sap_windows", None) + + # Act + result = mapper(from_dict(schema_cls, data)) + + # Assert — unchanged DG-modal default (cascade code 2). + assert result.sap_windows + assert all(w.glazing_type == 2 for w in result.sap_windows) + + +class TestReducedFieldNdGlazingResolver: + """Unit-level pins for the shared ND resolver (schema-independent).""" + + def test_single_in_description_is_single_glazed(self) -> None: + from datatypes.epc.domain.mapper import ( + _reduced_field_is_single_glazed, # pyright: ignore[reportPrivateUsage] + ) + + assert _reduced_field_is_single_glazed("Single glazed", 50) is True + + def test_zero_multiple_glazed_proportion_is_single_glazed(self) -> None: + from datatypes.epc.domain.mapper import ( + _reduced_field_is_single_glazed, # pyright: ignore[reportPrivateUsage] + ) + + # 0% multiple-glazed = 100% single, even if the description is ambiguous. + assert _reduced_field_is_single_glazed("Not recorded", 0) is True + + def test_double_description_with_glazing_is_not_single(self) -> None: + from datatypes.epc.domain.mapper import ( + _reduced_field_is_single_glazed, # pyright: ignore[reportPrivateUsage] + ) + + assert _reduced_field_is_single_glazed("Fully double glazed", 100) is False + + def test_resolver_returns_cascade_single_or_the_seam_default(self) -> None: + from datatypes.epc.domain.mapper import ( + _reduced_field_nd_glazing_type, # pyright: ignore[reportPrivateUsage] + ) + + assert _reduced_field_nd_glazing_type("Single glazed", 0, 2) == 1 + assert _reduced_field_nd_glazing_type("Fully double glazed", 100, 2) == 2