Merge pull request #1507 from Hestia-Homes/worktree-rdsap-glazing-cascade-fix

Cascade raw API glazing_type on lodged sap_windows (was passed through raw)
This commit is contained in:
Jun-te Kim 2026-07-08 15:27:18 +01:00 committed by GitHub
commit a76e49e133
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 108 additions and 92 deletions

View file

@ -728,22 +728,7 @@ class EpcPropertyDataMapper:
# rich certs keep their lodged per-window geometry (used directly:
# window_width = area, height = 1.0).
sap_windows=(
[
SapWindow(
frame_material=None,
glazing_gap=0,
orientation=w.orientation,
window_type=w.window_type,
glazing_type=w.glazing_type,
window_width=_measurement_value(w.window_area),
window_height=1.0,
draught_proofed=False,
window_location=w.window_location,
window_wall_type=0,
permanent_shutters_present=False,
)
for w in schema.sap_windows
]
[_reduced_field_api_sap_window(w) for w in schema.sap_windows]
if schema.sap_windows
else _synthesise_17_0_sap_windows(schema)
),
@ -1301,23 +1286,7 @@ class EpcPropertyDataMapper:
cylinder_insulation_thickness_mm=schema.sap_heating.cylinder_insulation_thickness,
),
sap_windows=(
[
SapWindow(
frame_material=None,
glazing_gap=0,
orientation=w.orientation,
window_type=w.window_type,
glazing_type=w.glazing_type,
# ADR-0028: 14 rich certs lodge real per-window area.
window_width=_measurement_value(w.window_area),
window_height=1.0,
draught_proofed=False,
window_location=w.window_location,
window_wall_type=0,
permanent_shutters_present=False,
)
for w in schema.sap_windows
]
[_reduced_field_api_sap_window(w) for w in schema.sap_windows]
if schema.sap_windows
else _synthesise_17_1_sap_windows(schema)
),
@ -1508,26 +1477,7 @@ class EpcPropertyDataMapper:
# glazed_area band + TFA (added in a later slice). The 10 rich
# certs keep their lodged per-window geometry.
sap_windows=(
[
SapWindow(
frame_material=None,
glazing_gap=0,
orientation=w.orientation,
window_type=w.window_type,
glazing_type=w.glazing_type,
# ADR-0028: the 10 rich certs lodge a real per-window
# window_area (Measurement) -- use it directly as geometry
# (width = area, height = 1.0) rather than the placeholder
# windowless 0x0.
window_width=_measurement_value(w.window_area),
window_height=1.0,
draught_proofed=False,
window_location=w.window_location,
window_wall_type=0,
permanent_shutters_present=False,
)
for w in schema.sap_windows
]
[_reduced_field_api_sap_window(w) for w in schema.sap_windows]
if schema.sap_windows
else _synthesise_18_0_sap_windows(schema)
),
@ -1754,22 +1704,7 @@ class EpcPropertyDataMapper:
# rich certs keep their lodged per-window geometry (used directly:
# window_width = area, height = 1.0).
sap_windows=(
[
SapWindow(
frame_material=None,
glazing_gap=0,
orientation=w.orientation,
window_type=w.window_type,
glazing_type=w.glazing_type,
window_width=_measurement_value(w.window_area),
window_height=1.0,
draught_proofed=False,
window_location=w.window_location,
window_wall_type=0,
permanent_shutters_present=False,
)
for w in schema.sap_windows
]
[_reduced_field_api_sap_window(w) for w in schema.sap_windows]
if schema.sap_windows
else _synthesise_19_0_sap_windows(schema)
),
@ -2020,28 +1955,7 @@ class EpcPropertyDataMapper:
# ADR-0027: 993/1000 omit sap_windows → synthesise from glazed_area
# band + TFA. The 7 rich certs keep their lodged per-window geometry.
sap_windows=(
[
SapWindow(
frame_material=None,
glazing_gap=0,
orientation=w.orientation,
window_type=w.window_type,
glazing_type=w.glazing_type,
# ADR-0027: the 7 rich certs lodge a real per-window
# window_area (Measurement) — use it directly as
# geometry (width = area, height = 1.0, matching the
# synthesis convention) rather than the placeholder
# windowless 0x0 that modelled these data-richest certs
# as having no glazing at all.
window_width=_measurement_value(w.window_area),
window_height=1.0,
draught_proofed=False,
window_location=w.window_location,
window_wall_type=0,
permanent_shutters_present=False,
)
for w in schema.sap_windows
]
[_reduced_field_api_sap_window(w) for w in schema.sap_windows]
if schema.sap_windows
else _synthesise_20_0_0_sap_windows(schema)
),
@ -2289,7 +2203,11 @@ class EpcPropertyDataMapper:
orientation=w.orientation,
window_type=w.window_type,
frame_factor=w.frame_factor,
glazing_type=w.glazing_type,
# See `_reduced_field_api_sap_window`: the raw API glazing
# code is the RdSAP-21 glazing enum, not the SAP 10.2
# cascade enum the calculator's U/g-value tables are keyed
# on — must be canonicalised, not passed through raw.
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",
@ -5233,6 +5151,48 @@ def _api_sap_roof_window(w: Any) -> SapRoofWindow:
)
def _reduced_field_api_sap_window(w: Any) -> SapWindow:
"""Build a `SapWindow` from one reduced-field API schema `sap_windows`
entry (RdSAP 17.0/17.1/18.0/19.0/20.0.0's minimal shape: orientation,
window_area, window_type, glazing_type, window_location no glazing_gap,
frame_factor or window_transmission_details lodged).
Mirrors `_api_sap_window`'s glazing-type cascade: the raw API
`glazing_type` must be canonicalised via `_api_cascade_glazing_type`
before storage these codes are the RdSAP-21 glazing enum (e.g. code 1
= "DG pre-2002"), NOT the SAP 10.2 Table 6b cascade enum the calculator's
`_G_LIGHT_BY_GLAZING_CODE`/U-value lookups are keyed on (cascade code 1 =
single glazed). Previously stored raw, this silently modelled any window
lodging API code 1 as single-glazed instead of double cert
0141-2860-6891-9124-5625 (uprn 100021969385, property 753950, RdSAP-16.2)
over-stated windows_w_per_k 82.27 vs Elmhurst's 51.44 (a 31 W/K gap, the
dominant single contributor to a SAP 42 vs Elmhurst 44 mismatch)."""
transmission = _api_glazing_transmission(w.glazing_type, 0)
return SapWindow(
frame_material=None,
glazing_gap=0,
orientation=w.orientation,
window_type=w.window_type,
glazing_type=_api_cascade_glazing_type(w.glazing_type),
window_width=_measurement_value(w.window_area),
window_height=1.0,
draught_proofed=False,
window_location=w.window_location,
window_wall_type=0,
permanent_shutters_present=False,
frame_factor=transmission[2] if transmission is not None else None,
window_transmission_details=(
WindowTransmissionDetails(
u_value=transmission[0],
data_source="SAP10 lookup (glazing_type, glazing_gap)",
solar_transmittance=transmission[1],
)
if transmission is not None
else None
),
)
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

