Route full-SAP certs lodged under SAP-Schema-16.x to the full-SAP mapper

A full-SAP cert (assessment_type=SAP, the as-designed LIG-* new-builds) lodged
under a SAP-Schema-16.x version failed to map with:

  ValueError: RdSapSchema17_1: missing required field 'door_count'

— crashing the property's modelling_e2e subtask. 89 distinct properties in the
2026-06-24 run hit this; ~22 of every 30 sampled are this full-SAP shape.

Root cause: from_api_response dispatches on the schema_type STRING only, and the
SAP-Schema-16.x branch assumed a single shape — "reduced-field (RdSAP-shaped)" —
sending every 16.x cert through _normalize_sap_schema_16_x -> RdSapSchema17_1.
But the SAP-Schema-16.x name covers two structurally different certs:

  * reduced RdSAP (assessment_type=RdSAP): top-level door_count / glazed_area
    band / construction-code building parts.
  * full SAP    (assessment_type=SAP): measured shape — sap_opening_types +
    structured sap_building_parts carrying measured U-values and door/window
    OPENINGS, with NO top-level door_count (the door is an opening). These omit
    the reduced-only count fields, so the reduced normaliser failed loud on the
    first one it checks (door_count) — and 14 others behind it.

Force-fitting a full-SAP cert to the reduced RdSAP schema was the bug: the data
was never missing, it was being validated against the wrong schema.

Fix: discriminate on shape (_is_full_sap_cert: assessment_type=SAP AND
sap_opening_types present) and route full-SAP 16.x certs to the existing
full-SAP 17.1 mapper, which reads the real measured openings (the lodged
0.9x2.1m door -> door_count 1) — no reduced-field defaulting. The only field the
16.x full-SAP shape omits that SapSchema17_1 needs is `tenure` (register
metadata, no SAP effect), defaulted. Reduced 16.x certs (assessment_type=RdSAP,
no opening types) are untouched — all pinned reduced 16.x fixtures stay on the
RdSAP path.

Regression test pins a real full-SAP-16.0 cert (0293-...-8795, lodged 83) mapping
via the full-SAP path + the reduced 16.2 cert staying on the RdSAP path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-06-24 15:16:51 +00:00
parent f6e5e11d88
commit 5e0963593a
3 changed files with 517 additions and 2 deletions

View file

@ -958,14 +958,45 @@ class EpcPropertyDataMapper:
@staticmethod
def _from_sap_schema_16_x(data: Dict[str, Any]) -> EpcPropertyData:
"""Shared body for the SAP-Schema-16.x dedicated mappers: normalise the
reduced-field doc onto the RdSAP-17.1 shape and reuse that mapper."""
"""Shared body for the SAP-Schema-16.x dedicated mappers.
The `SAP-Schema-16.x` *name* covers two structurally different certs:
* **RdSAP** (`assessment_type == "RdSAP"`) the reduced-field shape
(top-level `door_count` / `glazed_area` band, construction-code building
parts). Normalised onto RdSAP-17.1 and mapped by `from_rdsap_schema_17_1`.
* **full SAP** (`assessment_type == "SAP"`, e.g. the as-designed `LIG-*`
new-builds) the *measured* shape: `sap_opening_types` + structured
`sap_building_parts` carrying measured U-values and door/window openings,
NOT the reduced top-level count fields. These omit `door_count` because
the doors are lodged as openings, so the reduced normaliser failed loud
(`RdSapSchema17_1: missing required field 'door_count'`). They are
full-SAP certs and map via the full-SAP 17.1 mapper instead."""
if _is_full_sap_cert(data):
return EpcPropertyDataMapper._from_full_sap_schema_16_x(data)
from datatypes.epc.schema.rdsap_schema_17_1 import RdSapSchema17_1
return EpcPropertyDataMapper.from_rdsap_schema_17_1(
from_dict(RdSapSchema17_1, _normalize_sap_schema_16_x(data))
)
@staticmethod
def _from_full_sap_schema_16_x(data: Dict[str, Any]) -> EpcPropertyData:
"""Map a full-SAP cert lodged under a `SAP-Schema-16.x` version.
Structurally a full-SAP cert (`sap_opening_types` + measured
`sap_building_parts`), so it parses with the full-SAP 17.1 dataclass and
reuses `from_sap_schema_17_1` door/window/fabric come from the real
measured openings, no reduced-field defaulting. The only `SapSchema17_1`
field the 16.x full-SAP shape omits is `tenure` (register metadata with no
SAP effect), defaulted here."""
normalised = copy.deepcopy(data)
normalised.setdefault("tenure", "unknown")
return EpcPropertyDataMapper.from_sap_schema_17_1(
from_dict(SapSchema17_1, normalised)
)
@staticmethod
def from_rdsap_schema_17_1(schema: RdSapSchema17_1) -> EpcPropertyData:
es = schema.sap_energy_source
@ -3290,6 +3321,18 @@ def _derive_built_form_16x(dwelling_type: Any) -> int:
return 4 # flats / unstated form → modal built_form (SAP- and gate-neutral)
def _is_full_sap_cert(data: Dict[str, Any]) -> bool:
"""Whether a `SAP-Schema-16.x` doc is structurally a full-SAP cert rather than
the reduced RdSAP shape: an as-designed/full SAP assessment
(`assessment_type == "SAP"`) that lodges fabric as measured openings
(`sap_opening_types`) instead of the reduced top-level count fields. Both
signals agree on the real corpus; requiring both avoids mis-routing a reduced
cert that happens to carry one."""
return data.get("assessment_type") == "SAP" and bool(
data.get("sap_opening_types")
)
def _normalize_sap_schema_16_x(data: Dict[str, Any]) -> Dict[str, Any]:
"""Rewrite a `SAP-Schema-16.2`/`16.3` API doc onto the `RdSAP-Schema-17.1`
shape so it can reuse the tested `from_rdsap_schema_17_1` mapper.

View file

@ -657,3 +657,40 @@ class TestFromSapSchema16_2:
EpcPropertyDataMapper.from_api_response(data)
assert "window" not in data # the rename landed only on the copy
assert set(data.keys()) == before_keys
class TestFullSapSchema16xRouting:
"""The `SAP-Schema-16.x` name covers two structurally different certs: the
reduced RdSAP shape (top-level `door_count`/`glazed_area`) AND full SAP (the
as-designed `LIG-*` new-builds `assessment_type=SAP`, measured
`sap_opening_types` + structured `sap_building_parts`, doors lodged as
openings not a top-level count). Dispatch must route each to its own mapper;
sending a full-SAP cert through the reduced normaliser failed loud with
`RdSapSchema17_1: missing required field 'door_count'`."""
def test_full_sap_16_x_cert_maps_via_full_sap_path(self) -> None:
# Arrange — a real full-SAP cert lodged under SAP-Schema-16.0 (cert
# 0293-3880-6151-9872-8795, lodged 83) that previously raised the
# missing-door_count ValueError in the reduced normaliser.
data = load("sap_16_0_full.json")
# Act
epc = EpcPropertyDataMapper.from_api_response(data)
# Assert — mapped from the real measured openings, not reduced-field
# defaults: the single lodged door opening (0.9 x 2.1 m) → door_count 1.
assert isinstance(epc, EpcPropertyData)
assert epc.door_count == 1
assert epc.dwelling_type == "Detached house"
def test_reduced_16_x_cert_unaffected_by_full_sap_routing(self) -> None:
# Arrange — a reduced 16.2 cert (assessment_type RdSAP, no
# sap_opening_types) must stay on the RdSAP path, keeping its top-level
# property_type.
data = load("sap_16_2.json")
# Act
epc = EpcPropertyDataMapper.from_api_response(data)
# Assert
assert epc.property_type is not None

View file

@ -0,0 +1,435 @@
{
"uprn": 10008075674,
"roofs": [
{
"description": "Average thermal transmittance 0.11 W/m\u00b2K",
"energy_efficiency_rating": 5,
"environmental_efficiency_rating": 5
}
],
"walls": [
{
"description": "Average thermal transmittance 0.21 W/m\u00b2K",
"energy_efficiency_rating": 5,
"environmental_efficiency_rating": 5
}
],
"floors": [
{
"description": "Average thermal transmittance 0.12 W/m\u00b2K",
"energy_efficiency_rating": 5,
"environmental_efficiency_rating": 5
}
],
"status": "entered",
"windows": {
"description": "High performance glazing",
"energy_efficiency_rating": 5,
"environmental_efficiency_rating": 5
},
"lighting": {
"description": "Low energy lighting in all fixed outlets",
"energy_efficiency_rating": 5,
"environmental_efficiency_rating": 5
},
"postcode": "PE2 9HD",
"data_type": 2,
"hot_water": {
"description": "From main system, plus solar",
"energy_efficiency_rating": 5,
"environmental_efficiency_rating": 5
},
"post_town": "PETERBOROUGH",
"built_form": 1,
"created_at": "2012-05-30 13:30:25.000000",
"living_area": 18.011,
"orientation": 7,
"region_code": 2,
"report_type": 3,
"sap_heating": {
"thermal_store": 1,
"has_solar_panel": "true",
"water_fuel_type": 1,
"solar_store_volume": 100,
"water_heating_code": 901,
"hot_water_store_size": 210,
"main_heating_details": [
{
"burner_control": 3,
"main_fuel_type": 1,
"efficiency_type": 3,
"heat_emitter_type": 1,
"main_heating_code": 102,
"is_flue_fan_present": "false",
"main_heating_number": 1,
"is_condensing_boiler": "true",
"main_heating_control": 2110,
"is_interlocked_system": "true",
"main_heating_category": 2,
"main_heating_fraction": 1,
"gas_or_oil_boiler_type": 1,
"main_heating_flue_type": 2,
"main_heating_efficiency": 89,
"main_heating_data_source": 2,
"has_delayed_start_thermostat": "false",
"load_or_weather_compensation": 0,
"is_central_heating_pump_in_heated_space": "true"
}
],
"has_hot_water_cylinder": "true",
"has_solar_powered_pump": "false",
"has_cylinder_thermostat": "true",
"solar_panel_aperture_area": 4.51,
"has_fixed_air_conditioning": "false",
"secondary_heating_category": 1,
"solar_panel_collector_type": 2,
"is_cylinder_in_heated_space": "true",
"solar_panel_collector_pitch": 2,
"is_hot_water_separately_timed": "true",
"is_primary_pipework_insulated": "true",
"hot_water_store_insulation_type": 1,
"hot_water_store_heat_loss_source": 3,
"is_solar_store_combined_cylinder": "true",
"solar_panel_collector_data_source": 2,
"solar_panel_collector_orientation": 5,
"solar_panel_collector_overshading": 1,
"hot_water_store_insulation_thickness": 85,
"solar_panel_collector_heat_loss_rate": 3.681,
"solar_panel_collector_zero_loss_efficiency": 77
},
"sap_version": 9.9,
"schema_type": "SAP-Schema-16.0",
"uprn_source": "Energy Assessor",
"country_code": "EAW",
"main_heating": [
{
"description": "Boiler and radiators, mains gas",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
}
],
"air_tightness": {
"description": "Air permeability 4.9 m\u00b3/h.m\u00b2 (as tested)",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
},
"dwelling_type": "Detached house",
"language_code": 1,
"property_type": 0,
"address_line_1": "43, New Road",
"address_line_2": "Woodston",
"schema_version": "LIG-15.0",
"assessment_date": "2012-05-30",
"assessment_type": "SAP",
"completion_date": "2012-05-30",
"inspection_date": "2012-05-30",
"sap_ventilation": {
"psv_count": 0,
"pressure_test": 1,
"air_permeability": 4.88,
"open_flues_count": 0,
"ventilation_type": 1,
"extract_fans_count": 3,
"open_fireplaces_count": 0,
"sheltered_sides_count": 2,
"flueless_gas_fires_count": 0
},
"design_water_use": 1,
"total_floor_area": 76,
"transaction_type": 6,
"conservatory_type": 1,
"registration_date": "2012-05-30",
"restricted_access": 0,
"sap_energy_source": {
"electricity_tariff": 1,
"wind_turbines_count": 0,
"wind_turbine_terrain_type": 2,
"fixed_lighting_outlets_count": 0,
"low_energy_fixed_lighting_outlets_count": 0,
"low_energy_fixed_lighting_outlets_percentage": 100
},
"sap_opening_types": [
{
"name": "Door (1)",
"type": 1,
"u_value": 1.8,
"data_source": 2,
"description": "Data from Manufacturer",
"glazing_type": 1
},
{
"name": "Windows (1)",
"type": 4,
"u_value": 1.5,
"data_source": 2,
"description": "Data from Manufacturer",
"frame_factor": 0.7,
"glazing_type": 7,
"solar_transmittance": 0.63
}
],
"secondary_heating": {
"description": "None",
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
},
"lzc_energy_sources": [
10
],
"sap_building_parts": [
{
"sap_roofs": [
{
"name": "main roof",
"u_value": 0.11,
"roof_type": 2,
"kappa_value": 9,
"total_roof_area": 38.65
},
{
"name": "flat roof",
"u_value": 0.19,
"roof_type": 2,
"kappa_value": 9,
"total_roof_area": 1.39
}
],
"sap_walls": [
{
"name": "brick wall",
"u_value": 0.21,
"wall_type": 2,
"kappa_value": 150,
"total_wall_area": 115.98,
"is_curtain_walling": "false"
},
{
"name": "downstairs",
"u_value": 0,
"wall_type": 5,
"kappa_value": 9,
"total_wall_area": 44.7
},
{
"name": "upper",
"u_value": 0,
"wall_type": 5,
"kappa_value": 9,
"total_wall_area": 52.99
}
],
"identifier": "Main Dwelling",
"overshading": 2,
"sap_openings": [
{
"name": 1,
"type": "Door (1)",
"width": 0.9,
"height": 2.1,
"location": "brick wall",
"orientation": 7
},
{
"name": 2,
"type": "Windows (1)",
"width": 0.665,
"height": 1.05,
"location": "brick wall",
"orientation": 1
},
{
"name": 3,
"type": "Windows (1)",
"width": 2.09,
"height": 1.05,
"location": "brick wall",
"orientation": 7
},
{
"name": 4,
"type": "Windows (1)",
"width": 0.665,
"height": 1.05,
"location": "brick wall",
"orientation": 5
},
{
"name": 5,
"type": "Windows (1)",
"width": 2.5,
"height": 2.1,
"location": "brick wall",
"orientation": 3
},
{
"name": 6,
"type": "Windows (1)",
"width": 1.05,
"height": 1.425,
"location": "brick wall",
"orientation": 7
},
{
"name": 7,
"type": "Windows (1)",
"width": 0.6,
"height": 1.2,
"location": "brick wall",
"orientation": 1
},
{
"name": 8,
"type": "Windows (1)",
"width": 0.8,
"height": 1.425,
"location": "brick wall",
"orientation": 3
},
{
"name": 9,
"type": "Windows (1)",
"width": 0.8,
"height": 1.425,
"location": "brick wall",
"orientation": 3
},
{
"name": 10,
"type": "Windows (1)",
"width": 0.6,
"height": 1.05,
"location": "brick wall",
"orientation": 5
}
],
"construction_year": 2012,
"sap_thermal_bridges": {
"thermal_bridges": [
{
"length": 9.77,
"psi_value": 0.3,
"psi_value_source": 4,
"thermal_bridge_type": 2
},
{
"length": 7.27,
"psi_value": 0.04,
"psi_value_source": 4,
"thermal_bridge_type": 3
},
{
"length": 23.55,
"psi_value": 0.05,
"psi_value_source": 4,
"thermal_bridge_type": 4
},
{
"length": 27.18,
"psi_value": 0.16,
"psi_value_source": 4,
"thermal_bridge_type": 5
},
{
"length": 27.18,
"psi_value": 0.07,
"psi_value_source": 4,
"thermal_bridge_type": 6
},
{
"length": 8.68,
"psi_value": 0.06,
"psi_value_source": 4,
"thermal_bridge_type": 10
},
{
"length": 17.17,
"psi_value": 0.24,
"psi_value_source": 4,
"thermal_bridge_type": 12
},
{
"length": 21.04,
"psi_value": 0.09,
"psi_value_source": 4,
"thermal_bridge_type": 16
}
],
"thermal_bridge_code": 5
},
"building_part_number": 1,
"sap_floor_dimensions": [
{
"storey": 0,
"u_value": 0.12,
"floor_type": 2,
"storey_height": 2.55,
"heat_loss_area": 38.65,
"total_floor_area": 38.65,
"kappa_value_from_below": 18
},
{
"storey": 1,
"u_value": 0,
"floor_type": 3,
"kappa_value": 18,
"storey_height": 2.71,
"heat_loss_area": 1.3900032,
"total_floor_area": 37.26,
"kappa_value_from_below": 18
}
]
}
],
"bedf_revision_number": 323,
"heating_cost_current": 260,
"co2_emissions_current": 1.2,
"energy_rating_average": 60,
"energy_rating_current": 83,
"lighting_cost_current": 43,
"main_heating_controls": [
{
"description": "Time and temperature zone control",
"energy_efficiency_rating": 5,
"environmental_efficiency_rating": 5
}
],
"has_hot_water_cylinder": "true",
"heating_cost_potential": 251,
"hot_water_cost_current": 37,
"suggested_improvements": [
{
"sequence": 1,
"typical_saving": 219,
"indicative_cost": "\u00a311,000 - \u00a320,000",
"improvement_type": "U",
"improvement_details": {
"improvement_number": 34
},
"improvement_category": 3,
"energy_performance_rating": 94,
"environmental_impact_rating": 97
}
],
"co2_emissions_potential": 1.2,
"energy_rating_potential": 83,
"lighting_cost_potential": 43,
"hot_water_cost_potential": 47,
"is_in_smoke_control_area": "unknown",
"renewable_heat_incentive": {
"rhi_new_dwelling": {
"space_heating": 3010,
"water_heating": 2175
}
},
"seller_commission_report": "Y",
"energy_consumption_current": 84,
"has_fixed_air_conditioning": "false",
"calculation_software_version": "Version: 1.4.0.80",
"energy_consumption_potential": 84,
"environmental_impact_current": 87,
"current_energy_efficiency_band": "B",
"environmental_impact_potential": 87,
"has_heated_separate_conservatory": "false",
"potential_energy_efficiency_band": "B",
"co2_emissions_current_per_floor_area": 16
}