Synthesise 20.0.0 window geometry from glazed-area band and floor area 🟩

ADR-0027 Reduced-Field Synthesis: certs with no per-window array now get total
glazing = 0.148 x TFA x band-multiplier (median + quartile multipliers fit from
the 1000 real 21.0.1 certs), split 4-way across N/E/S/W with width=area/4,
height=1.0; glazing_type routed through the verified 21.0.1 cascade. Also guard
optional PhotovoltaicSupply.none_or_no_details (a parse straggler). Corpus maps
983/1000, up from 974.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-06-10 14:29:13 +00:00
parent 8074f4152c
commit 3352f11be3
3 changed files with 172 additions and 18 deletions

View file

@ -1165,23 +1165,28 @@ class EpcPropertyDataMapper:
secondary_heating_type=schema.sap_heating.secondary_heating_type,
cylinder_insulation_thickness_mm=schema.sap_heating.cylinder_insulation_thickness,
),
# 20.0.0 SapWindow lacks frame/gap/draught fields present in later schemas
sap_windows=[
SapWindow(
frame_material=None,
glazing_gap=0,
orientation=w.orientation,
window_type=w.window_type,
glazing_type=w.glazing_type,
window_width=0.0,
window_height=0.0,
draught_proofed=False,
window_location=w.window_location,
window_wall_type=0,
permanent_shutters_present=False,
)
for w in schema.sap_windows
],
# 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,
window_width=0.0,
window_height=0.0,
draught_proofed=False,
window_location=w.window_location,
window_wall_type=0,
permanent_shutters_present=False,
)
for w in schema.sap_windows
]
if schema.sap_windows
else _synthesise_20_0_0_sap_windows(schema)
),
sap_energy_source=SapEnergySource(
mains_gas=es.mains_gas == "Y",
meter_type=str(es.meter_type),
@ -1198,6 +1203,7 @@ class EpcPropertyDataMapper:
)
)
if es.photovoltaic_supply
and es.photovoltaic_supply.none_or_no_details
else None
),
),
@ -2866,6 +2872,68 @@ def _api_sheltered_sides(built_form: object) -> Optional[int]:
return _API_BUILT_FORM_TO_SHELTERED_SIDES[built_form]
# ADR-0027 (Reduced-Field Synthesis): RdSAP 20.0.0 lodges a glazed_area *band* +
# floor area, never window m². Synthesised total glazing = ratio x TFA; the ratio
# is the MEDIAN glazing-area/floor-area of the 1000 real 21.0.1 certs (mean 0.155;
# ~constant across dwelling sizes, so a flat proportional rule holds). No ground
# truth — assumption pinned in tests.
_RDSAP20_GLAZING_RATIO: float = 0.148
# ADR-0027: the glazed_area band scales the Normal ratio. Multipliers are the
# 21.0.1 glazing/floor-area distribution quartiles relative to its median
# (Normal=P50, More=P75/P50, Less=P25/P50, MuchMore=P90/P50, MuchLess=P10/P50).
# An unmapped band ('ND' / not recorded) falls back to Normal.
_RDSAP20_GLAZED_AREA_BAND_MULTIPLIER: dict[int, float] = {
1: 1.00, # Normal
2: 1.25, # More than typical
3: 0.81, # Less than typical
4: 1.51, # Much more than typical
5: 0.62, # Much less than typical
}
# ADR-0027: orientation is unrecorded in 20.0.0, so the synthesised area is split
# 4-way across N/E/S/W (SAP10 codes 1/3/5/7) and the calculator's solar_gains
# averages them (avg-orientation treatment; replaces the prior skip-unknown ->
# zero-solar-gain bias).
_RDSAP20_SYNTH_ORIENTATIONS: tuple[int, ...] = (1, 3, 5, 7)
def _synthesise_20_0_0_sap_windows(schema: RdSapSchema20_0_0) -> List[SapWindow]:
"""ADR-0027 Reduced-Field Synthesis of `sap_windows` for a 20.0.0 cert.
993/1000 certs carry no per-window array, only a glazed_area band + floor
area; synthesise total glazing as `ratio x TFA`, split 4-way across N/E/S/W
with `window_width = area/4, window_height = 1.0` (width x height is the only
quantity the calculator reads, so height=1 makes width carry the area
exactly matching the existing Elmhurst precedent).
"""
band_multiplier = _RDSAP20_GLAZED_AREA_BAND_MULTIPLIER.get(schema.glazed_area, 1.0)
total_area = (
_RDSAP20_GLAZING_RATIO * float(schema.total_floor_area) * band_multiplier
)
per_window_area = total_area / len(_RDSAP20_SYNTH_ORIENTATIONS)
return [
SapWindow(
frame_material=None,
glazing_gap=0,
orientation=orientation,
window_type=0,
# ADR-0027: 20.0.0 glazed_type codes 1-8+ND are identical to 21.0.1's,
# so reuse the verified 21.0.1 cascade (fixes code 1 "DG pre-2002"
# being mis-read as single). g⊥ comes from window_transmission_details
# (slice 6), so glazing_type only feeds the daylight g_L lookup.
glazing_type=_api_cascade_glazing_type(schema.multiple_glazing_type),
window_width=per_window_area,
window_height=1.0,
draught_proofed=False,
window_location=0,
window_wall_type=0,
permanent_shutters_present=False,
)
for orientation in _RDSAP20_SYNTH_ORIENTATIONS
]
# GOV.UK API `glazing_type` integer → (u_value W/m²K, g_perpendicular,
# frame_factor) lookup the cascade reads via `window_transmission_
# details` for per-window cascade fidelity. The cascade defaults to a

