From 25e550ce3c97a51c35d443f38e909c2f17489b2e Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Thu, 11 Jun 2026 12:21:17 +0000 Subject: [PATCH] =?UTF-8?q?Dispatch=20and=20map=20RdSAP-Schema-17.1=20cert?= =?UTF-8?q?s=20end-to-end=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- datatypes/epc/domain/mapper.py | 17 ++++- datatypes/epc/schema/rdsap_schema_17_1.py | 91 +++++++++++++++-------- 2 files changed, 73 insertions(+), 35 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 514e78e2..8f8dec1a 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -607,7 +607,12 @@ class EpcPropertyDataMapper: 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, @@ -701,7 +706,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 +2135,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}") diff --git a/datatypes/epc/schema/rdsap_schema_17_1.py b/datatypes/epc/schema/rdsap_schema_17_1.py index b0af07e6..f7f186e6 100644 --- a/datatypes/epc/schema/rdsap_schema_17_1.py +++ b/datatypes/epc/schema/rdsap_schema_17_1.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import List, Optional, Union from .common import CostAmount, DescriptionV1, Measurement @@ -36,7 +36,7 @@ class MainHeatingDetail: sap_main_heating_code: Optional[int] = None -@dataclass +@dataclass(kw_only=True) class SapHeating: cylinder_size: int water_heating_code: int @@ -44,8 +44,9 @@ class SapHeating: instantaneous_wwhrs: Optional[InstantaneousWwhrs] main_heating_details: List[MainHeatingDetail] immersion_heating_type: Union[int, str] - cylinder_insulation_type: int has_fixed_air_conditioning: str + # ADR-0028: 325/1000 omit cylinder_insulation_type — default it. + cylinder_insulation_type: Optional[int] = None cylinder_thermostat: Optional[str] = None secondary_fuel_type: Optional[int] = None secondary_heating_type: Optional[int] = None @@ -60,7 +61,7 @@ class PhotovoltaicSupplyNoneOrNoDetails: @dataclass class PhotovoltaicSupply: - none_or_no_details: PhotovoltaicSupplyNoneOrNoDetails + none_or_no_details: Optional[PhotovoltaicSupplyNoneOrNoDetails] = None @dataclass @@ -84,23 +85,26 @@ class SapFloorDimension: floor_construction: Optional[int] = None -@dataclass +@dataclass(kw_only=True) class SapBuildingPart: - identifier: str - wall_dry_lined: str - wall_thickness: int - floor_heat_loss: int - roof_construction: int - wall_construction: int - building_part_number: int - sap_floor_dimensions: List[SapFloorDimension] - wall_insulation_type: int - construction_age_band: str - party_wall_construction: Union[int, str] - wall_thickness_measured: str - roof_insulation_location: Union[int, str] + # ADR-0028: 17/1000 lodge a conservatory-shaped part with none of the + # construction fields. Every field is Optional (the 18.0/20.0.0 precedent); + # the all-None part flows through harmlessly. + identifier: Optional[str] = None + wall_dry_lined: Optional[str] = None + wall_thickness: Optional[int] = None + floor_heat_loss: Optional[int] = None + roof_construction: Optional[int] = None + wall_construction: Optional[int] = None + building_part_number: Optional[int] = None + sap_floor_dimensions: Optional[List[SapFloorDimension]] = None + wall_insulation_type: Optional[int] = None + construction_age_band: Optional[str] = None + party_wall_construction: Optional[Union[int, str]] = None + wall_thickness_measured: Optional[str] = None + roof_insulation_location: Optional[Union[int, str]] = None # Can be a thickness string (e.g. "100mm") or 0 for uninsulated flat roofs - roof_insulation_thickness: Union[str, int] + roof_insulation_thickness: Optional[Union[str, int]] = None wall_insulation_thickness: Optional[str] = None @@ -129,15 +133,17 @@ class SuggestedImprovement: environmental_impact_rating: int -@dataclass +@dataclass(kw_only=True) class AlternativeImprovement: - sequence: int - typical_saving: CostAmount - improvement_type: str - improvement_details: ImprovementDetails - improvement_category: int - energy_performance_rating: int - environmental_impact_rating: int + # ADR-0028: parse-only (the mapper does not read alternative_improvements); + # a reduced shape lodges only some fields, so every field is Optional. + sequence: Optional[int] = None + typical_saving: Optional[CostAmount] = None + improvement_type: Optional[str] = None + improvement_details: Optional[ImprovementDetails] = None + improvement_category: Optional[int] = None + energy_performance_rating: Optional[int] = None + environmental_impact_rating: Optional[int] = None @dataclass @@ -149,6 +155,19 @@ class RenewableHeatIncentive: @dataclass +class SapWindow: + """Per-window geometry. ADR-0028: only 14/1000 17.1 certs lodge this array + (all band-4); window_area arrives as a Measurement and is read via + `_measurement_value`. The extra pvc_frame/glazing_gap keys are ignored.""" + + orientation: int + window_area: float + window_type: int + glazing_type: int + window_location: int + + +@dataclass(kw_only=True) class RdSapSchema17_1: uprn: int roofs: List[EnergyElement] @@ -164,7 +183,8 @@ class RdSapSchema17_1: built_form: int door_count: int glazed_area: int - glazing_gap: str + # ADR-0028: lodged as int, str, or omitted (478/1000) — widen + default. + glazing_gap: Optional[Union[int, str]] = None region_code: int report_type: int sap_heating: SapHeating @@ -173,7 +193,8 @@ class RdSapSchema17_1: uprn_source: str country_code: str main_heating: List[EnergyElement] - dwelling_type: DescriptionV1 + # ADR-0028: 259/1000 lodge dwelling_type as a plain str, not DescriptionV1. + dwelling_type: Union[str, DescriptionV1] language_code: int property_type: int address_line_1: str @@ -186,11 +207,11 @@ class RdSapSchema17_1: transaction_type: int conservatory_type: int heated_room_count: int - pvc_window_frames: str + pvc_window_frames: Optional[str] = None registration_date: str sap_energy_source: SapEnergySource secondary_heating: EnergyElement - lzc_energy_sources: List[int] + lzc_energy_sources: List[int] = field(default_factory=list) sap_building_parts: List[SapBuildingPart] low_energy_lighting: int solar_water_heating: str @@ -202,14 +223,15 @@ class RdSapSchema17_1: energy_rating_current: int lighting_cost_current: CostAmount main_heating_controls: List[EnergyElement] - multiple_glazing_type: int + # ADR-0028: int code (1-7) or "ND" (Not Defined, 56/1000) — widen. + multiple_glazing_type: Union[int, str] open_fireplaces_count: int has_hot_water_cylinder: str heating_cost_potential: CostAmount hot_water_cost_current: CostAmount mechanical_ventilation: int percent_draughtproofed: int - suggested_improvements: List[SuggestedImprovement] + suggested_improvements: List[SuggestedImprovement] = field(default_factory=list) co2_emissions_potential: float energy_rating_potential: int lighting_cost_potential: CostAmount @@ -232,3 +254,6 @@ class RdSapSchema17_1: sap_flat_details: Optional[SapFlatDetails] = None address_line_2: Optional[str] = None alternative_improvements: Optional[List[AlternativeImprovement]] = None + # ADR-0028: additive — the placeholder schema omitted sap_windows, dropping + # the 14 rich certs' lodged geometry. Default [] = windowless (synthesised). + sap_windows: List[SapWindow] = field(default_factory=list)