Map RdSAP-Schema-17.0 certs to EpcPropertyData 🟩

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) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-06-12 12:45:19 +00:00
parent 3995433816
commit 26651ca71c
2 changed files with 117 additions and 53 deletions

View file

@ -458,7 +458,15 @@ class EpcPropertyDataMapper:
uprn=schema.uprn, uprn=schema.uprn,
assessment_type=schema.assessment_type, assessment_type=schema.assessment_type,
sap_version=schema.sap_version, 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), property_type=str(schema.property_type),
built_form=str(schema.built_form), built_form=str(schema.built_form),
address_line_1=schema.address_line_1, 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, 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 else None
), ),
), ),
@ -563,22 +573,10 @@ class EpcPropertyDataMapper:
party_wall_construction=_api_party_wall_construction_int( party_wall_construction=_api_party_wall_construction_int(
bp.party_wall_construction bp.party_wall_construction
), ),
sap_floor_dimensions=[ sap_floor_dimensions=_api_build_sap_floor_dimensions(
SapFloorDimension( bp.sap_floor_dimensions or [],
room_height_m=fd.room_height.value, bp.floor_heat_loss,
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
],
building_part_number=bp.building_part_number, building_part_number=bp.building_part_number,
wall_dry_lined=bp.wall_dry_lined == "Y", wall_dry_lined=bp.wall_dry_lined == "Y",
wall_thickness_mm=bp.wall_thickness, wall_thickness_mm=bp.wall_thickness,
@ -2230,6 +2228,14 @@ class EpcPropertyDataMapper:
from_dict(RdSapSchema17_1, data) 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}") raise ValueError(f"Unsupported EPC schema: {schema!r}")

View file

@ -1,4 +1,4 @@
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import List, Optional, Union from typing import List, Optional, Union
from .common import CostAmount, DescriptionV1, Measurement from .common import CostAmount, DescriptionV1, Measurement
@ -29,6 +29,10 @@ class MainHeatingDetail:
main_heating_category: int main_heating_category: int
main_heating_fraction: int main_heating_fraction: int
main_heating_data_source: 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 sap_main_heating_code: Optional[int] = None
@ -40,8 +44,13 @@ class SapHeating:
instantaneous_wwhrs: Optional[InstantaneousWwhrs] instantaneous_wwhrs: Optional[InstantaneousWwhrs]
main_heating_details: List[MainHeatingDetail] main_heating_details: List[MainHeatingDetail]
immersion_heating_type: Union[int, str] immersion_heating_type: Union[int, str]
cylinder_insulation_type: int
has_fixed_air_conditioning: str 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 @dataclass
@ -52,7 +61,8 @@ class PhotovoltaicSupplyNoneOrNoDetails:
@dataclass @dataclass
class PhotovoltaicSupply: 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 @dataclass
@ -69,27 +79,47 @@ class SapFloorDimension:
floor: int floor: int
room_height: Measurement room_height: Measurement
total_floor_area: Measurement total_floor_area: Measurement
party_wall_length: Measurement party_wall_length: Union[Measurement, int]
heat_loss_perimeter: Measurement heat_loss_perimeter: Measurement
floor_insulation: Optional[int] = None
floor_construction: Optional[int] = None
@dataclass @dataclass
class SapBuildingPart: class SapRoomInRoof:
identifier: str """Room-in-roof details. floor_area is usually a Measurement but some certs
wall_dry_lined: str lodge a plain number (ADR-0028) read via `_measurement_value`."""
wall_thickness: int
floor_heat_loss: int floor_area: Union[Measurement, int, float]
roof_construction: int insulation: str
wall_construction: int roof_room_connected: str
building_part_number: int
sap_floor_dimensions: List[SapFloorDimension]
wall_insulation_type: int
construction_age_band: str construction_age_band: str
party_wall_construction: Union[int, str]
wall_thickness_measured: str
roof_insulation_location: Union[int, str] @dataclass(kw_only=True)
roof_insulation_thickness: str 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 wall_insulation_thickness: Optional[str] = None
floor_insulation_thickness: Optional[str] = None
flat_roof_insulation_thickness: Optional[Union[str, int]] = None
@dataclass @dataclass
@ -117,15 +147,17 @@ class SuggestedImprovement:
environmental_impact_rating: int environmental_impact_rating: int
@dataclass @dataclass(kw_only=True)
class AlternativeImprovement: class AlternativeImprovement:
sequence: int # ADR-0028: some certs lodge a reduced alternative-improvement shape (only
typical_saving: CostAmount # improvement_details/-type). Parse-only — every field is Optional.
improvement_type: str sequence: Optional[int] = None
improvement_details: ImprovementDetails typical_saving: Optional[CostAmount] = None
improvement_category: int improvement_type: Optional[str] = None
energy_performance_rating: int improvement_details: Optional[ImprovementDetails] = None
environmental_impact_rating: int improvement_category: Optional[int] = None
energy_performance_rating: Optional[int] = None
environmental_impact_rating: Optional[int] = None
@dataclass @dataclass
@ -137,6 +169,20 @@ class RenewableHeatIncentive:
@dataclass @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: class RdSapSchema17_0:
uprn: int uprn: int
roofs: List[EnergyElement] roofs: List[EnergyElement]
@ -152,7 +198,8 @@ class RdSapSchema17_0:
built_form: int built_form: int
door_count: int door_count: int
glazed_area: 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 region_code: int
report_type: int report_type: int
sap_heating: SapHeating sap_heating: SapHeating
@ -161,7 +208,9 @@ class RdSapSchema17_0:
uprn_source: str uprn_source: str
country_code: str country_code: str
main_heating: List[EnergyElement] 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 language_code: int
property_type: int property_type: int
address_line_1: str address_line_1: str
@ -174,11 +223,13 @@ class RdSapSchema17_0:
transaction_type: int transaction_type: int
conservatory_type: int conservatory_type: int
heated_room_count: 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 registration_date: str
sap_energy_source: SapEnergySource sap_energy_source: SapEnergySource
secondary_heating: EnergyElement 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] sap_building_parts: List[SapBuildingPart]
low_energy_lighting: int low_energy_lighting: int
solar_water_heating: str solar_water_heating: str
@ -190,14 +241,17 @@ class RdSapSchema17_0:
energy_rating_current: int energy_rating_current: int
lighting_cost_current: CostAmount lighting_cost_current: CostAmount
main_heating_controls: List[EnergyElement] 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 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 heating_cost_potential: CostAmount
hot_water_cost_current: CostAmount hot_water_cost_current: CostAmount
mechanical_ventilation: int mechanical_ventilation: int
percent_draughtproofed: int percent_draughtproofed: int
suggested_improvements: List[SuggestedImprovement] suggested_improvements: List[SuggestedImprovement] = field(default_factory=list)
co2_emissions_potential: float co2_emissions_potential: float
energy_rating_potential: int energy_rating_potential: int
lighting_cost_potential: CostAmount lighting_cost_potential: CostAmount
@ -205,7 +259,7 @@ class RdSapSchema17_0:
hot_water_cost_potential: CostAmount hot_water_cost_potential: CostAmount
renewable_heat_incentive: RenewableHeatIncentive renewable_heat_incentive: RenewableHeatIncentive
energy_consumption_current: int energy_consumption_current: int
has_fixed_air_conditioning: str has_fixed_air_conditioning: Optional[str] = None
multiple_glazed_proportion: int multiple_glazed_proportion: int
calculation_software_version: str calculation_software_version: str
energy_consumption_potential: int energy_consumption_potential: int
@ -213,10 +267,14 @@ class RdSapSchema17_0:
fixed_lighting_outlets_count: int fixed_lighting_outlets_count: int
current_energy_efficiency_band: str current_energy_efficiency_band: str
environmental_impact_potential: int environmental_impact_potential: int
has_heated_separate_conservatory: str has_heated_separate_conservatory: Optional[str] = None
potential_energy_efficiency_band: str potential_energy_efficiency_band: str
co2_emissions_current_per_floor_area: int co2_emissions_current_per_floor_area: int
low_energy_fixed_lighting_outlets_count: int low_energy_fixed_lighting_outlets_count: int
sap_flat_details: Optional[SapFlatDetails] = None sap_flat_details: Optional[SapFlatDetails] = None
address_line_2: Optional[str] = None address_line_2: Optional[str] = None
alternative_improvements: Optional[List[AlternativeImprovement]] = 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)