Derive has_hot_water_cylinder from cylinder_size when the 16.x-family cert omits it

Task b9fcd354-2a33-4fc4-a64e-388c060a055c (portfolio 824 / scenario 1278)
also failed property_id 749229 (UPRN 100010349400) with "RdSapSchema17_1:
missing required field 'has_hot_water_cylinder'". Cert
9568-3034-6211-6089-5960 (SAP-Schema-15.0) lodges `sap_heating.cylinder_size`
but omits the separate top-level `has_hot_water_cylinder` boolean
RdSapSchema17_1 also requires.

Unlike `multiple_glazed_proportion` (deliberately left un-defaulted a few
lines above — a prior guessed default regressed the accuracy gate),
`cylinder_size` isn't a proxy needing inference: RdSAP 10 Table 28 defines
code 1 as "no cylinder" — literally the same fact `has_hot_water_cylinder`
encodes. Confirmed 1:1 across every real 15.0/16.x fixture that lodges both
fields (cylinder_size == 1 <-> false, every other code <-> true).

Added to the shared `_normalize_sap_schema_16_x`, so it covers the whole
15.0/16.0/16.1/16.2/16.3 reduced-field family, not just 15.0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-07-10 11:48:12 +00:00
parent 2851a0151b
commit 4f9bec7a4a
3 changed files with 281 additions and 0 deletions

View file

@ -3998,6 +3998,22 @@ def _normalize_sap_schema_16_x(data: Dict[str, Any]) -> Dict[str, Any]:
mh.setdefault("main_heating_index_number", mh["boiler_index_number"])
mh.setdefault("emitter_temperature", None)
# Some 16.x/15.0 certs (e.g. cert 9568-3034-6211-6089-5960, UPRN
# 100010349400, SAP-Schema-15.0) lodge `sap_heating.cylinder_size` but
# omit the top-level `has_hot_water_cylinder` boolean entirely —
# RdSapSchema17_1 requires both as independent fields. Unlike
# `multiple_glazed_proportion` above, this is NOT a guess: RdSAP 10
# §10.5's `cylinder_size` code 1 means "no cylinder" by definition
# (see `_CYLINDER_SIZE_CODE_TO_LITRES` / cert_to_inputs.py:5902), the
# exact same fact `has_hot_water_cylinder` encodes — confirmed 1:1
# across every real 15.0/16.x fixture that lodges both fields
# (cylinder_size == 1 <-> has_hot_water_cylinder == false, every
# other code <-> true). Derive rather than leave the cert unmappable.
if "has_hot_water_cylinder" not in d:
cylinder_size = heating.get("cylinder_size")
if isinstance(cylinder_size, int):
d["has_hot_water_cylinder"] = cylinder_size != 1
for bp in _dicts(d.get("sap_building_parts")):
for fd in _dicts(bp.get("sap_floor_dimensions")):
fd.setdefault("party_wall_length", 0)

View file

@ -86,3 +86,24 @@ class TestFromSapSchema15_0:
assert len(epc.sap_building_parts) == 1
assert epc.sap_building_parts[0].construction_age_band == "D"
def test_derives_has_hot_water_cylinder_from_cylinder_size_when_absent(
self,
) -> None:
# Task b9fcd354-2a33-4fc4-a64e-388c060a055c also failed property_id
# 749229 (UPRN 100010349400) with "RdSapSchema17_1: missing required
# field 'has_hot_water_cylinder'": cert 9568-3034-6211-6089-5960 lodges
# `sap_heating.cylinder_size` but omits the top-level
# `has_hot_water_cylinder` boolean entirely. Unlike
# `multiple_glazed_proportion` (deliberately NOT defaulted — see the NB
# above), `cylinder_size` code 1 means "no cylinder" by definition
# (RdSAP 10 §10.5 Table 28 / cert_to_inputs.py `_CYLINDER_SIZE_CODE_*`)
# — the exact same fact as `has_hot_water_cylinder`, confirmed 1:1
# across every real 15.0/16.x fixture that lodges both fields. Must be
# derived rather than left to fail loud.
epc = EpcPropertyDataMapper.from_api_response(
load("sap_15_0_uprn_100010349400.json")
)
assert epc.has_hot_water_cylinder is False
assert epc.sap_heating.cylinder_size == 1

View file

