From 26651ca71c48895f30cfd26cad2090d97912a5fa Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Fri, 12 Jun 2026 12:45:19 +0000 Subject: [PATCH] =?UTF-8?q?Map=20RdSAP-Schema-17.0=20certs=20to=20EpcPrope?= =?UTF-8?q?rtyData=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dispatch RdSAP-Schema-17.0 through from_api_response, parse-fix the schema (data-driven required->optional, validated against the 1000-cert 17.0 corpus per ADR-0028 — incl. SapHeating.cylinder_insulation_type and the has_hot_water_cylinder / has_fixed_air_conditioning / has_heated_separate_ conservatory flags), and port the defensive mapper reads (dwelling_type str/dict/number, photovoltaic_supply guard, sap_floor_dimensions guard). All 1000 corpus certs now parse and map. Co-Authored-By: Claude Opus 4.8 (1M context) --- datatypes/epc/domain/mapper.py | 42 ++++--- datatypes/epc/schema/rdsap_schema_17_0.py | 128 ++++++++++++++++------ 2 files changed, 117 insertions(+), 53 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 56315404..a9e9fbf7 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -458,7 +458,15 @@ class EpcPropertyDataMapper: uprn=schema.uprn, assessment_type=schema.assessment_type, sap_version=schema.sap_version, - dwelling_type=schema.dwelling_type.value, + # ADR-0028: 17.0 lodges dwelling_type as str (182/1000), localised + # dict, or a plain number — coerce all three to str. + dwelling_type=( + schema.dwelling_type + if isinstance(schema.dwelling_type, str) + else schema.dwelling_type.value + if hasattr(schema.dwelling_type, "value") + else str(schema.dwelling_type) + ), property_type=str(schema.property_type), built_form=str(schema.built_form), address_line_1=schema.address_line_1, @@ -549,7 +557,9 @@ class EpcPropertyDataMapper: percent_roof_area=es.photovoltaic_supply.none_or_no_details.percent_roof_area, ) ) - if es.photovoltaic_supply + # ADR-0028: photovoltaic_supply can be absent, None, or a + # sparse shape without none_or_no_details — guard the read. + if getattr(es.photovoltaic_supply, "none_or_no_details", None) else None ), ), @@ -563,22 +573,10 @@ class EpcPropertyDataMapper: party_wall_construction=_api_party_wall_construction_int( bp.party_wall_construction ), - sap_floor_dimensions=[ - SapFloorDimension( - room_height_m=fd.room_height.value, - total_floor_area_m2=fd.total_floor_area.value, - party_wall_length_m=( - float(fd.party_wall_length) - if isinstance(fd.party_wall_length, int) - else fd.party_wall_length.value - ), - heat_loss_perimeter_m=fd.heat_loss_perimeter.value, - floor=fd.floor, - floor_insulation=None, - floor_construction=None, - ) - for fd in bp.sap_floor_dimensions - ], + sap_floor_dimensions=_api_build_sap_floor_dimensions( + bp.sap_floor_dimensions or [], + bp.floor_heat_loss, + ), building_part_number=bp.building_part_number, wall_dry_lined=bp.wall_dry_lined == "Y", wall_thickness_mm=bp.wall_thickness, @@ -2230,6 +2228,14 @@ class EpcPropertyDataMapper: from_dict(RdSapSchema17_1, data) ) ) + if schema == "RdSAP-Schema-17.0": + from datatypes.epc.schema.rdsap_schema_17_0 import RdSapSchema17_0 + + return _clear_basement_flag_when_system_built( + EpcPropertyDataMapper.from_rdsap_schema_17_0( + from_dict(RdSapSchema17_0, data) + ) + ) raise ValueError(f"Unsupported EPC schema: {schema!r}") diff --git a/datatypes/epc/schema/rdsap_schema_17_0.py b/datatypes/epc/schema/rdsap_schema_17_0.py index 9cbedf97..aa1255ca 100644 --- a/datatypes/epc/schema/rdsap_schema_17_0.py +++ b/datatypes/epc/schema/rdsap_schema_17_0.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 @@ -29,6 +29,10 @@ class MainHeatingDetail: main_heating_category: int main_heating_fraction: int main_heating_data_source: int + boiler_flue_type: Optional[int] = None + fan_flue_present: Optional[str] = None + central_heating_pump_age: Optional[int] = None + main_heating_index_number: Optional[int] = None sap_main_heating_code: Optional[int] = None @@ -40,8 +44,13 @@ 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: cylinder_insulation_type is absent in 308/1000 17.0 certs. + cylinder_insulation_type: Optional[int] = None + cylinder_thermostat: Optional[str] = None + secondary_fuel_type: Optional[int] = None + secondary_heating_type: Optional[int] = None + cylinder_insulation_thickness: Optional[int] = None @dataclass @@ -52,7 +61,8 @@ class PhotovoltaicSupplyNoneOrNoDetails: @dataclass class PhotovoltaicSupply: - none_or_no_details: PhotovoltaicSupplyNoneOrNoDetails + # ADR-0028 data-driven required→optional: 3/1000 omit none_or_no_details. + none_or_no_details: Optional[PhotovoltaicSupplyNoneOrNoDetails] = None @dataclass @@ -69,27 +79,47 @@ class SapFloorDimension: floor: int room_height: Measurement total_floor_area: Measurement - party_wall_length: Measurement + party_wall_length: Union[Measurement, int] heat_loss_perimeter: Measurement + floor_insulation: Optional[int] = None + floor_construction: Optional[int] = None @dataclass -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 +class SapRoomInRoof: + """Room-in-roof details. floor_area is usually a Measurement but some certs + lodge a plain number (ADR-0028) — read via `_measurement_value`.""" + + floor_area: Union[Measurement, int, float] + insulation: str + roof_room_connected: str construction_age_band: str - party_wall_construction: Union[int, str] - wall_thickness_measured: str - roof_insulation_location: Union[int, str] - roof_insulation_thickness: str + + +@dataclass(kw_only=True) +class SapBuildingPart: + # Data-driven required→optional (ADR-0028): a conservatory-shaped part can + # carry only a subset of fields. Every field is Optional (the + # 21.0.1/20.0.0/18.0 precedent). 17.0 corpus: 2/1000 omit identifier, + # wall_thickness, or roof_insulation_thickness. + 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 + roof_insulation_thickness: Optional[Union[str, int]] = None + sap_room_in_roof: Optional[SapRoomInRoof] = None wall_insulation_thickness: Optional[str] = None + floor_insulation_thickness: Optional[str] = None + flat_roof_insulation_thickness: Optional[Union[str, int]] = None @dataclass @@ -117,15 +147,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: some certs lodge a reduced alternative-improvement shape (only + # improvement_details/-type). Parse-only — 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 @@ -137,6 +169,20 @@ class RenewableHeatIncentive: @dataclass +class SapWindow: + """Per-window geometry. ADR-0028: only 10/1000 17.0 certs lodge this array; + window_area arrives as a Measurement and is read via `_measurement_value`. + Mirrors the 20.0.0/18.0 SapWindow shape. This is the per-spec Validation + Cohort — its lodged geometry is used directly, never synthesised over.""" + + orientation: int + window_area: float + window_type: int + glazing_type: int + window_location: int + + +@dataclass(kw_only=True) class RdSapSchema17_0: uprn: int roofs: List[EnergyElement] @@ -152,7 +198,8 @@ class RdSapSchema17_0: built_form: int door_count: int glazed_area: int - glazing_gap: str + # ADR-0028: glazing_gap lodged as int, str, or omitted (482/1000) — widen. + glazing_gap: Optional[Union[int, str]] = None region_code: int report_type: int sap_heating: SapHeating @@ -161,7 +208,9 @@ class RdSapSchema17_0: uprn_source: str country_code: str main_heating: List[EnergyElement] - dwelling_type: DescriptionV1 + # ADR-0028: 182/1000 lodge dwelling_type as a plain str, not a localised + # DescriptionV1 object. Widen so both shapes parse. + dwelling_type: Union[str, DescriptionV1] language_code: int property_type: int address_line_1: str @@ -174,11 +223,13 @@ class RdSapSchema17_0: transaction_type: int conservatory_type: int heated_room_count: int - pvc_window_frames: str + # ADR-0028: missing in 343/1000 — widen + default. + pvc_window_frames: Optional[str] = None registration_date: str sap_energy_source: SapEnergySource secondary_heating: EnergyElement - lzc_energy_sources: List[int] + # ADR-0028: present in only 95/1000 — default to empty. + lzc_energy_sources: List[int] = field(default_factory=list) sap_building_parts: List[SapBuildingPart] low_energy_lighting: int solar_water_heating: str @@ -190,14 +241,17 @@ class RdSapSchema17_0: energy_rating_current: int lighting_cost_current: CostAmount main_heating_controls: List[EnergyElement] - multiple_glazing_type: int + # ADR-0028: int code (1-7) or the string "ND" (Not Defined, 54/1000) — widen + # so both parse; the synthesis maps "ND" to a default. + multiple_glazing_type: Union[int, str] open_fireplaces_count: int - has_hot_water_cylinder: str + # ADR-0028: a handful of 17.0 certs omit these boolean flags — default them. + has_hot_water_cylinder: Optional[str] = None 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 @@ -205,7 +259,7 @@ class RdSapSchema17_0: hot_water_cost_potential: CostAmount renewable_heat_incentive: RenewableHeatIncentive energy_consumption_current: int - has_fixed_air_conditioning: str + has_fixed_air_conditioning: Optional[str] = None multiple_glazed_proportion: int calculation_software_version: str energy_consumption_potential: int @@ -213,10 +267,14 @@ class RdSapSchema17_0: fixed_lighting_outlets_count: int current_energy_efficiency_band: str environmental_impact_potential: int - has_heated_separate_conservatory: str + has_heated_separate_conservatory: Optional[str] = None potential_energy_efficiency_band: str co2_emissions_current_per_floor_area: int low_energy_fixed_lighting_outlets_count: int 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 entirely. + # The 10 rich certs use lodged window_area directly; the windowless majority + # synthesise from the glazed_area band. + sap_windows: List[SapWindow] = field(default_factory=list)