diff --git a/.claude/skills/expand-sap-accuracy-corpus/worklist.md b/.claude/skills/expand-sap-accuracy-corpus/worklist.md index 7f382483..55134391 100644 --- a/.claude/skills/expand-sap-accuracy-corpus/worklist.md +++ b/.claude/skills/expand-sap-accuracy-corpus/worklist.md @@ -10,12 +10,26 @@ UPRN, tick it and annotate: `— · eng / elm · `. 2020 new-build flat) — full loop proven: eng 77 / elm 78, engine-on-Elmhurst- inputs 79 (calculator faithful within ~1). Use it to sanity-check the pipeline. +## E2E testing set + +UPRNs needed for end-to-end testing (also tracked in The 100 below). + +| UPRN | Schema | Status | Engine SAP | Notes | +|---|---|---|---|---| +| 10093116528 | SAP-17.1 | ✅ pinned | 82 (elm 81) | full-SAP semi; AP50 fix | +| 10093116543 | SAP-17.1 | ✅ pinned | 81 (elm 77) | 2017 gas-combi semi | +| 10093116529 | SAP-17.1 | ✅ pinned | 81 (elm 78) | 2017 gas-combi g/f flat; party-wall fix | +| 100020933699 | SAP-16.2 | 🔧 mapper + glazing fix | 71 (lodged 70) | RdSAP-shaped; single-glazing fix; Elmhurst validation pending | +| 44012843 | — | ☐ todo | — | not yet processed | +| 10023444324 | — | ☐ todo | — | not yet processed | +| 10023444320 | — | ☐ todo | — | not yet processed | + ## The 100 - [x] 🔧 10093116528 — SAP-17.1 (full-SAP semi) · eng 82 / elm 81 · 🔧 air-perm AP50 fix: q50 Blower-Door routed to (18)=AP50/20 not the AP4/Pulse formula (was eng 78). Residual +1 = lodged-U vs RdSAP age-band-U; FGHRS (60031) omitted both sides. Worked-ref 10092973954 re-pinned 77→80 by same fix. -- [ ] 10093116543 -- [ ] 10093116529 -- [ ] 100020933699 +- [x] 10093116543 — SAP-17.1 (2017 gas-combi semi) · eng 81 / elm 77 (lodged 82) · PINNED engine 81. +4 = documented full-SAP→RdSAP residual, NOT a mapper bug: ~1.5 floor-U (cert lodges measured 0.11 vs Elmhurst RdSAP solid default 0.23; U-known override disabled) + ~1 boiler-eff (cert PCDB 17644 88.5% vs Elmhurst generic BGW combi 84%; PCDB search disabled, 89% cascade option is a regular boiler needing a cylinder) + ~0.5 roof band-L/infil. Conservatory leftover from prior cert cleared (worksheet 73→77). No mapper change. +- [x] 🔧 10093116529 — SAP-17.1 (2017 gas-combi ground-floor FLAT, TFA 49) · eng 81 / elm 78 (lodged 81) · PINNED engine 81. 🔧 FIXED a real calc bug: full-SAP flats took the 0.25 house party-wall default instead of the RdSAP Table-15 flat 0.0 (flatness is in dwelling_type, not property_type) — heat_transmission._is_flat_or_maisonette_dwelling; +regression test. Cert lodges party u_value 0; Elmhurst worksheet 0.0; fix 80→81. Residual +3 vs Elmhurst = documented full-SAP→RdSAP gap (measured wall 0.184/floor 0.12 + PCDB 88.5% vs generic 84%). Calculator faithful: fed Elmhurst's Us, HTC 93.4 vs ~94. House→Flat Elmhurst switch (storeys→1, roof→another-dwelling-above). No mapper change. +- [ ] 🔧 100020933699 — SAP-16.2 SCHEMA COVERAGE ADDED (end-terrace house, band G). 16.2 is structurally RdSAP-17.1 (reduced fields, glazed_area band, construction-code building parts) under a different name; mapped via `_normalize_sap_schema_16_2` (renames windows→window, main_gas→mains_gas, boiler_index_number→main_heating_index_number, wwhrs→instantaneous_wwhrs + defaults) → reuses from_rdsap_schema_17_1. 🔧 Also fixed: "Single glazed" description honoured when multiple_glazing_type="ND" (was defaulting to double; RdSAP-21 code 5) → eng 72→71. +4 regression tests, sap_16_2.json fixture, 0 new pyright errors. eng 71 / lodged 70. ⚠ Known gap: 16.2 lodges no party_wall_length → end-terrace party wall unmodelled (likely the residual +1). ⏳ Elmhurst build (partial: PropDesc/Dims/Walls/Roofs done) + pin still pending. - [ ] 44012843 - [ ] 10023444324 - [ ] 10092970673 diff --git a/.devcontainer/backend/Dockerfile b/.devcontainer/backend/Dockerfile index 1514f78f..f9fa2902 100644 --- a/.devcontainer/backend/Dockerfile +++ b/.devcontainer/backend/Dockerfile @@ -6,11 +6,14 @@ ARG USER_UID=1000 ARG USER_GID=1000 ARG DEBIAN_FRONTEND=noninteractive -# 1) Toolchain + utilities for building libpostal, plus LazyVim deps +# 1) Toolchain + utilities for building libpostal, plus LazyVim deps. +# poppler-utils provides `pdfinfo`, required by backend/documents_parser (the +# Elmhurst Input-Summary / SAP-worksheet PDF fixtures in the SAP corpus tests +# shell out to it). RUN apt-get update && apt-get install -y --no-install-recommends \ sudo jq vim curl git ca-certificates wget \ build-essential pkg-config automake autoconf libtool \ - ripgrep fd-find make unzip bash-completion \ + ripgrep fd-find make unzip bash-completion poppler-utils \ && rm -rf /var/lib/apt/lists/* # 1b) Headed-browser viewer stack for the hyde Elmhurst automation. diff --git a/backend/epc_api/json_samples/real_life_examples/SAP-Schema-16.2/uprn_100020933699/elmhurst_inputs.md b/backend/epc_api/json_samples/real_life_examples/SAP-Schema-16.2/uprn_100020933699/elmhurst_inputs.md new file mode 100644 index 00000000..6644ffaa --- /dev/null +++ b/backend/epc_api/json_samples/real_life_examples/SAP-Schema-16.2/uprn_100020933699/elmhurst_inputs.md @@ -0,0 +1,86 @@ +# Elmhurst RdSAP inputs — UPRN 100020933699 (cert 9548-2053-6299-9947-0950, SAP-Schema-16.2) + +**Lodged SAP:** 70 **Our engine:** 72 (continuous 72.29) ← compare Elmhurst against the engine +**Property:** End-terrace HOUSE, age band G (1983–1990), mains-gas regular boiler + cylinder, TFA 62 m² + +**Schema note:** SAP-Schema-16.2 is RdSAP-shaped (reduced-field) — mapped via `_normalize_sap_schema_16_2` → RdSAP-17.1 mapper. This is a REDUCED-FIELD cert (descriptions + glazed-area band, no measured U-values), so unlike the 17.1 new-builds the engine and Elmhurst should both use RdSAP age-band/description U-values — expect a TIGHTER agreement. + +**Known divergences / gaps to watch:** +- **Party wall:** 16.2 does not lodge `party_wall_length`; the normalizer defaults it to 0, so the engine currently models NO party wall. An end-terrace HAS one party wall — Elmhurst will model it. **This is the likely main divergence; check the worksheet party-wall line.** +- Age band G → Elmhurst on-screen band **G 1983-1990**. + +## Property Description +| Elmhurst field | Value | Notes | +|---|---|---| +| Property Type | **House** | dwelling_type "End-terrace house" (switch from FLAT — prior cert) | +| Built form | **End-Terrace** | built_form 3 | +| Age band | **G 1983-1990** | construction_age_band G | +| Storeys | 2 | floor 0 + floor 1 | +| Habitable rooms | 4 | habitable_room_count 4 | + +## Dimensions +| Elmhurst field | Value | Notes | +|---|---|---| +| Ground floor area | 31.06 m² | floor 0 | +| First floor area | 31.06 m² | floor 1 | +| Room height (ground / first) | 2.40 / 2.65 m | room_height | +| Heat-loss perimeter (each) | 15.8 m | heat_loss_perimeter | +| Party-wall length (each) | **see note** | 16.2 lodges none; for an end-terrace enter the real party-wall length if Elmhurst derives it from built-form, else note the divergence | + +## Walls +| Elmhurst field | Value | Notes | +|---|---|---| +| Construction | Cavity | wall_construction 4 | +| Insulation | **Filled** | wall_insulation_type 2 = filled cavity ("Cavity wall, filled cavity") | +| Thickness | 280 mm | wall_thickness | +| Party wall | Present (End-Terrace) — Unable to determine | built_form 3 | + +## Roofs +| Elmhurst field | Value | Notes | +|---|---|---| +| Type | Pitched, access to loft | roof_construction 4 | +| Insulation at | Joists | roof_insulation_location 2 | +| Thickness | 100 mm | roof_insulation_thickness "100mm" | + +## Floors +| Elmhurst field | Value | Notes | +|---|---|---| +| Location / type | Ground floor / Solid | floor_construction 1 | +| Insulation | **None** | "Solid, no insulation (assumed)" | + +## Openings +| Elmhurst field | Value | Notes | +|---|---|---| +| Windows | **Single glazed**, glazed-area band Normal | glazed_area 1, multiple_glazing_type ND, "Single glazed" | +| Doors | **2 doors, uninsulated** | door_count 2, insulated_door_count 0 (high U — solid/older doors) | + +## Ventilation & Lighting +| Elmhurst field | Value | Notes | +|---|---|---| +| Ventilation | Natural | no MV; no air-permeability test lodged (ap50/ap4 None) | +| Extract fans / flues | 0 / 0 | none lodged; open chimneys 0 | +| Sheltered sides | 1 | sheltered_sides 1 | +| Air Pressure Test | **None** (do NOT enter Blower Door) | no test lodged — leave method blank | +| Lighting | 22% low-energy | low_energy 2 / total 9 outlets | + +## Space Heating +| Elmhurst field | Value | Notes | +|---|---|---| +| Main heating | Mains gas **regular boiler + radiators** | fuel 26, emitter 1, control 2106, fan-assisted flue, PCDB 10321. NOT a combi — has a cylinder → use the BGB regular condensing/non-condensing cascade option (per age band G, likely non-condensing) | +| Secondary | None | secondary "None" | + +## Water Heating +| Elmhurst field | Value | Notes | +|---|---|---| +| Water heating | From main (901), gas | water_heating_fuel 26 | +| Hot water cylinder | **Present** — 110 L (Normal), Foam, 25 mm | has_hot_water_cylinder True, cylinder_size 2, insulation type 1 (foam) 25 mm | +| Solar / WWHRS / FGHRS | None | — | + +## Fields to clear in Elmhurst (do NOT map) +| Elmhurst field | Set to | Why absent | +|---|---|---| +| Flats page | (gone — House now) | switch from prior FLAT cert | +| Conservatory | none / uncheck | not lodged | +| Air Pressure Test method | **blank** | no test lodged (prior certs had Blower Door — CLEAR it) | +| Main Heating 2 / Secondary | none | single system | +| PV / Wind / Hydro | none | none lodged | diff --git a/backend/epc_api/json_samples/real_life_examples/SAP-Schema-16.2/uprn_100020933699/epc.json b/backend/epc_api/json_samples/real_life_examples/SAP-Schema-16.2/uprn_100020933699/epc.json new file mode 100644 index 00000000..b8193954 --- /dev/null +++ b/backend/epc_api/json_samples/real_life_examples/SAP-Schema-16.2/uprn_100020933699/epc.json @@ -0,0 +1,310 @@ +{ + "uprn": 100020933699, + "roofs": [ + { + "description": "Pitched, 100 mm loft insulation", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + } + ], + "walls": [ + { + "description": "Cavity wall, filled cavity", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": "Solid, no insulation (assumed)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 2, + "windows": [ + { + "description": "Single glazed", + "energy_efficiency_rating": 1, + "environmental_efficiency_rating": 1 + } + ], + "lighting": { + "description": "Low energy lighting in 22% of fixed outlets", + "energy_efficiency_rating": 2, + "environmental_efficiency_rating": 2 + }, + "postcode": "SE18 2PE", + "hot_water": { + "description": "From main system", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "LONDON", + "built_form": 3, + "created_at": "2013-01-14 15:52:33.000000", + "door_count": 2, + "glazed_area": 1, + "region_code": 17, + "report_type": 2, + "sap_heating": { + "wwhrs": { + "rooms_with_bath_and_or_shower": 1, + "rooms_with_mixer_shower_no_bath": 0, + "rooms_with_bath_and_mixer_shower": 0 + }, + "cylinder_size": 2, + "water_heating_code": 901, + "water_heating_fuel": 26, + "cylinder_thermostat": "Y", + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "fan_flue_present": "Y", + "heat_emitter_type": 1, + "boiler_index_number": 10321, + "main_heating_number": 1, + "main_heating_control": 2106, + "main_heating_category": 2, + "main_heating_fraction": 1, + "main_heating_data_source": 1 + } + ], + "cylinder_insulation_type": 1, + "has_fixed_air_conditioning": "false", + "cylinder_insulation_thickness": 25 + }, + "sap_version": 9.91, + "schema_type": "SAP-Schema-16.2", + "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": "End-terrace house", + "language_code": 1, + "property_type": 0, + "address_line_1": "1, Acland Close", + "schema_version": "LIG-16.1", + "assessment_type": "RdSAP", + "completion_date": "2013-01-14", + "inspection_date": "2013-01-14", + "extensions_count": 0, + "measurement_type": 1, + "total_floor_area": 62, + "transaction_type": 8, + "conservatory_type": 1, + "heated_room_count": 4, + "registration_date": "2013-01-14", + "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", + "wall_dry_lined": "N", + "wall_thickness": 280, + "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": 1, + "total_floor_area": 31.06, + "floor_construction": 1, + "heat_loss_perimeter": 15.8 + }, + { + "floor": 1, + "room_height": 2.4, + "total_floor_area": 31.06, + "heat_loss_perimeter": 15.8 + } + ], + "wall_insulation_type": 2, + "construction_age_band": "G", + "wall_thickness_measured": "Y", + "roof_insulation_location": 2, + "roof_insulation_thickness": "100mm" + } + ], + "low_energy_lighting": 22, + "solar_water_heating": "N", + "bedf_revision_number": 333, + "habitable_room_count": 4, + "heating_cost_current": { + "value": 370, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 2.2, + "energy_rating_average": 60, + "energy_rating_current": 70, + "lighting_cost_current": { + "value": 66, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Programmer, room thermostat and TRVs", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "multiple_glazing_type": "ND", + "open_fireplaces_count": 0, + "has_hot_water_cylinder": "true", + "heating_cost_potential": { + "value": 296, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 106, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 27, + "currency": "GBP" + }, + "indicative_cost": "\u00a3800 - \u00a31,200", + "improvement_type": "W", + "improvement_details": { + "improvement_number": 47 + }, + "improvement_category": 5, + "energy_performance_rating": 71, + "environmental_impact_rating": 73 + }, + { + "sequence": 2, + "typical_saving": { + "value": 26, + "currency": "GBP" + }, + "indicative_cost": "\u00a335", + "improvement_type": "E", + "improvement_details": { + "improvement_number": 35 + }, + "improvement_category": 5, + "energy_performance_rating": 72, + "environmental_impact_rating": 74 + }, + { + "sequence": 3, + "typical_saving": { + "value": 33, + "currency": "GBP" + }, + "indicative_cost": "\u00a34,000 - \u00a36,000", + "improvement_type": "N", + "improvement_details": { + "improvement_number": 19 + }, + "improvement_category": 5, + "energy_performance_rating": 74, + "environmental_impact_rating": 76 + }, + { + "sequence": 4, + "typical_saving": { + "value": 52, + "currency": "GBP" + }, + "indicative_cost": "\u00a33,300 - \u00a36,500", + "improvement_type": "O", + "improvement_details": { + "improvement_number": 8 + }, + "improvement_category": 5, + "energy_performance_rating": 77, + "environmental_impact_rating": 80 + }, + { + "sequence": 5, + "typical_saving": { + "value": 236, + "currency": "GBP" + }, + "indicative_cost": "\u00a39,000 - \u00a314,000", + "improvement_type": "U", + "improvement_details": { + "improvement_number": 34 + }, + "improvement_category": 5, + "energy_performance_rating": 89, + "environmental_impact_rating": 92 + }, + { + "sequence": 6, + "typical_saving": { + "value": 19, + "currency": "GBP" + }, + "indicative_cost": "\u00a31,500 - \u00a34,000", + "improvement_type": "V", + "improvement_details": { + "improvement_number": 44 + }, + "improvement_category": 5, + "energy_performance_rating": 90, + "environmental_impact_rating": 92 + } + ], + "co2_emissions_potential": 0.5, + "energy_rating_potential": 90, + "lighting_cost_potential": { + "value": 37, + "currency": "GBP" + }, + "hot_water_cost_potential": { + "value": 72, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 2577, + "impact_of_loft_insulation": -309, + "space_heating_existing_dwelling": 5864 + }, + "seller_commission_report": "Y", + "energy_consumption_current": 189, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 0, + "calculation_software_version": 8.0, + "energy_consumption_potential": 36, + "environmental_impact_current": 71, + "fixed_lighting_outlets_count": 9, + "current_energy_efficiency_band": "C", + "environmental_impact_potential": 92, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "B", + "co2_emissions_current_per_floor_area": 36, + "low_energy_fixed_lighting_outlets_count": 2 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/real_life_examples/SAP-Schema-17.1/uprn_10093116529/elmhurst_inputs.md b/backend/epc_api/json_samples/real_life_examples/SAP-Schema-17.1/uprn_10093116529/elmhurst_inputs.md new file mode 100644 index 00000000..fb47fb67 --- /dev/null +++ b/backend/epc_api/json_samples/real_life_examples/SAP-Schema-17.1/uprn_10093116529/elmhurst_inputs.md @@ -0,0 +1,88 @@ +# Elmhurst RdSAP inputs — UPRN 10093116529 (cert 8178-7436-5600-9809-0906, SAP-Schema-17.1) + +**Lodged SAP:** 81 **Our engine:** 80 (continuous 80.22) ← compare Elmhurst against the engine +**Property:** ground-floor FLAT, 2017 build, mains-gas combi, TFA 49.0 m² + +**Known divergences (flag, don't tune) — same full-SAP→RdSAP pattern as uprn_10093116543:** +- **Walls/floor measured U:** cert lodges measured U (wall 0.184, floor 0.12); engine uses them, Elmhurst RdSAP forces band-L defaults (~0.28 / ~0.23). Expected residual. +- **Boiler:** cert PCDB 17644 (88.5%); Elmhurst PCDB search disabled → generic BGW combi 84%. +- **Age band:** engine 'M', build year 2017 → enter Elmhurst band **L (2012–2022)**. + +## Property Description +| Elmhurst field | Value | Notes | +|---|---|---| +| Property Type | **Flat** | dwelling_type "Ground-floor flat" — SWITCH from House (prior cert) | +| Built form | (flat — N/A) | — | +| Age band | **L (2012–2022)** | construction_year 2017 | +| Habitable rooms | 2 | habitable_rooms_count 2 | +| Storeys | 1 | single-storey flat | + +## Flats +| Elmhurst field | Value | Notes | +|---|---|---| +| Position of flat | Ground Floor | ground-floor flat | +| Floor level | 0 | ground = storey 0 | +| Corridor | **None** (all-exposed) | the "to corridor" wall is keyed as a semi-exposed wall element instead — see Walls. Avoids the sheltered-alt-wall validation trap. | + +## Dimensions +| Elmhurst field | Value | Notes | +|---|---|---| +| Lowest-floor area | 49.03 m² | floor dim[0] | +| Room height | 2.60 m | room_height_m | +| Heat-loss perimeter | 28.45 m | heat_loss_perimeter_m | +| Party-wall length | 4.34 m | party_wall_length_m | + +## Walls +| Elmhurst field | Value | Notes | +|---|---|---| +| Main wall | Cavity / As Built | wall_construction 4; cert lodges measured U 0.184 (Elmhurst RdSAP default ~0.28) | +| Wall area (external) | 56.6 m² | Wall 1 "External" | +| "To corridor" wall | semi-exposed, 17.37 m², U 0.15 | Wall 3 (type 3). Heat loss to unheated corridor — key as an alternative/sheltered wall if Elmhurst allows; else note divergence. | +| Party wall | Present, **U Unable to determine** | Wall 2 (type 4) area 11.28; party_walls_w_per_k 2.82 | + +## Roofs +| Elmhurst field | Value | Notes | +|---|---|---| +| Roof type | **A Another dwelling above** | ground-floor flat → roof_w_per_k = 0 | + +## Floors +| Elmhurst field | Value | Notes | +|---|---|---| +| Location | Ground floor | floor_type 2 | +| Type / insulation | Solid / As built | cert lodges measured U 0.12 (Elmhurst RdSAP ~0.23) | + +## Openings +| Elmhurst field | Value | Notes | +|---|---|---| +| Windows (combined) | ~24.9 m², East, Double post-2022 | 7 synth windows; U 1.4 / g 0.72 / frame 0.7 | +| Doors | 1 insulated, U 1.6 | door_count 1 | + +## Ventilation & Lighting +| Elmhurst field | Value | Notes | +|---|---|---| +| Ventilation | Natural + 2 intermittent extract fans | extract_fans_count 2 | +| Air Pressure Test | Blower Door, **3.45** (+ cert number) | air_permeability_ap50 3.45 | +| Sheltered sides | 2 | sheltered_sides 2 | +| Lighting | 100% low-energy | 10 bulbs, all low-energy | + +## Space Heating +| Elmhurst field | Value | Notes | +|---|---|---| +| Main heating | Mains gas condensing **combi** (generic BGW 84% substitute) | fuel 1, emitter radiators, control 2110, fan-assisted balanced flue, PCDB 17644 (88.5% — can't enter) | +| Secondary | None | — | + +## Water Heating +| Elmhurst field | Value | Notes | +|---|---|---| +| Water heating | From main (combi), gas | code 901; no cylinder | +| Solar / WWHRS / FGHRS | None | — | + +## Fields to clear in Elmhurst (do NOT map) +| Elmhurst field | Set to | Why absent | +|---|---|---| +| Property Description · extensions / room-in-roof | blank | none | +| Dimensions · 1st-floor row | clear | single-storey flat (was 2-storey house) | +| Conservatory | none / uncheck | not lodged (CLEAR — prior-cert leftover risk) | +| Space Heating · Main Heating 2 / Secondary | none | single main system | +| Water Heating · cylinder | none | combi | +| New Tech · PV / Wind / Hydro | none | none lodged | diff --git a/backend/epc_api/json_samples/real_life_examples/SAP-Schema-17.1/uprn_10093116529/elmhurst_summary.pdf b/backend/epc_api/json_samples/real_life_examples/SAP-Schema-17.1/uprn_10093116529/elmhurst_summary.pdf new file mode 100644 index 00000000..fbf7d74d Binary files /dev/null and b/backend/epc_api/json_samples/real_life_examples/SAP-Schema-17.1/uprn_10093116529/elmhurst_summary.pdf differ diff --git a/backend/epc_api/json_samples/real_life_examples/SAP-Schema-17.1/uprn_10093116529/elmhurst_worksheet.pdf b/backend/epc_api/json_samples/real_life_examples/SAP-Schema-17.1/uprn_10093116529/elmhurst_worksheet.pdf new file mode 100644 index 00000000..dbfd34b9 Binary files /dev/null and b/backend/epc_api/json_samples/real_life_examples/SAP-Schema-17.1/uprn_10093116529/elmhurst_worksheet.pdf differ diff --git a/backend/epc_api/json_samples/real_life_examples/SAP-Schema-17.1/uprn_10093116529/epc.json b/backend/epc_api/json_samples/real_life_examples/SAP-Schema-17.1/uprn_10093116529/epc.json new file mode 100644 index 00000000..bd641281 --- /dev/null +++ b/backend/epc_api/json_samples/real_life_examples/SAP-Schema-17.1/uprn_10093116529/epc.json @@ -0,0 +1,390 @@ +{ + "uprn": 10093116529, + "roofs": [ + { + "description": "(other premises above)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "walls": [ + { + "description": "Average thermal transmittance 0.17 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", + "tenure": "ND", + "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": "PO10 8BG", + "data_type": 2, + "hot_water": { + "description": "From main system, flue gas heat recovery", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "post_town": "EMSWORTH", + "built_form": 2, + "created_at": "2018-06-04 10:56:20", + "living_area": 21.77, + "orientation": 0, + "region_code": 16, + "report_type": 3, + "sap_heating": { + "thermal_store": 1, + "water_fuel_type": 1, + "water_heating_code": 901, + "main_heating_details": [ + { + "has_fghrs": "true", + "main_fuel_type": 1, + "heat_emitter_type": 1, + "fghrs_index_number": 60031, + "emitter_temperature": 1, + "is_flue_fan_present": "true", + "main_heating_number": 1, + "main_heating_control": 2110, + "is_interlocked_system": "true", + "main_heating_category": 2, + "main_heating_fraction": 1, + "main_heating_flue_type": 2, + "central_heating_pump_age": 2, + "main_heating_data_source": 1, + "main_heating_index_number": 17644, + "has_separate_delayed_start": "true", + "load_or_weather_compensation": 0, + "is_central_heating_pump_in_heated_space": "true" + } + ], + "has_hot_water_cylinder": "false", + "has_fixed_air_conditioning": "false", + "secondary_heating_category": 1 + }, + "sap_version": 9.92, + "schema_type": "SAP-Schema-17.1", + "uprn_source": "Energy Assessor", + "country_code": "ENG", + "main_heating": [ + { + "description": "Boiler and radiators, mains gas", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "air_tightness": { + "description": "Air permeability 3.5 m\u00b3/h.m\u00b2 (as tested)", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "dwelling_type": "Ground-floor flat", + "language_code": 1, + "property_type": 2, + "address_line_1": "6, Woodfield Park Road", + "assessment_date": "2018-06-01", + "assessment_type": "SAP", + "completion_date": "2018-06-04", + "inspection_date": "2018-06-01", + "sap_ventilation": { + "psv_count": 0, + "pressure_test": 1, + "air_permeability": 3.45, + "open_flues_count": 0, + "ventilation_type": 1, + "extract_fans_count": 2, + "open_fireplaces_count": 0, + "sheltered_sides_count": 2, + "flueless_gas_fires_count": 0 + }, + "design_water_use": 1, + "sap_data_version": 9.92, + "sap_flat_details": { + "level": 1 + }, + "total_floor_area": 49, + "transaction_type": 6, + "conservatory_type": 1, + "registration_date": "2018-06-04", + "sap_energy_source": { + "electricity_tariff": 1, + "wind_turbines_count": 0, + "wind_turbine_terrain_type": 2, + "fixed_lighting_outlets_count": 10, + "low_energy_fixed_lighting_outlets_count": 10, + "low_energy_fixed_lighting_outlets_percentage": 100 + }, + "sap_opening_types": [ + { + "name": 1, + "type": 4, + "u_value": 1.4, + "data_source": 2, + "description": "Window", + "frame_factor": 0.7, + "glazing_type": 4, + "solar_transmittance": 0.72 + }, + { + "name": 8, + "type": 1, + "u_value": 1.6, + "data_source": 2, + "description": "Front Door", + "glazing_type": 1 + } + ], + "secondary_heating": { + "description": "None", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "sap_building_parts": [ + { + "sap_walls": [ + { + "name": "Wall 1", + "u_value": 0.184, + "wall_type": 2, + "description": "External", + "total_wall_area": 56.6, + "is_curtain_walling": "false" + }, + { + "name": "Wall 3", + "u_value": 0.15, + "wall_type": 3, + "description": "To corridor", + "total_wall_area": 17.37, + "is_curtain_walling": "false" + }, + { + "name": "Wall 2", + "u_value": 0, + "wall_type": 4, + "description": "To flat", + "total_wall_area": 11.28 + } + ], + "identifier": "Main Dwelling", + "overshading": 2, + "sap_openings": [ + { + "name": 1, + "type": 1, + "width": 0.72, + "height": 2.5, + "location": "Wall 1", + "orientation": 3 + }, + { + "name": 2, + "type": 1, + "width": 1.6, + "height": 2.5, + "location": "Wall 1", + "orientation": 3 + }, + { + "name": 3, + "type": 1, + "width": 1, + "height": 2.5, + "location": "Wall 1", + "orientation": 7 + }, + { + "name": 4, + "type": 1, + "width": 2.5, + "height": 2.5, + "location": "Wall 1", + "orientation": 7 + }, + { + "name": 5, + "type": 1, + "width": 1.15, + "height": 2.5, + "location": "Wall 1", + "orientation": 1 + }, + { + "name": 6, + "type": 1, + "width": 1.5, + "height": 2.5, + "location": "Wall 1", + "orientation": 1 + }, + { + "name": 7, + "type": 1, + "width": 1.5, + "height": 2.5, + "location": "Wall 1", + "orientation": 1 + }, + { + "name": 8, + "type": 8, + "width": 1, + "height": 2.1, + "location": "Wall 3", + "orientation": 0 + } + ], + "construction_year": 2017, + "sap_thermal_bridges": { + "thermal_bridges": [ + { + "length": 10.97, + "psi_value": 0.3, + "psi_value_source": 2, + "thermal_bridge_type": "E2" + }, + { + "length": 9.97, + "psi_value": 0.04, + "psi_value_source": 2, + "thermal_bridge_type": "E3" + }, + { + "length": 39.2, + "psi_value": 0.05, + "psi_value_source": 2, + "thermal_bridge_type": "E4" + }, + { + "length": 21.96, + "psi_value": 0.16, + "psi_value_source": 2, + "thermal_bridge_type": "E5" + }, + { + "length": 21.96, + "psi_value": 0.07, + "psi_value_source": 2, + "thermal_bridge_type": "E7" + }, + { + "length": 10.4, + "psi_value": 0.09, + "psi_value_source": 2, + "thermal_bridge_type": "E16" + }, + { + "length": 2.6, + "psi_value": -0.09, + "psi_value_source": 2, + "thermal_bridge_type": "E17" + }, + { + "length": 2.4, + "psi_value": 0.06, + "psi_value_source": 2, + "thermal_bridge_type": "E18" + }, + { + "length": 6.68, + "psi_value": 0.16, + "psi_value_source": 4, + "thermal_bridge_type": "P1" + }, + { + "length": 6.68, + "psi_value": 0, + "psi_value_source": 4, + "thermal_bridge_type": "P3" + } + ], + "thermal_bridge_code": 5 + }, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "storey": 0, + "u_value": 0.12, + "floor_type": 2, + "description": "Floor 1", + "storey_height": 2.6, + "heat_loss_area": 49.03, + "total_floor_area": 49.03 + } + ], + "thermal_mass_parameter": 100 + } + ], + "heating_cost_current": { + "value": 185, + "currency": "GBP" + }, + "co2_emissions_current": 0.9, + "energy_rating_average": 60, + "energy_rating_current": 81, + "lighting_cost_current": { + "value": 37, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Time and temperature zone control", + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + } + ], + "has_hot_water_cylinder": "false", + "heating_cost_potential": { + "value": 185, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 58, + "currency": "GBP" + }, + "co2_emissions_potential": 0.9, + "energy_rating_potential": 81, + "lighting_cost_potential": { + "value": 37, + "currency": "GBP" + }, + "schema_version_original": "LIG-17.0", + "hot_water_cost_potential": { + "value": 58, + "currency": "GBP" + }, + "is_in_smoke_control_area": "unknown", + "renewable_heat_incentive": { + "rhi_new_dwelling": { + "space_heating": 1927, + "water_heating": 1458 + } + }, + "seller_commission_report": "Y", + "energy_consumption_current": 108, + "has_fixed_air_conditioning": "false", + "multiple_glazed_percentage": 100, + "calculation_software_version": 6.2, + "energy_consumption_potential": 108, + "environmental_impact_current": 85, + "current_energy_efficiency_band": "B", + "environmental_impact_potential": 85, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "B", + "co2_emissions_current_per_floor_area": 19 +} \ No newline at end of file diff --git a/backend/epc_api/json_samples/real_life_examples/SAP-Schema-17.1/uprn_10093116543/elmhurst_summary.pdf b/backend/epc_api/json_samples/real_life_examples/SAP-Schema-17.1/uprn_10093116543/elmhurst_summary.pdf index 43a7cb98..53607451 100644 Binary files a/backend/epc_api/json_samples/real_life_examples/SAP-Schema-17.1/uprn_10093116543/elmhurst_summary.pdf and b/backend/epc_api/json_samples/real_life_examples/SAP-Schema-17.1/uprn_10093116543/elmhurst_summary.pdf differ diff --git a/backend/epc_api/json_samples/real_life_examples/SAP-Schema-17.1/uprn_10093116543/elmhurst_worksheet.pdf b/backend/epc_api/json_samples/real_life_examples/SAP-Schema-17.1/uprn_10093116543/elmhurst_worksheet.pdf index 3de2ee71..45fef3ca 100644 Binary files a/backend/epc_api/json_samples/real_life_examples/SAP-Schema-17.1/uprn_10093116543/elmhurst_worksheet.pdf and b/backend/epc_api/json_samples/real_life_examples/SAP-Schema-17.1/uprn_10093116543/elmhurst_worksheet.pdf differ diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index e4170d27..7028274c 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -1,3 +1,4 @@ +import copy import re from dataclasses import replace from datetime import date @@ -2552,6 +2553,19 @@ class EpcPropertyDataMapper: return EpcPropertyDataMapper.from_sap_schema_17_1( from_dict(SapSchema17_1, data) ) + if schema == "SAP-Schema-16.2": + # SAP-Schema-16.2 is structurally an RdSAP-17.1 cert (reduced fields, + # glazed_area band, construction-code building parts) under a different + # name + a handful of renamed/omitted fields — normalise it onto the + # RdSAP-17.1 shape and reuse that tested mapper. See + # `_normalize_sap_schema_16_2`. + from datatypes.epc.schema.rdsap_schema_17_1 import RdSapSchema17_1 + + return _clear_basement_flag_when_system_built( + EpcPropertyDataMapper.from_rdsap_schema_17_1( + from_dict(RdSapSchema17_1, _normalize_sap_schema_16_2(data)) + ) + ) raise ValueError(f"Unsupported EPC schema: {schema!r}") @@ -3008,6 +3022,81 @@ def _default_missing_post_town(data: Dict[str, Any]) -> Dict[str, Any]: return {**data, "post_town": ""} +def _normalize_sap_schema_16_2(data: Dict[str, Any]) -> Dict[str, Any]: + """Rewrite a `SAP-Schema-16.2` API doc onto the `RdSAP-Schema-17.1` shape so + it can reuse the tested `from_rdsap_schema_17_1` mapper. + + Despite the "SAP-Schema" name, 16.2 is structurally an RdSAP cert (reduced + fields: a `glazed_area` *band* not measured openings, construction-code + building parts, `main_gas`/`meter_type` energy source, a PCDB boiler index). + It differs from RdSAP-17.1 only by a handful of field names plus three + fields it omits: + * top-level `windows` (list) → `window` (first element); + `schema_version` → `schema_version_original` + * sap_heating `wwhrs` → `instantaneous_wwhrs` (identical shape); + `immersion_heating_type` defaulted (16.2 omits it — gas cert) + * main_heating_details[] `boiler_index_number` → `main_heating_index_number` + (preserves PCDB efficiency); `emitter_temperature` defaulted + * sap_floor_dimensions[] `party_wall_length` defaulted to 0 (16.2 does not + lodge it — note: party-wall heat loss is then unmodelled) + * sap_energy_source `main_gas` → `mains_gas` + + Mirrors `_normalize_shower_outlets` — mutates a deep copy, never the + caller's dict. Defaults use setdefault so a 16.2 cert that *does* carry the + RdSAP-17.1 name is left untouched. + """ + d: Dict[str, Any] = copy.deepcopy(data) + + def _dicts(value: Any) -> List[Dict[str, Any]]: + """The dict elements of `value` when it is a list, else [].""" + if not isinstance(value, list): + return [] + items: List[Any] = cast(List[Any], value) + return [cast(Dict[str, Any], x) for x in items if isinstance(x, dict)] + + windows: Any = d.get("windows") + if isinstance(windows, list) and windows: + window_list: List[Any] = cast(List[Any], windows) + d.setdefault("window", window_list[0]) + d.setdefault("schema_version_original", d.get("schema_version", "")) + + # 16.2 lodges glazing in BOTH `multiple_glazing_type` (frequently the "ND" + # not-defined sentinel) AND the windows[].description. When the numeric field + # is undefined, honour an explicit "Single glazed" description so it is not + # defaulted to double — RdSAP-21 code 5 = single glazing (cascade single + # slot, U≈4.8). Otherwise keep the ND→double-modal default the cascade uses. + glazing_type: Any = d.get("multiple_glazing_type") + if not isinstance(glazing_type, int): + window: Any = d.get("window") + description = "" + if isinstance(window, dict): + description = str(cast(Dict[str, Any], window).get("description") or "").lower() + if "single" in description: + d["multiple_glazing_type"] = 5 + + sap_heating: Any = d.get("sap_heating") + if isinstance(sap_heating, dict): + heating: Dict[str, Any] = cast(Dict[str, Any], sap_heating) + if "wwhrs" in heating: + heating.setdefault("instantaneous_wwhrs", heating["wwhrs"]) + heating.setdefault("immersion_heating_type", None) + for mh in _dicts(heating.get("main_heating_details")): + if "boiler_index_number" in mh: + mh.setdefault("main_heating_index_number", mh["boiler_index_number"]) + mh.setdefault("emitter_temperature", None) + + for bp in _dicts(d.get("sap_building_parts")): + for fd in _dicts(bp.get("sap_floor_dimensions")): + fd.setdefault("party_wall_length", 0) + + energy_source: Any = d.get("sap_energy_source") + if isinstance(energy_source, dict): + source: Dict[str, Any] = cast(Dict[str, Any], energy_source) + if "main_gas" in source: + source.setdefault("mains_gas", source["main_gas"]) + return d + + def _count_shower_outlets_by_type( schema_shower_outlets: Any, target_type: int, diff --git a/datatypes/epc/domain/tests/test_from_sap_schema.py b/datatypes/epc/domain/tests/test_from_sap_schema.py index f9351340..8f5d3ab1 100644 --- a/datatypes/epc/domain/tests/test_from_sap_schema.py +++ b/datatypes/epc/domain/tests/test_from_sap_schema.py @@ -376,3 +376,54 @@ class TestFromSapSchema17_1Perimeter: schema = from_dict(SapSchema17_1, data) with pytest.raises(UnmappedApiCode): EpcPropertyDataMapper.from_sap_schema_17_1(schema) + + +class TestFromSapSchema16_2: + """SAP-Schema-16.2 — structurally an RdSAP-17.1 cert under a different name + + a few renamed/omitted fields. `from_api_response` normalises it onto the + RdSAP-17.1 shape (`_normalize_sap_schema_16_2`) and reuses that mapper. + Regression for uprn_100020933699 (cert 9548-…-0950, End-terrace house).""" + + def test_16_2_dispatches_and_maps_via_rdsap_17_1(self) -> None: + # Arrange / Act — the raw 16.2 doc must dispatch (no "Unsupported + # schema") and map all the renamed seams: windows→window, + # main_gas→mains_gas, boiler_index_number→main_heating_index_number, + # wwhrs→instantaneous_wwhrs. + epc = EpcPropertyDataMapper.from_api_response(load("sap_16_2.json")) + + # Assert — identity + the seams that would silently drop on a bad rename. + assert isinstance(epc, EpcPropertyData) + assert epc.uprn == 100020933699 + assert epc.total_floor_area_m2 == 62.0 + assert epc.dwelling_type == "End-terrace house" + mh = epc.sap_heating.main_heating_details[0] + assert mh.main_fuel_type == 26 # mains gas + # boiler_index_number must survive as the PCDB index (efficiency source). + assert mh.main_heating_index_number == 10321 + + def test_16_2_produces_a_sap_score(self) -> None: + # The mapped cert must run end-to-end through the SAP-10 engine. + from domain.sap10_calculator.calculator import Sap10Calculator + + epc = EpcPropertyDataMapper.from_api_response(load("sap_16_2.json")) + result = Sap10Calculator().calculate(epc) + # Lodged 70; engine produces 71 (Elmhurst validation still pending). + assert result.sap_score == 71 + + def test_16_2_single_glazed_description_honoured_over_nd(self) -> None: + # 16.2 lodges multiple_glazing_type "ND" but a "Single glazed" windows + # description; the normaliser must route to single glazing (cascade slot + # 1, U≈4.8) not the ND→double default — else single-glazed older certs + # over-rate. Regression for uprn_100020933699. + epc = EpcPropertyDataMapper.from_api_response(load("sap_16_2.json")) + assert epc.sap_windows, "expected synthesised windows" + assert epc.sap_windows[0].glazing_type == 1 # cascade single-glazing slot + + def test_16_2_normalizer_does_not_mutate_caller_dict(self) -> None: + # Mirror _normalize_shower_outlets' contract: the caller's dict is + # untouched (deep copy), so a re-dispatch sees the original shape. + data = load("sap_16_2.json") + before_keys = set(data.keys()) + EpcPropertyDataMapper.from_api_response(data) + assert "window" not in data # the rename landed only on the copy + assert set(data.keys()) == before_keys diff --git a/datatypes/epc/schema/tests/fixtures/sap_16_2.json b/datatypes/epc/schema/tests/fixtures/sap_16_2.json new file mode 100644 index 00000000..b8193954 --- /dev/null +++ b/datatypes/epc/schema/tests/fixtures/sap_16_2.json @@ -0,0 +1,310 @@ +{ + "uprn": 100020933699, + "roofs": [ + { + "description": "Pitched, 100 mm loft insulation", + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + } + ], + "walls": [ + { + "description": "Cavity wall, filled cavity", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": "Solid, no insulation (assumed)", + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 2, + "windows": [ + { + "description": "Single glazed", + "energy_efficiency_rating": 1, + "environmental_efficiency_rating": 1 + } + ], + "lighting": { + "description": "Low energy lighting in 22% of fixed outlets", + "energy_efficiency_rating": 2, + "environmental_efficiency_rating": 2 + }, + "postcode": "SE18 2PE", + "hot_water": { + "description": "From main system", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "LONDON", + "built_form": 3, + "created_at": "2013-01-14 15:52:33.000000", + "door_count": 2, + "glazed_area": 1, + "region_code": 17, + "report_type": 2, + "sap_heating": { + "wwhrs": { + "rooms_with_bath_and_or_shower": 1, + "rooms_with_mixer_shower_no_bath": 0, + "rooms_with_bath_and_mixer_shower": 0 + }, + "cylinder_size": 2, + "water_heating_code": 901, + "water_heating_fuel": 26, + "cylinder_thermostat": "Y", + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "fan_flue_present": "Y", + "heat_emitter_type": 1, + "boiler_index_number": 10321, + "main_heating_number": 1, + "main_heating_control": 2106, + "main_heating_category": 2, + "main_heating_fraction": 1, + "main_heating_data_source": 1 + } + ], + "cylinder_insulation_type": 1, + "has_fixed_air_conditioning": "false", + "cylinder_insulation_thickness": 25 + }, + "sap_version": 9.91, + "schema_type": "SAP-Schema-16.2", + "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": "End-terrace house", + "language_code": 1, + "property_type": 0, + "address_line_1": "1, Acland Close", + "schema_version": "LIG-16.1", + "assessment_type": "RdSAP", + "completion_date": "2013-01-14", + "inspection_date": "2013-01-14", + "extensions_count": 0, + "measurement_type": 1, + "total_floor_area": 62, + "transaction_type": 8, + "conservatory_type": 1, + "heated_room_count": 4, + "registration_date": "2013-01-14", + "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", + "wall_dry_lined": "N", + "wall_thickness": 280, + "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": 1, + "total_floor_area": 31.06, + "floor_construction": 1, + "heat_loss_perimeter": 15.8 + }, + { + "floor": 1, + "room_height": 2.4, + "total_floor_area": 31.06, + "heat_loss_perimeter": 15.8 + } + ], + "wall_insulation_type": 2, + "construction_age_band": "G", + "wall_thickness_measured": "Y", + "roof_insulation_location": 2, + "roof_insulation_thickness": "100mm" + } + ], + "low_energy_lighting": 22, + "solar_water_heating": "N", + "bedf_revision_number": 333, + "habitable_room_count": 4, + "heating_cost_current": { + "value": 370, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 2.2, + "energy_rating_average": 60, + "energy_rating_current": 70, + "lighting_cost_current": { + "value": 66, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": "Programmer, room thermostat and TRVs", + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "multiple_glazing_type": "ND", + "open_fireplaces_count": 0, + "has_hot_water_cylinder": "true", + "heating_cost_potential": { + "value": 296, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 106, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 27, + "currency": "GBP" + }, + "indicative_cost": "\u00a3800 - \u00a31,200", + "improvement_type": "W", + "improvement_details": { + "improvement_number": 47 + }, + "improvement_category": 5, + "energy_performance_rating": 71, + "environmental_impact_rating": 73 + }, + { + "sequence": 2, + "typical_saving": { + "value": 26, + "currency": "GBP" + }, + "indicative_cost": "\u00a335", + "improvement_type": "E", + "improvement_details": { + "improvement_number": 35 + }, + "improvement_category": 5, + "energy_performance_rating": 72, + "environmental_impact_rating": 74 + }, + { + "sequence": 3, + "typical_saving": { + "value": 33, + "currency": "GBP" + }, + "indicative_cost": "\u00a34,000 - \u00a36,000", + "improvement_type": "N", + "improvement_details": { + "improvement_number": 19 + }, + "improvement_category": 5, + "energy_performance_rating": 74, + "environmental_impact_rating": 76 + }, + { + "sequence": 4, + "typical_saving": { + "value": 52, + "currency": "GBP" + }, + "indicative_cost": "\u00a33,300 - \u00a36,500", + "improvement_type": "O", + "improvement_details": { + "improvement_number": 8 + }, + "improvement_category": 5, + "energy_performance_rating": 77, + "environmental_impact_rating": 80 + }, + { + "sequence": 5, + "typical_saving": { + "value": 236, + "currency": "GBP" + }, + "indicative_cost": "\u00a39,000 - \u00a314,000", + "improvement_type": "U", + "improvement_details": { + "improvement_number": 34 + }, + "improvement_category": 5, + "energy_performance_rating": 89, + "environmental_impact_rating": 92 + }, + { + "sequence": 6, + "typical_saving": { + "value": 19, + "currency": "GBP" + }, + "indicative_cost": "\u00a31,500 - \u00a34,000", + "improvement_type": "V", + "improvement_details": { + "improvement_number": 44 + }, + "improvement_category": 5, + "energy_performance_rating": 90, + "environmental_impact_rating": 92 + } + ], + "co2_emissions_potential": 0.5, + "energy_rating_potential": 90, + "lighting_cost_potential": { + "value": 37, + "currency": "GBP" + }, + "hot_water_cost_potential": { + "value": 72, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 2577, + "impact_of_loft_insulation": -309, + "space_heating_existing_dwelling": 5864 + }, + "seller_commission_report": "Y", + "energy_consumption_current": 189, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 0, + "calculation_software_version": 8.0, + "energy_consumption_potential": 36, + "environmental_impact_current": 71, + "fixed_lighting_outlets_count": 9, + "current_energy_efficiency_band": "C", + "environmental_impact_potential": 92, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "B", + "co2_emissions_current_per_floor_area": 36, + "low_energy_fixed_lighting_outlets_count": 2 +} \ No newline at end of file diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 9f603d04..a10b1841 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -231,6 +231,20 @@ def _is_flat_or_maisonette(property_type: Optional[str]) -> bool: return property_type in _PROPERTY_TYPES_FLAT_OR_MAISONETTE +def _is_flat_or_maisonette_dwelling(dwelling_type: Optional[str]) -> bool: + """Fallback flat/maisonette detection from `epc.dwelling_type` for certs + where `property_type` is absent. Full-SAP / SAP-Schema certs carry the + flatness only in dwelling_type (e.g. "Ground-floor flat", "Mid-floor + maisonette"), so without this the RdSAP 10 Table 15 footnote * party-wall + U=0 default never fires for them and they wrongly take the 0.25 house + default. Houses/bungalows ("Detached house", "Mid-Terrace house") don't + contain these tokens, so they stay on the house default.""" + if dwelling_type is None: + return False + dt = dwelling_type.lower() + return "flat" in dt or "maisonette" in dt + + @dataclass(frozen=True) class HeatTransmission: """SAP 10.2 §3 conduction HLC broken down per element type, summed @@ -1029,7 +1043,10 @@ def heat_transmission_from_cert( # branch (they're houses for party-wall purposes per the spec). upw = u_party_wall( party_wall_construction=party_construction, - is_flat=_is_flat_or_maisonette(epc.property_type), + is_flat=( + _is_flat_or_maisonette(epc.property_type) + or _is_flat_or_maisonette_dwelling(epc.dwelling_type) + ), ) # Per-bp `y` for backwards compat: when the bp's own age band # differs from the dwelling's primary, the cascade applies the diff --git a/tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py b/tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py index 7a22c28f..f39e0168 100644 --- a/tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py +++ b/tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py @@ -129,6 +129,62 @@ _EXPECTATIONS: Final[tuple[RealCertExpectation, ...]] = ( cert_num="8000-8495-2839-2607-9683", sap_score=82, ), + # UPRN 10093116543 → cert 8358-7436-5620-6889-0906. SAP-Schema-17.1 — a + # FULL-SAP cert (2017 mains-gas COMBI semi, Emsworth), forced through the + # RdSAP SAP-10 engine. Lodged 82; engine produces 81. Built in Elmhurst + # RdSAP10 on the lodged inputs (evidence saved: elmhurst_summary.pdf / + # elmhurst_worksheet.pdf): Elmhurst worksheet SAP 77. The +4 (81 vs 77) is + # the inherent full-SAP-lodged-value vs RdSAP-default methodology gap — the + # engine faithfully uses the cert's MEASURED full-SAP data that Elmhurst + # RdSAP structurally cannot: + # • Ground floor: cert lodges measured U=0.11 (raw floor u_value 0.11, + # "Average thermal transmittance 0.11 W/m²K"); engine uses 0.11, Elmhurst + # RdSAP solid-floor default = 0.23 (~1.5 SAP). Elmhurst's U-value-known + # override can't be set via the entry tool. + # • Boiler: cert lodges PCDB index 17644 (88.5%); engine uses 88.5%, + # Elmhurst's PCDB/SEDBUK boiler search is disabled in this UI state so the + # only automatable combi is the generic SAP Table 4b "BGW condensing + # combi" = 84% (~1 SAP). The 89% cascade option is a *regular* boiler + # (needs a cylinder) — not valid for this combi/no-cylinder dwelling. + # • Remainder: roof band-L 0.16 vs engine band-M 0.15, infiltration / + # sheltered-sides 1-vs-2 (~0.5). + # Calculator confirmed faithful on the prior sibling cert (engine on + # Elmhurst's own parsed inputs ≈ worksheet). A leftover conservatory from the + # reused assessment was cleared during the build (worksheet 73→77). PINNED TO + # THE OBSERVED 81, not lodged 82 — mapping deliberately untuned; the Elmhurst + # delta is the documented full-SAP→RdSAP residual, NOT a mapper bug. + RealCertExpectation( + schema="SAP-Schema-17.1", + sample="uprn_10093116543", + cert_num="8358-7436-5620-6889-0906", + sap_score=81, + ), + # UPRN 10093116529 → cert 8178-7436-5600-9809-0906. SAP-Schema-17.1 — a + # FULL-SAP cert (2017 mains-gas combi GROUND-FLOOR FLAT, Emsworth, TFA 49 m²), + # forced through the RdSAP SAP-10 engine. Lodged 81; engine produces 81. Built + # in Elmhurst RdSAP10 on the lodged inputs (evidence saved: elmhurst_summary.pdf + # / elmhurst_worksheet.pdf): Elmhurst worksheet SAP 78. Calculator confirmed + # faithful — fed Elmhurst's own U-values the engine reproduces its HTC to ~0.6 + # W/K (93.4 vs ~94). The +3 (81 vs 78) is the full-SAP→RdSAP methodology gap: + # cert lodges measured U (wall 0.184, floor 0.12), engine uses them, Elmhurst + # RdSAP forces band-L defaults; plus the generic 84% combi vs the cert's PCDB + # 17644 (88.5%). The "to corridor" wall (17.37 m², U 0.15) is already inside the + # heat-loss perimeter (28.45 m × 2.6 = 73.97 m² external), so Elmhurst derives + # it as part of the main cavity wall. + # This cert surfaced + drove the FLAT PARTY-WALL FIX (heat_transmission.py + # `_is_flat_or_maisonette_dwelling`): full-SAP flats carry their flatness in + # `dwelling_type` not `property_type`, so the party-wall default was wrongly + # the 0.25 house value instead of the RdSAP Table 15 footnote-* flat 0.0. The + # cert lodges party `u_value: 0` and Elmhurst's worksheet uses 0.0; the fix + # lifts this flat 80→81 (and matches the lodged/Elmhurst party wall). + # PINNED TO THE OBSERVED 81, not lodged 81-coincidence — mapping untuned; the + # Elmhurst delta is the documented full-SAP residual, not a bug. + RealCertExpectation( + schema="SAP-Schema-17.1", + sample="uprn_10093116529", + cert_num="8178-7436-5600-9809-0906", + sap_score=81, + ), # UPRN 10002468137 → cert 0215-2818-7357-9703-2145. RdSAP-Schema-17.1, # all-electric high-heat-retention storage heaters on Economy 7, solid- # brick uninsulated end-terrace. Validated against Elmhurst RdSAP10 on diff --git a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py index d1ed0c92..c7773d63 100644 --- a/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py +++ b/tests/domain/sap10_calculator/worksheet/test_heat_transmission.py @@ -1150,6 +1150,46 @@ def test_ground_floor_flat_exposure_keeps_floor_drops_roof() -> None: assert ground.roof_w_per_k == 0.0 +def test_full_sap_flat_party_wall_uses_flat_zero_default_via_dwelling_type() -> None: + # Arrange — a full-SAP / SAP-Schema cert carries its flatness in + # `dwelling_type` ("Ground-floor flat"), NOT `property_type` (which is + # None). The party-wall default must still fire the RdSAP 10 Table 15 + # footnote * flat value U=0.0 (party wall between two heated dwellings is + # not a heat-loss element), not the 0.25 house default. Regression for + # uprn_10093116529: without the dwelling_type fallback the engine charged + # 2.82 W/K of phantom party-wall loss (cert lodged party u_value 0; + # Elmhurst's worksheet uses 0.0). `party_wall_construction=None` = + # "unknown" → the is_flat-dependent default branch. + def _build(dwelling_type: str) -> HeatTransmission: + main = make_building_part( + identifier="Main Dwelling", + construction_age_band="L", + wall_construction=4, wall_insulation_type=4, + party_wall_construction=None, roof_construction=4, + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=49.0, room_height_m=2.6, + party_wall_length_m=5.0, heat_loss_perimeter_m=28.0, floor=0, + ), + ], + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=49.0, country_code="ENG", + dwelling_type=dwelling_type, property_type=None, + sap_building_parts=[main], + ) + return heat_transmission_from_cert(epc) + + # Act + flat = _build("Ground-floor flat") + house = _build("Mid-terrace house") + + # Assert — flat party wall is a non-heat-loss element (U=0); the same + # geometry as a house takes the 0.25 unknown-house default (> 0). + assert flat.party_walls_w_per_k == 0.0 + assert house.party_walls_w_per_k > 0.0 + + def test_floor_over_another_dwelling_below_zeroes_floor_despite_exposed_flag() -> None: # Arrange — a "Ground-floor flat" lodged with floor_heat_loss=6 # ("another dwelling below") sits over a heated dwelling (e.g. a