mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
8074f4152c
commit
3352f11be3
3 changed files with 172 additions and 18 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue