Map RdSAP-Schema-19.0 certs to EpcPropertyData 🟩

Dispatch RdSAP-Schema-19.0 through from_api_response, parse-fix the schema
(data-driven required->optional, validated against the 1000-cert 19.0 corpus
per ADR-0028), and port 18.0's defensive mapper reads (dwelling_type str/dict/
number, photovoltaic_supply guard, sap_room_in_roof Measurement coercion).
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:29:36 +00:00
parent 5178197dc2
commit 792f76f2fa
2 changed files with 100 additions and 37 deletions

View file

@ -1023,7 +1023,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: 19.0 lodges dwelling_type as str (503/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,
@ -1118,7 +1126,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
), ),
), ),
@ -1172,8 +1182,11 @@ class EpcPropertyDataMapper:
), ),
sap_room_in_roof=( sap_room_in_roof=(
SapRoomInRoof( SapRoomInRoof(
# floor_area is a Measurement in 19.0 # ADR-0028: floor_area is usually a Measurement but
floor_area=bp.sap_room_in_roof.floor_area.value, # some certs lodge a plain number — coerce both.
floor_area=_measurement_value(
bp.sap_room_in_roof.floor_area
),
construction_age_band=bp.sap_room_in_roof.construction_age_band, construction_age_band=bp.sap_room_in_roof.construction_age_band,
) )
if bp.sap_room_in_roof if bp.sap_room_in_roof
@ -2173,6 +2186,14 @@ class EpcPropertyDataMapper:
from_dict(RdSapSchema20_0_0, data) from_dict(RdSapSchema20_0_0, data)
) )
) )
if schema == "RdSAP-Schema-19.0":
from datatypes.epc.schema.rdsap_schema_19_0 import RdSapSchema19_0
return _clear_basement_flag_when_system_built(
EpcPropertyDataMapper.from_rdsap_schema_19_0(
from_dict(RdSapSchema19_0, data)
)
)
if schema == "RdSAP-Schema-18.0": if schema == "RdSAP-Schema-18.0":
from datatypes.epc.schema.rdsap_schema_18_0 import RdSapSchema18_0 from datatypes.epc.schema.rdsap_schema_18_0 import RdSapSchema18_0

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
@ -60,7 +60,9 @@ class PhotovoltaicSupplyNoneOrNoDetails:
@dataclass @dataclass
class PhotovoltaicSupply: class PhotovoltaicSupply:
none_or_no_details: PhotovoltaicSupplyNoneOrNoDetails # ADR-0028 data-driven required→optional: the photovoltaic_supply block can
# arrive without its none_or_no_details child (matches 18.0).
none_or_no_details: Optional[PhotovoltaicSupplyNoneOrNoDetails] = None
@dataclass @dataclass
@ -85,28 +87,37 @@ class SapFloorDimension:
@dataclass @dataclass
class SapRoomInRoof: class SapRoomInRoof:
floor_area: Measurement """Room-in-roof details. floor_area is usually a Measurement object but some
certs lodge it as a plain number (ADR-0028, as in 18.0) read via
`_measurement_value`, which coerces both shapes."""
floor_area: Union[Measurement, int, float]
insulation: str insulation: str
roof_room_connected: str roof_room_connected: str
construction_age_band: str construction_age_band: str
@dataclass @dataclass(kw_only=True)
class SapBuildingPart: class SapBuildingPart:
identifier: str # Data-driven required→optional (ADR-0028): a conservatory-shaped part can
wall_dry_lined: str # carry only a subset of fields (none of the construction fields). Every
wall_thickness: int # field is Optional (the 21.0.1/20.0.0/18.0 precedent); the sparse part flows
floor_heat_loss: int # through harmlessly. 19.0 corpus: 6/1000 omit roof_insulation_thickness,
roof_construction: int # 2/1000 omit identifier.
wall_construction: int identifier: Optional[str] = None
building_part_number: int wall_dry_lined: Optional[str] = None
sap_floor_dimensions: List[SapFloorDimension] wall_thickness: Optional[int] = None
wall_insulation_type: int floor_heat_loss: Optional[int] = None
construction_age_band: str roof_construction: Optional[int] = None
party_wall_construction: Union[int, str] wall_construction: Optional[int] = None
wall_thickness_measured: str building_part_number: Optional[int] = None
roof_insulation_location: Union[int, str] sap_floor_dimensions: Optional[List[SapFloorDimension]] = None
roof_insulation_thickness: Union[str, int] 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 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 floor_insulation_thickness: Optional[str] = None
@ -145,15 +156,18 @@ 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 — the mapper does not read
improvement_type: str # alternative_improvements — so every field is Optional.
improvement_details: ImprovementDetails sequence: Optional[int] = None
improvement_category: int typical_saving: Optional[CostAmount] = None
energy_performance_rating: int improvement_type: Optional[str] = None
environmental_impact_rating: int improvement_details: Optional[ImprovementDetails] = None
improvement_category: Optional[int] = None
energy_performance_rating: Optional[int] = None
environmental_impact_rating: Optional[int] = None
@dataclass @dataclass
@ -165,6 +179,20 @@ class RenewableHeatIncentive:
@dataclass @dataclass
class SapWindow:
"""Per-window geometry. ADR-0028: only 6/1000 19.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 RdSapSchema19_0: class RdSapSchema19_0:
uprn: int uprn: int
roofs: List[EnergyElement] roofs: List[EnergyElement]
@ -180,6 +208,9 @@ class RdSapSchema19_0:
built_form: int built_form: int
door_count: int door_count: int
glazed_area: int glazed_area: int
# ADR-0028: glazing_gap is lodged as int (162/1000), str (357/1000), or
# omitted (481/1000) — widen + default, not int-required.
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
@ -188,7 +219,9 @@ class RdSapSchema19_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: 503/1000 lodge dwelling_type as a plain str, not a localised
# DescriptionV1 object (matches 20.0.0/18.0). 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
@ -201,11 +234,13 @@ class RdSapSchema19_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 314/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 35/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
@ -217,21 +252,24 @@ class RdSapSchema19_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: lodged as an int code (1-7) or the string "ND" (Not Defined,
# 50/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 has_hot_water_cylinder: str
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
schema_version_original: str schema_version_original: str
hot_water_cost_potential: CostAmount hot_water_cost_potential: CostAmount
renewable_heat_incentive: RenewableHeatIncentive renewable_heat_incentive: RenewableHeatIncentive
windows_transmission_details: WindowsTransmissionDetails # 19.0-specific block, absent in 713/1000 — Optional + default.
windows_transmission_details: Optional[WindowsTransmissionDetails] = None
energy_consumption_current: int energy_consumption_current: int
has_fixed_air_conditioning: str has_fixed_air_conditioning: str
multiple_glazed_proportion: int multiple_glazed_proportion: int
@ -247,5 +285,9 @@ class RdSapSchema19_0:
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
glazing_gap: Optional[Union[str, int]] = None
alternative_improvements: Optional[List[AlternativeImprovement]] = None alternative_improvements: Optional[List[AlternativeImprovement]] = None
# ADR-0028: additive — the placeholder schema omitted sap_windows entirely,
# silently dropping the 6 rich certs' lodged per-window geometry. Capture it
# so the mapper can use lodged window_area directly (default [] = windowless,
# synthesised from the glazed_area band).
sap_windows: List[SapWindow] = field(default_factory=list)