diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index beabc5ae..b8fd3312 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -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 diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index 3de24f78..73b10217 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -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) diff --git a/datatypes/epc/schema/rdsap_schema_20_0_0.py b/datatypes/epc/schema/rdsap_schema_20_0_0.py index c317ab61..2c959924 100644 --- a/datatypes/epc/schema/rdsap_schema_20_0_0.py +++ b/datatypes/epc/schema/rdsap_schema_20_0_0.py @@ -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