From 2851a0151b357b1d880170dd02aea3947c3dac4d Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Fri, 10 Jul 2026 11:34:17 +0000 Subject: [PATCH 1/3] Drop phantom alternative-wall building parts lodged inline (reinstates dropped fix) Task b9fcd354-2a33-4fc4-a64e-388c060a055c (portfolio 824 / scenario 1278) still failed 5 of 128 batches with NotNullViolation on epc_building_part.construction_age_band even after the SAP-Schema-15.0 mapper landed (PR #1531). Cert 8169-6926-5310-0447-8996 (UPRN 100010344955, SAP-Schema-15.0) lodges a bare alternative-wall record (wall_area/wall_construction/wall_insulation_type, no identifier, age band, or floor dimensions) as a second sap_building_parts element rather than under a distinct sap_alternative_wall_1 key. RdSapSchema17_1 (which the 15.0/16.x reduced-field mappers delegate to) has no alt-wall slot to route it to, so it persisted as a phantom building part with a null age band. _drop_building_parts_without_age_band already solved exactly this shape of problem for lodging artifacts (ae81a81e), but that commit sits on fix/drop-building-part-without-age-band, an unmerged branch that has since drifted far behind main. Reinstating the same filter (fresh, not cherry-picked) fixes both the original artifact case and this SAP-15 alt-wall case, since RdSapSchema17_1 has nowhere else to put it either way. Verified against all 174 properties across the task's originally-failing batches: zero remaining null-age-band building parts. Co-Authored-By: Claude Sonnet 5 --- datatypes/epc/domain/mapper.py | 44 ++- .../domain/tests/test_from_sap_schema_15_0.py | 21 ++ .../fixtures/sap_15_0_uprn_100010344955.json | 310 ++++++++++++++++++ 3 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 datatypes/epc/schema/tests/fixtures/sap_15_0_uprn_100010344955.json diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 064a42d09..0f0582d11 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -3057,7 +3057,10 @@ class EpcPropertyDataMapper: return _clear_basement_flag_when_system_built( _with_renewable_heat_incentive( - _with_recorded_performance(mapped, data), data + _with_recorded_performance( + _drop_building_parts_without_age_band(mapped), data + ), + data, ) ) @@ -3067,6 +3070,45 @@ class EpcPropertyDataMapper: # --------------------------------------------------------------------------- +def _drop_building_parts_without_age_band( + epc: EpcPropertyData, +) -> EpcPropertyData: + """Drop building parts that carry no `construction_age_band`. + + Some gov-API certs lodge a leading building part with floor geometry but no + construction descriptors (no age band, identifier or wall/roof construction) + — a glazed-perimeter "conservatory shell" / lodging artifact, e.g. cert + 3235-4627-4400-0610-7292's part 0 `{floor_area, room_height, glazed_perimeter, + double_glazed}`. A part with no age band cannot have its RdSAP fabric U-values + derived (the engine reads `parts[0].construction_age_band` as the primary era), + and it violates the epc_building_part NOT-NULL column on persist. + + Also catches 2011-era `SAP-Schema-15.0`/16.x reduced-field certs (see + `from_sap_schema_15_0`) that lodge an alternative-wall record (bare + `wall_area`/`wall_construction`/`wall_insulation_type`, no identifier or + floor dimensions) inline as a second `sap_building_parts` element instead of + under a distinct `sap_alternative_wall_1` key — e.g. cert + 8169-6926-5310-0447-8996 (UPRN 100010344955), part 1 `{wall_area: 18.74, + wall_construction: 5, wall_insulation_type: 4}`. `RdSapSchema17_1` has no + alternative-wall slot to route it to, so it is dropped like any other + band-less part rather than persisted as a phantom building part. + + Drops such parts only when at least one banded part remains, so a fully + band-less cert (e.g. full SAP, measured-U) is left untouched. Returns the + same object when nothing changes.""" + parts = epc.sap_building_parts + banded = [ + p + for p in parts + # construction_age_band is typed str but the gov API genuinely omits it + # on these artifact parts, so the runtime None check is real. + if p.construction_age_band is not None # pyright: ignore[reportUnnecessaryComparison] + ] + if banded and len(banded) != len(parts): + return replace(epc, sap_building_parts=banded) + return epc + + # RdSAP 10 Table 27 (p.52) living-area fraction by habitable-room count. # Mirrored here read-only to back-solve a room count from full SAP's measured # living_area (single home is the calculator; this is the inverse lookup). diff --git a/datatypes/epc/domain/tests/test_from_sap_schema_15_0.py b/datatypes/epc/domain/tests/test_from_sap_schema_15_0.py index 570b84df9..eb4c8649d 100644 --- a/datatypes/epc/domain/tests/test_from_sap_schema_15_0.py +++ b/datatypes/epc/domain/tests/test_from_sap_schema_15_0.py @@ -65,3 +65,24 @@ class TestFromSapSchema15_0: result = Sap10Calculator().calculate(epc) # Lodged 73; engine produces 72 (Elmhurst validation not yet run). assert result.sap_score == 72 + + def test_drops_a_lone_alternative_wall_lodged_as_a_phantom_building_part( + self, + ) -> None: + # Task b9fcd354-2a33-4fc4-a64e-388c060a055c (portfolio 824 / scenario + # 1278) still failed to persist UPRN 100010344955 even once the schema + # itself dispatches: cert 8169-6926-5310-0447-8996 lodges a bare + # alternative-wall record (wall_area/wall_construction/ + # wall_insulation_type, no identifier/age band/floor dimensions) as a + # second `sap_building_parts` element rather than under a distinct + # `sap_alternative_wall_1` key. `RdSapSchema17_1` has no slot to route + # it to, so it must be dropped like any other band-less part + # (`_drop_building_parts_without_age_band`) instead of persisted as a + # phantom building part that violates the NOT-NULL + # `construction_age_band` column. + epc = EpcPropertyDataMapper.from_api_response( + load("sap_15_0_uprn_100010344955.json") + ) + + assert len(epc.sap_building_parts) == 1 + assert epc.sap_building_parts[0].construction_age_band == "D" diff --git a/datatypes/epc/schema/tests/fixtures/sap_15_0_uprn_100010344955.json b/datatypes/epc/schema/tests/fixtures/sap_15_0_uprn_100010344955.json new file mode 100644 index 000000000..6d7087b13 --- /dev/null +++ b/datatypes/epc/schema/tests/fixtures/sap_15_0_uprn_100010344955.json @@ -0,0 +1,310 @@ +{ + "uprn": 100010344955, + "roofs": [ + { + "description": "Pitched, 100 mm loft insulation", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + } + ], + "walls": [ + { + "description": "Cavity wall, as built, no insulation (assumed)", + "energy_efficiency_rating": 2, + "environmental_efficiency_rating": 2 + }, + { + "description": "Timber frame, as built, partial insulation (assumed)", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + } + ], + "floors": [ + { + "description": "Suspended, no 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 50% of fixed outlets", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "postcode": "BB11 4DP", + "hot_water": { + "description": "From main system, no cylinder thermostat", + "energy_efficiency_rating": 2, + "environmental_efficiency_rating": 2 + }, + "post_town": "BURNLEY", + "built_form": 2, + "created_at": "2011-06-13 13:23:32", + "glazed_area": 1, + "region_code": 19, + "report_type": 2, + "sap_heating": { + "cylinder_size": 2, + "water_heating_code": 901, + "water_heating_fuel": 26, + "cylinder_thermostat": "N", + "secondary_fuel_type": 26, + "main_heating_details": [ + { + "main_fuel_type": 26, + "boiler_flue_type": 1, + "heat_emitter_type": 1, + "main_heating_number": 1, + "main_heating_control": 2107, + "main_heating_category": 2, + "main_heating_fraction": 1, + "sap_main_heating_code": 111, + "main_heating_data_source": 2 + } + ], + "secondary_heating_type": 601, + "cylinder_insulation_type": 1, + "has_fixed_air_conditioning": "false", + "cylinder_insulation_thickness": 38 + }, + "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": "9, Kinross Street", + "schema_version": "LIG-15.0", + "assessment_type": "RdSAP", + "completion_date": "2011-06-13", + "inspection_date": "2011-06-13", + "extensions_count": 0, + "measurement_type": 1, + "total_floor_area": 63, + "transaction_type": 5, + "conservatory_type": 1, + "heated_room_count": 3, + "registration_date": "2011-06-13", + "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": "Room heaters, mains gas", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "floor_heat_loss": 7, + "roof_construction": 4, + "wall_construction": 4, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": 2.4, + "floor_insulation": 0, + "total_floor_area": 32.93, + "floor_construction": 2, + "heat_loss_perimeter": 16.46 + }, + { + "floor": 1, + "room_height": 2.4, + "total_floor_area": 29.57, + "heat_loss_perimeter": 15.76 + } + ], + "wall_insulation_type": 4, + "construction_age_band": "D", + "roof_insulation_location": 2, + "roof_insulation_thickness": "100mm" + }, + { + "wall_area": 18.74, + "wall_location": 0, + "wall_construction": 5, + "wall_insulation_type": 4 + } + ], + "low_energy_lighting": 50, + "solar_water_heating": "N", + "bedf_revision_number": 310, + "habitable_room_count": 3, + "heating_cost_current": 648, + "co2_emissions_current": 4.5, + "energy_rating_current": 50, + "lighting_cost_current": 51, + "main_heating_controls": [ + { + "description": "Programmer, TRVs and bypass", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + } + ], + "multiple_glazing_type": 1, + "open_fireplaces_count": 0, + "has_hot_water_cylinder": "true", + "heating_cost_potential": 426, + "hot_water_cost_current": 177, + "mechanical_ventilation": 0, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": 17, + "indicative_cost": "\u00a3100 - \u00a3300", + "improvement_type": "A", + "improvement_details": { + "improvement_number": 5 + }, + "improvement_category": 1, + "energy_performance_rating": 51, + "environmental_impact_rating": 47 + }, + { + "sequence": 2, + "typical_saving": 110, + "indicative_cost": "\u00a3100 - \u00a3300", + "improvement_type": "B", + "improvement_details": { + "improvement_number": 6 + }, + "improvement_category": 1, + "energy_performance_rating": 57, + "environmental_impact_rating": 54 + }, + { + "sequence": 3, + "typical_saving": 14, + "indicative_cost": "\u00a310", + "improvement_type": "E", + "improvement_details": { + "improvement_number": 35 + }, + "improvement_category": 1, + "energy_performance_rating": 58, + "environmental_impact_rating": 55 + }, + { + "sequence": 4, + "typical_saving": 30, + "indicative_cost": "\u00a3200 - \u00a3400", + "improvement_type": "F", + "improvement_details": { + "improvement_number": 4 + }, + "improvement_category": 1, + "energy_performance_rating": 60, + "environmental_impact_rating": 57 + }, + { + "sequence": 5, + "typical_saving": 40, + "indicative_cost": "\u00a3350 - \u00a3450", + "improvement_type": "G", + "improvement_details": { + "improvement_number": 14 + }, + "improvement_category": 1, + "energy_performance_rating": 62, + "environmental_impact_rating": 60 + }, + { + "sequence": 6, + "typical_saving": 109, + "indicative_cost": "\u00a31,500 - \u00a33,500", + "improvement_type": "I", + "improvement_details": { + "improvement_number": 20 + }, + "improvement_category": 2, + "energy_performance_rating": 68, + "environmental_impact_rating": 68 + }, + { + "sequence": 7, + "typical_saving": 31, + "indicative_cost": "\u00a34,000 - \u00a36,000", + "improvement_type": "N", + "improvement_details": { + "improvement_number": 19 + }, + "improvement_category": 3, + "energy_performance_rating": 70, + "environmental_impact_rating": 71 + }, + { + "sequence": 8, + "typical_saving": 207, + "indicative_cost": "\u00a311,000 - \u00a320,000", + "improvement_type": "U", + "improvement_details": { + "improvement_number": 34 + }, + "improvement_category": 3, + "energy_performance_rating": 82, + "environmental_impact_rating": 82 + }, + { + "sequence": 9, + "typical_saving": 17, + "indicative_cost": "\u00a31,500 - \u00a34,000", + "improvement_type": "V", + "improvement_details": { + "improvement_number": 44 + }, + "improvement_category": 3, + "energy_performance_rating": 83, + "environmental_impact_rating": 83 + } + ], + "co2_emissions_potential": 2.6, + "energy_rating_potential": 68, + "lighting_cost_potential": 34, + "hot_water_cost_potential": 97, + "renewable_heat_incentive": { + "water_heating": 3180, + "space_heating_existing_dwelling": 9251, + "space_heating_with_loft_insulation": 9108, + "space_heating_with_cavity_insulation": 7244, + "space_heating_with_loft_and_cavity_insulation": 7092 + }, + "seller_commission_report": "Y", + "energy_consumption_current": 370, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "19.04r16", + "energy_consumption_potential": 213, + "environmental_impact_current": 46, + "fixed_lighting_outlets_count": 8, + "current_energy_efficiency_band": "E", + "environmental_impact_potential": 68, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "D", + "co2_emissions_current_per_floor_area": 71, + "low_energy_fixed_lighting_outlets_count": 4 +} \ No newline at end of file From 4f9bec7a4acb03c122d8d0d12cefd369b9188961 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Fri, 10 Jul 2026 11:48:12 +0000 Subject: [PATCH 2/3] Derive has_hot_water_cylinder from cylinder_size when the 16.x-family cert omits it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- datatypes/epc/domain/mapper.py | 16 ++ .../domain/tests/test_from_sap_schema_15_0.py | 21 ++ .../fixtures/sap_15_0_uprn_100010349400.json | 244 ++++++++++++++++++ 3 files changed, 281 insertions(+) create mode 100644 datatypes/epc/schema/tests/fixtures/sap_15_0_uprn_100010349400.json diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 0f0582d11..017f3ac19 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -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) diff --git a/datatypes/epc/domain/tests/test_from_sap_schema_15_0.py b/datatypes/epc/domain/tests/test_from_sap_schema_15_0.py index eb4c8649d..a95345344 100644 --- a/datatypes/epc/domain/tests/test_from_sap_schema_15_0.py +++ b/datatypes/epc/domain/tests/test_from_sap_schema_15_0.py @@ -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 diff --git a/datatypes/epc/schema/tests/fixtures/sap_15_0_uprn_100010349400.json b/datatypes/epc/schema/tests/fixtures/sap_15_0_uprn_100010349400.json new file mode 100644 index 000000000..e2ef2f26a --- /dev/null +++ b/datatypes/epc/schema/tests/fixtures/sap_15_0_uprn_100010349400.json @@ -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 +} \ No newline at end of file From c0b4fd1e6a9d2da1b3007565c9925132c3014821 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Fri, 10 Jul 2026 11:58:07 +0000 Subject: [PATCH 3/3] Fix has_hot_water_cylinder derivation to lodge the string convention, not a bool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior commit derived a Python bool (`cylinder_size != 1`), but `from_rdsap_schema_17_1` reads the field via `schema.has_hot_water_cylinder == "true"` — every real 15.0/16.x fixture lodges the lowercase string "true"/"false", not a JSON boolean. A bare bool compares False against that string check either way, so the derivation silently mapped every cylinder-present cert to has_hot_water_cylinder=False too. Caught by the requested true-branch test (mutating sap_16_2.json, which lodges cylinder_size=2/has_hot_water_cylinder="true", to omit the field) — it failed under the original bool-typed fix. Now emits "true"/"false" strings to match the lodged convention. Co-Authored-By: Claude Sonnet 5 --- datatypes/epc/domain/mapper.py | 14 ++++++++++---- .../epc/domain/tests/test_from_sap_schema_15_0.py | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 017f3ac19..b02455c8e 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -4000,19 +4000,25 @@ def _normalize_sap_schema_16_x(data: Dict[str, Any]) -> Dict[str, Any]: # 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 — + # omit the top-level `has_hot_water_cylinder` field 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. + # (cylinder_size == 1 <-> has_hot_water_cylinder == "false", every + # other code <-> "true"). Derive rather than leave the cert + # unmappable. Lodged as the lowercase string "true"/"false" (RdSAP + # convention every real fixture uses — `from_rdsap_schema_17_1` + # reads it via `schema.has_hot_water_cylinder == "true"`, a bare + # Python bool would silently compare False either way). 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 + d["has_hot_water_cylinder"] = ( + "true" if cylinder_size != 1 else "false" + ) for bp in _dicts(d.get("sap_building_parts")): for fd in _dicts(bp.get("sap_floor_dimensions")): diff --git a/datatypes/epc/domain/tests/test_from_sap_schema_15_0.py b/datatypes/epc/domain/tests/test_from_sap_schema_15_0.py index a95345344..82be846d8 100644 --- a/datatypes/epc/domain/tests/test_from_sap_schema_15_0.py +++ b/datatypes/epc/domain/tests/test_from_sap_schema_15_0.py @@ -107,3 +107,18 @@ class TestFromSapSchema15_0: assert epc.has_hot_water_cylinder is False assert epc.sap_heating.cylinder_size == 1 + + def test_derives_has_hot_water_cylinder_true_from_a_sized_cylinder( + self, + ) -> None: + # Mirror of the above for the other branch: a lodged size code other + # than 1 (here 2 = Normal/110L, per sap_16_2.json) means a cylinder + # IS present, so an omitted `has_hot_water_cylinder` must derive + # True — not just default-False by omission. + data = load("sap_16_2.json") + assert data["sap_heating"]["cylinder_size"] == 2 + del data["has_hot_water_cylinder"] + + epc = EpcPropertyDataMapper.from_api_response(data) + + assert epc.has_hot_water_cylinder is True