View file

@ -1177,3 +1177,87 @@ class TestRdSap20_0_0ReducedFieldSynthesis:
# Assert
assert isinstance(result, EpcPropertyData)
def test_band_normal_synthesises_total_glazing_at_0_148_of_floor_area(
self,
) -> None:
# Arrange — ADR-0027 assumption: 20.0.0 lodges only a glazed_area *band*
# (1 = Normal), not window m². For Normal, synthesised total glazing =
# 0.148 x total_floor_area (the median glazing/floor ratio measured from
# the 1000 real 21.0.1 certs). A band-1 cert with no per-window array.
corpus = _load_20_0_0_corpus()
if not corpus:
pytest.skip("no RdSAP-Schema-20.0.0 corpus harvested")
cert = next(
(
c
for c in corpus
if not c.get("sap_windows") and c.get("glazed_area") == 1
),
None,
)
if cert is None:
pytest.skip("no band-1 corpus cert without sap_windows")
tfa = float(cert["total_floor_area"])
# Act
result = EpcPropertyDataMapper.from_api_response(cert)
# Assert — 4 windows (N/E/S/W avg-orientation split), each height 1.0,
# total width-sum (= total area, height=1) at 0.148 x TFA.
assert len(result.sap_windows) == 4
assert all(w.window_height == 1.0 for w in result.sap_windows)
assert sorted(w.orientation for w in result.sap_windows) == [1, 3, 5, 7]
total_area = sum(w.window_width * w.window_height for w in result.sap_windows)
assert total_area == pytest.approx(0.148 * tfa)
def test_band_more_than_typical_scales_glazing_by_1_25(self) -> None:
# Arrange — ADR-0027: glazed_area band scales the synthesised area off
# the Normal ratio. Band 2 ("More than typical") = P75/P50 = 1.25, fit
# from the same 21.0.1 ratio distribution as the 0.148 median.
corpus = _load_20_0_0_corpus()
if not corpus:
pytest.skip("no RdSAP-Schema-20.0.0 corpus harvested")
cert = next(
(
c
for c in corpus
if not c.get("sap_windows") and c.get("glazed_area") == 2
),
None,
)
if cert is None:
pytest.skip("no band-2 corpus cert without sap_windows")
tfa = float(cert["total_floor_area"])
# Act
result = EpcPropertyDataMapper.from_api_response(cert)
# Assert
total_area = sum(w.window_width * w.window_height for w in result.sap_windows)
assert total_area == pytest.approx(0.148 * tfa * 1.25)
def test_synthesised_glazing_type_routed_through_cascade(self) -> None:
# Arrange — ADR-0027: multiple_glazing_type uses the same code space as
# 21.0.1, so route it through `_api_cascade_glazing_type` (as the working
# 21.0.1 path does), NOT raw — else the calculator mis-reads code 1
# ("double pre-2002") as single. A cert lodging multiple_glazing_type=1.
corpus = _load_20_0_0_corpus()
if not corpus:
pytest.skip("no RdSAP-Schema-20.0.0 corpus harvested")
cert = next(
(
c
for c in corpus
if not c.get("sap_windows") and c.get("multiple_glazing_type") == 1
),
None,
)
if cert is None:
pytest.skip("no corpus cert with multiple_glazing_type=1")
# Act
result = EpcPropertyDataMapper.from_api_response(cert)
# Assert — cascade remaps 1 ("DG pre-2002") -> 2 (double), not raw 1.
assert all(w.glazing_type == 2 for w in result.sap_windows)

View file

@ -72,7 +72,9 @@ class PhotovoltaicSupplyNoneOrNoDetails:
@dataclass
class PhotovoltaicSupply:
none_or_no_details: PhotovoltaicSupplyNoneOrNoDetails
# ADR-0027: a photovoltaic_supply block can lodge measured-array detail
# instead of the none_or_no_details summary → optional (absent on ~10 certs).
none_or_no_details: Optional[PhotovoltaicSupplyNoneOrNoDetails] = None
@dataclass