From 0f3321e655749812aecefc1e326d7d7399bb1ea0 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Thu, 11 Jun 2026 13:13:37 +0000 Subject: [PATCH] =?UTF-8?q?Synthesise=20windows,=20lighting,=20ventilation?= =?UTF-8?q?=20and=20hot=20water=20for=2017.1=20certs=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rich certs (14/1000) use lodged window_area; the windowless majority synthesise glazing from the glazed_area band via _synthesise_17_1_sap_windows (own seam, inherited 20.0.0 coefficients); lighting/ventilation/hot-water mirror 18.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- datatypes/epc/domain/mapper.py | 114 +++++++++++++++++++++++++++++++-- 1 file changed, 109 insertions(+), 5 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 514e78e2..7660a45b 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -603,11 +603,18 @@ class EpcPropertyDataMapper: @staticmethod def from_rdsap_schema_17_1(schema: RdSapSchema17_1) -> EpcPropertyData: es = schema.sap_energy_source + # ADR-0028: instantaneous_wwhrs holds bath/shower ROOM counts. + iw = schema.sap_heating.instantaneous_wwhrs return EpcPropertyData( uprn=schema.uprn, assessment_type=schema.assessment_type, sap_version=schema.sap_version, - dwelling_type=schema.dwelling_type.value, + # ADR-0028: 17.1 lodges dwelling_type as str OR localised dict. + dwelling_type=( + schema.dwelling_type + if isinstance(schema.dwelling_type, str) + else schema.dwelling_type.value + ), property_type=str(schema.property_type), built_form=str(schema.built_form), address_line_1=schema.address_line_1, @@ -631,12 +638,23 @@ class EpcPropertyDataMapper: heated_rooms_count=schema.heated_room_count, wet_rooms_count=0, extensions_count=schema.extensions_count, - open_chimneys_count=0, + open_chimneys_count=schema.open_fireplaces_count, insulated_door_count=schema.insulated_door_count, draughtproofed_door_count=None, + percent_draughtproofed=schema.percent_draughtproofed, + # ADR-0028: sheltered_sides from built_form, else the calculator + # assumes mid-terrace (2) for every dwelling. + sap_ventilation=SapVentilation( + sheltered_sides=_api_sheltered_sides(schema.built_form), + ), + # ADR-0028: total + low-energy OUTLET counts, not a bulb split. led_fixed_lighting_bulbs_count=0, cfl_fixed_lighting_bulbs_count=0, - incandescent_fixed_lighting_bulbs_count=0, + incandescent_fixed_lighting_bulbs_count=( + schema.fixed_lighting_outlets_count + - schema.low_energy_fixed_lighting_outlets_count + ), + low_energy_fixed_lighting_bulbs_count=schema.low_energy_fixed_lighting_outlets_count, roofs=EpcPropertyDataMapper._map_energy_elements(schema.roofs), walls=EpcPropertyDataMapper._map_energy_elements(schema.walls), floors=EpcPropertyDataMapper._map_energy_elements(schema.floors), @@ -651,6 +669,19 @@ class EpcPropertyDataMapper: ), sap_heating=SapHeating( instantaneous_wwhrs=InstantaneousWwhrs(), + # ADR-0028: derive HW demand counts from bath/shower ROOM counts. + number_baths=( + iw.rooms_with_bath_and_or_shower + + iw.rooms_with_bath_and_mixer_shower + if iw is not None + else None + ), + mixer_shower_count=( + iw.rooms_with_mixer_shower_no_bath + + iw.rooms_with_bath_and_mixer_shower + if iw is not None + else None + ), main_heating_details=[ MainHeatingDetail( has_fghrs=d.has_fghrs == "Y", @@ -685,7 +716,27 @@ class EpcPropertyDataMapper: secondary_heating_type=schema.sap_heating.secondary_heating_type, cylinder_insulation_thickness_mm=schema.sap_heating.cylinder_insulation_thickness, ), - sap_windows=[], + 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 + ] + if schema.sap_windows + else _synthesise_17_1_sap_windows(schema) + ), sap_energy_source=SapEnergySource( mains_gas=es.mains_gas == "Y", meter_type=str(es.meter_type), @@ -701,7 +752,7 @@ class EpcPropertyDataMapper: percent_roof_area=es.photovoltaic_supply.none_or_no_details.percent_roof_area, ) ) - if es.photovoltaic_supply + if getattr(es.photovoltaic_supply, "none_or_no_details", None) else None ), ), @@ -2130,6 +2181,14 @@ class EpcPropertyDataMapper: from_dict(RdSapSchema18_0, data) ) ) + if schema == "RdSAP-Schema-17.1": + from datatypes.epc.schema.rdsap_schema_17_1 import RdSapSchema17_1 + + return _clear_basement_flag_when_system_built( + EpcPropertyDataMapper.from_rdsap_schema_17_1( + from_dict(RdSapSchema17_1, data) + ) + ) raise ValueError(f"Unsupported EPC schema: {schema!r}") @@ -3087,6 +3146,51 @@ def _synthesise_18_0_sap_windows(schema: RdSapSchema18_0) -> List[SapWindow]: ] +# ADR-0028: multiple_glazing_type "ND" (Not Defined, 56/1000 17.1 certs) → the +# DG-modal default, as for 18.0. +_RDSAP17_1_ND_GLAZING_TYPE: int = 2 + + +def _synthesise_17_1_sap_windows(schema: RdSapSchema17_1) -> List[SapWindow]: + """ADR-0028 Reduced-Field Synthesis of `sap_windows` for a 17.1 cert. + + A separate seam from the 18.0/20.0.0 helpers so 17.1 can diverge, but it + reuses the inherited 20.0.0 coefficients unchanged (ADR-0028: 969/1000 band-1 + with no measured band-1 windows; its band-4 rich certs reproduce the model). + 990/1000 certs carry no per-window array — synthesise total glazing as + `ratio x TFA`, split 4-way across N/E/S/W with height=1.0. + """ + 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) + # ADR-0028: 17.1 glazed_type codes 1-8 are identical to 20.0.0's (verified + # against epc_codes.csv); the "ND" string falls back to the DG-modal default. + mgt = schema.multiple_glazing_type + glazing_type = ( + _api_cascade_glazing_type(mgt) + if isinstance(mgt, int) + else _RDSAP17_1_ND_GLAZING_TYPE + ) + return [ + SapWindow( + frame_material=None, + glazing_gap=0, + orientation=orientation, + window_type=0, + glazing_type=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