Merge pull request #1407 from Hestia-Homes/fix/glazing-nd-single-all-schemas

Honour single-glazed ND certs on every reduced-field synthesis seam
This commit is contained in:
Jun-te Kim 2026-07-02 09:21:40 +01:00 committed by GitHub
commit b94c45a392
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 181 additions and 5 deletions

View file

@ -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,
@ -4730,6 +4731,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
@ -4746,7 +4796,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,
@ -4772,7 +4826,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
@ -4800,7 +4858,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
@ -4829,7 +4891,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
@ -4851,7 +4917,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

View file

@ -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