@ -0,0 +1,244 @@
{
"uprn": 100010349400,
"roofs": [
{
"description": "Pitched, 200 mm loft insulation",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
}
],
"walls": [
{
"description": "Cavity wall, as built, insulated (assumed)",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
}
],
"floors": [
{
"description": "Solid, limited insulation (assumed)",
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
}
],
"status": "entered",
"windows": [
{
"description": "Fully double glazed",
"energy_efficiency_rating": 3,
"environmental_efficiency_rating": 3
}
],
"lighting": {
"description": "Low energy lighting in 71% of fixed outlets",
"energy_efficiency_rating": 5,
"environmental_efficiency_rating": 5
},
"postcode": "BB10 2HD",
"hot_water": {
"description": "From main system",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
},
"post_town": "BURNLEY",
"built_form": 2,
"created_at": "2011-09-16 17:02:16",
"glazed_area": 1,
"region_code": 19,
"report_type": 2,
"sap_heating": {
"cylinder_size": 1,
"water_heating_code": 901,
"water_heating_fuel": 26,
"main_heating_details": [
{
"main_fuel_type": 26,
"boiler_flue_type": 2,
"fan_flue_present": "Y",
"heat_emitter_type": 1,
"boiler_index_number": 457,
"main_heating_number": 1,
"main_heating_control": 2104,
"main_heating_category": 2,
"main_heating_fraction": 1,
"main_heating_data_source": 1
}
],
"has_fixed_air_conditioning": "false"
},
"sap_version": 9.9,
"schema_type": "SAP-Schema-15.0",
"uprn_source": "Energy Assessor",
"country_code": "EAW",
"main_heating": [
{
"description": "Boiler and radiators, mains gas",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
}
],
"dwelling_type": "Semi-detached house",
"language_code": 1,
"property_type": 0,
"address_line_1": "2, Moorview Close",
"schema_version": "LIG-15.0",
"assessment_type": "RdSAP",
"completion_date": "2011-09-16",
"inspection_date": "2011-09-16",
"extensions_count": 0,
"measurement_type": 1,
"total_floor_area": 52,
"transaction_type": 3,
"conservatory_type": 1,
"heated_room_count": 4,
"registration_date": "2011-09-16",
"restricted_access": 0,
"sap_energy_source": {
"main_gas": "Y",
"meter_type": 2,
"photovoltaic_supply": {
"percent_roof_area": 0
},
"wind_turbines_count": 0,
"wind_turbines_terrain_type": 2
},
"secondary_heating": {
"description": "None",
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
},
"sap_building_parts": [
{
"identifier": "Main Dwelling",
"floor_heat_loss": 7,
"roof_construction": 5,
"wall_construction": 4,
"building_part_number": 1,
"sap_floor_dimensions": [
{
"floor": 0,
"room_height": 2.3,
"floor_insulation": 1,
"total_floor_area": 26.1,
"floor_construction": 1,
"heat_loss_perimeter": 14.45
},
{
"floor": 1,
"room_height": 2.3,
"total_floor_area": 26.1,
"heat_loss_perimeter": 14.45
}
],
"wall_insulation_type": 4,
"construction_age_band": "I",
"roof_insulation_location": 2,
"roof_insulation_thickness": "200mm"
}
],
"low_energy_lighting": 71,
"solar_water_heating": "N",
"bedf_revision_number": 313,
"habitable_room_count": 4,
"heating_cost_current": 373,
"co2_emissions_current": 2.2,
"energy_rating_current": 69,
"lighting_cost_current": 42,
"main_heating_controls": [
{
"description": "Programmer and room thermostat",
"energy_efficiency_rating": 3,
"environmental_efficiency_rating": 3
}
],
"multiple_glazing_type": 1,
"open_fireplaces_count": 0,
"heating_cost_potential": 322,
"hot_water_cost_current": 86,
"mechanical_ventilation": 0,
"suggested_improvements": [
{
"sequence": 1,
"typical_saving": 8,
"indicative_cost": "\u00a35",
"improvement_type": "E",
"improvement_details": {
"improvement_number": 35
},
"improvement_category": 1,
"energy_performance_rating": 69,
"environmental_impact_rating": 71
},
{
"sequence": 2,
"typical_saving": 68,
"indicative_cost": "\u00a31,500 - \u00a33,500",
"improvement_type": "I",
"improvement_details": {
"improvement_number": 20
},
"improvement_category": 2,
"energy_performance_rating": 74,
"environmental_impact_rating": 76
},
{
"sequence": 3,
"typical_saving": 22,
"indicative_cost": "\u00a34,000 - \u00a36,000",
"improvement_type": "N",
"improvement_details": {
"improvement_number": 19
},
"improvement_category": 3,
"energy_performance_rating": 75,
"environmental_impact_rating": 78
},
{
"sequence": 4,
"typical_saving": 214,
"indicative_cost": "\u00a311,000 - \u00a320,000",
"improvement_type": "U",
"improvement_details": {
"improvement_number": 34
},
"improvement_category": 3,
"energy_performance_rating": 88,
"environmental_impact_rating": 91
},
{
"sequence": 5,
"typical_saving": 18,
"indicative_cost": "\u00a31,500 - \u00a34,000",
"improvement_type": "V",
"improvement_details": {
"improvement_number": 44
},
"improvement_category": 3,
"energy_performance_rating": 89,
"environmental_impact_rating": 92
}
],
"co2_emissions_potential": 1.7,
"energy_rating_potential": 74,
"lighting_cost_potential": 32,
"hot_water_cost_potential": 70,
"renewable_heat_incentive": {
"water_heating": 1739,
"space_heating_existing_dwelling": 5036,
"space_heating_with_loft_and_cavity_insulation": 5036
},
"seller_commission_report": "Y",
"energy_consumption_current": 216,
"has_fixed_air_conditioning": "false",
"multiple_glazed_proportion": 100,
"calculation_software_version": 9.9,
"energy_consumption_potential": 173,
"environmental_impact_current": 70,
"fixed_lighting_outlets_count": 7,
"current_energy_efficiency_band": "C",
"environmental_impact_potential": 76,
"has_heated_separate_conservatory": "false",
"potential_energy_efficiency_band": "C",
"co2_emissions_current_per_floor_area": 41,
"low_energy_fixed_lighting_outlets_count": 5
}