View file

@ -385,6 +385,25 @@ class TestFromRdSapSchema21_0_0:
# photovoltaic_supply is None when the measured shape is present
assert result.sap_energy_source.photovoltaic_supply is None
def test_lodged_window_glazing_type_is_cascaded_not_raw(self) -> None:
# Arrange — the fixture's own glazing_type (14) happens to be a
# coincident code (cascade(14) == 14), which would hide this bug.
# Override to code 1 ("DG pre-2002" in the RdSAP-21 glazing enum),
# which must canonicalise to cascade code 2 — passed through raw it
# would misread as cascade code 1 (single glazed). See the sibling
# `_reduced_field_api_sap_window` fix for the reduced-schema seams;
# this is the from_rdsap_schema_21_0_0 site of the same bug class.
data = load("21_0_0.json")
data["sap_windows"][0]["glazing_type"] = 1
schema = from_dict(RdSapSchema21_0_0, data)
# Act
result = EpcPropertyDataMapper.from_rdsap_schema_21_0_0(schema)
# Assert
assert result.sap_windows[0].glazing_type == 2
def test_photovoltaic_array_orientation_nd_nulls_only_that_field(self) -> None:
# Arrange — a 3-array dwelling where the middle array lodges the RdSAP
# 'ND' ("Not Defined") sentinel for orientation. Regression for the
@ -2969,6 +2988,43 @@ def test_nd_glazing_without_single_signal_keeps_double_modal_default(
assert all(w.glazing_type == 2 for w in result.sap_windows)
@pytest.mark.parametrize("schema_cls, mapper, fixture", _REDUCED_FIELD_SEAMS)
def test_lodged_sap_windows_glazing_type_is_cascaded_not_raw(
schema_cls: Any, mapper: Any, fixture: str
) -> None:
# Arrange — a real lodged `sap_windows[]` entry (the rich-cert path, not
# synthesis) whose raw API `glazing_type` code (1 = "DG pre-2002" in the
# RdSAP-21 glazing enum) differs from the SAP 10.2 cascade enum the
# calculator's U/g-value tables are keyed on (cascade code 1 = single
# glazed). Previously stored raw and unmodified — cert
# 0141-2860-6891-9124-5625 (uprn 100021969385, property 753950) lodges
# exactly this shape and was silently modelled as single glazed on its
# dominant window (91% of glazed area), overstating windows_w_per_k
# 82.27 vs Elmhurst's 51.44 W/K.
data = load(fixture)
data["sap_windows"] = [
{
"orientation": 5,
"window_area": {"value": 18.66, "quantity": "square metres"},
"window_type": 1,
"glazing_type": 1,
"window_location": 0,
}
]
# Act
result = mapper(from_dict(schema_cls, data))
# Assert — glazing_type is the CASCADED value (2), not the raw API code
# (1) that would misread as single glazed; transmission details/U-value
# are populated from the SAP10 lookup rather than left None.
assert result.sap_windows
window = result.sap_windows[0]
assert window.glazing_type == 2
assert window.window_transmission_details is not None
assert window.window_transmission_details.u_value == pytest.approx(2.8)
class TestReducedFieldNdGlazingResolver:
"""Unit-level pins for the shared ND resolver (schema-independent)."""