From 2851a0151b357b1d880170dd02aea3947c3dac4d Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Fri, 10 Jul 2026 11:34:17 +0000 Subject: [PATCH] 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