mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Honour single-glazed ND certs on every reduced-field synthesis seam
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) <noreply@anthropic.com>
This commit is contained in:
parent
2892ce8560
commit
0e9bceb2d4
2 changed files with 181 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue