diff --git a/backend/documents_parser/tests/test_pashub_sap_accuracy_lrha_wave3.py b/backend/documents_parser/tests/test_pashub_sap_accuracy_lrha_wave3.py index b6aaa1df7..c8ccc34e4 100644 --- a/backend/documents_parser/tests/test_pashub_sap_accuracy_lrha_wave3.py +++ b/backend/documents_parser/tests/test_pashub_sap_accuracy_lrha_wave3.py @@ -109,13 +109,18 @@ _MANIFEST_PATH = _FIXTURES_DIR / "manifest.json" # holds `main_heating_2` + `main_heating_1_percent`, the extractor slices per # system, and the mapper emits two MainHeatingDetails with the fraction → 5 of 6 # converge (within-0.5 53.4% → 55.3%, MAE 0.799). The 6th (497655371974, -# storage+panel) regresses because the calculator bills an electric Main 2 at -# Main 1's rate (deferred §10a slice) — the correct mapping exposes that gap. -# Treat the constants as a regression tripwire, NOT a cohort accuracy figure; -# re-baseline (coverage growth is not loosening) as further mapper fixes unblock -# fixtures. +# storage+panel) still missed because the calculator billed an electric Main 2 +# at Main 1's rate (deferred §10a slice) — the correct mapping exposed that gap. +# 2026-07-27 (#1684): the calculator now bills an electric Main 2 at its OWN +# Table 12a Grid 1 SH rate (not Main 1's), so the storage+panel Main 2's on-peak +# half is costed correctly: fixture 497655371974 +15.3 → -2.5. within-0.5 holds +# at 55.3% (still >0.5 vs PasHub's hybrid figure — genuine divergence, not a +# bug), MAE 0.799 → 0.675. Homogeneous off-peak cohorts (storage+storage) are a +# no-op. Treat the constants as a regression tripwire, NOT a cohort accuracy +# figure; re-baseline (coverage growth is not loosening) as further mapper fixes +# unblock fixtures. _MIN_WITHIN_HALF: float = 0.55 -_MAX_SAP_MAE: float = 0.80 +_MAX_SAP_MAE: float = 0.68 _KNOWN_GAP_REASON = ( "pashub `from_site_notes` mapper does not int-code a main-heating " @@ -187,11 +192,12 @@ def test_dual_main_heating_models_both_systems() -> None: MainHeatingDetail with the split (Main 2 fraction = 100 - Main 1 %) closes it to 0.0. - NB the sibling storage+PANEL dual-main (497655371974) is NOT pinned here: the - calculator deliberately bills an electric Main 2 at Main 1's rate - (`_main_2_space_heating_fuel_cost_gbp_per_kwh`, deferred §10a off-peak slice), - so its on-peak panel half is under-costed — a documented calculator gap the - correct two-main mapping now exposes, tracked separately.""" + NB the sibling storage+PANEL dual-main (497655371974) is NOT pinned here. + Its Main 2 on-peak panel half is now costed correctly (#1684: + `_main_2_space_heating_fuel_cost_gbp_per_kwh` bills an electric Main 2 at its + OWN Table 12a rate), moving it +15.3 → -2.5 vs PasHub. The residual is + PasHub-vs-Elmhurst divergence (>0.5), not a bug — a ground-truth Elmhurst + pin is tracked separately, so it is left out of the within-0.5 assertion.""" outcome = _evaluate("461029305556") assert outcome.diff is not None assert outcome.diff < 0.5, f"dual-main not modelled: diff {outcome.diff:.2f}" diff --git a/docs/HANDOVER_1684_ELMHURST_INPUTS_497655371974.md b/docs/HANDOVER_1684_ELMHURST_INPUTS_497655371974.md new file mode 100644 index 000000000..e8c2c165d --- /dev/null +++ b/docs/HANDOVER_1684_ELMHURST_INPUTS_497655371974.md @@ -0,0 +1,130 @@ +# Elmhurst RdSAP input sheet — fixture 497655371974 (4 Edmund Close) + +Purpose: reproduce LRHA WAVE 3 fixture **497655371974** in the Elmhurst RdSAP 10 +entry tool, download its **Input Summary + SAP Worksheets**, and give us the +**accredited SAP score** + the **space/water-heating fuel-cost worksheet lines**. +This anchors #1684 to accredited ground truth (per the A5 discipline: Elmhurst, +not PasHub's hybrid `pre_sap`), so we can pin it as a `RealCertExpectation` +instead of relying on the PasHub-relative residual. + +The #1684 fix is already shipped and green; this worksheet is the **confirmatory +ground-truth anchor**, not a blocker. + +## What this cert is (and why it matters for #1684) +A detached house with **two electric main heating systems on Economy 7**: +- **Main 1** — modern-slimline electric **storage** heaters (charged off-peak, + ~5.5 p/kWh low rate). +- **Main 2** — electric **direct-acting panel/convector** heaters (on-peak, + ~15.29 p/kWh high rate), 50% split. + +The bug (#1684) billed the on-peak panel Main 2 at Main 1's off-peak storage +rate → over-rated SAP by **+15.3 vs PasHub**. The fix bills Main 2 at its own +Table 12a rate → **SAP 40.5** (−2.5 vs PasHub 43). **The load-bearing worksheet +lines to check are the space-heating fuel-cost rows: Main 2's kWh must bill at +the HIGH (on-peak) rate, not Main 1's low rate.** + +## Our engine's output (to compare against Elmhurst) +| Quantity | Our engine | +|---|---| +| SAP score (continuous / band) | **40.49** (E) | +| Space heating demand | 9 166 kWh/yr | +| Main 1 (storage) fuel | 7 791 kWh/yr | +| Main 2 (panel) fuel | 3 896 kWh/yr | +| Hot water | 1 459 kWh/yr | +| CO₂ | 1 555 kg/yr | + +If Elmhurst lands materially away from ~40.5, that is the signal to dig — but +the direction (panel billed on-peak) is not in doubt (SAP 10.2 Table 12a Grid 1, +`OTHER_DIRECT_ACTING_ELECTRIC` = 100% high rate on a 7-hour tariff). + +--- + +## Page-by-page inputs + +### Property / general +- Address: **4 Edmund Close, Gainsborough, DN21 5RN** +- Property type: **House** · Built form: **Detached** +- Dwelling type: Detached house · Tenure: Rented (social) +- Transaction type: None of the above +- Inspection date: 2026-04-30 +- Age band: **F** (England & Wales 1976–1982) +- Storeys: **2** · Habitable rooms: **5** · Heated habitable rooms: 5 +- Total floor area: **78.3 m²** (ground 38.24 + first 38.24 + extension 1.82) +- Extensions: **1** +- Country: England + +### Dimensions +| Level | Storey height (m) | Floor area (m²) | Heat-loss perimeter (m) | +|---|---|---|---| +| Ground (main) | 2.62 | 38.24 | 25.64 | +| First (main) | 2.30 | 38.24 | 25.64 | +| Extension (ground) | 2.30 | 1.82 | 4.02 | + +Party-wall length: 0 (detached). + +### Walls +- Construction: **Cavity** +- Insulation: **Filled cavity** (retrofit CWI) +- Thickness: **300 mm** (measured) +- Dry-lined: No + +### Roof +- Main: **Pitched, slates/tiles, access to loft** · Insulation at **joists**, + **250 mm** +- Extension: Pitched, slates/tiles, **no access** · Insulation: unknown + (RdSAP default for age band) + +### Floor +- **Solid** ground floor · Insulation: **as built** (none assumed) · U unknown + +### Windows / doors +- Glazing / low-E: RdSAP defaults for age band F (no override surveyed) +- Doors: **3** (all draught-proofed) · Insulated doors: 0 +- Draught-proofing: **100%** + +### Ventilation +- **Natural** ventilation · No pressure test +- **Draught lobby: present** +- Open chimneys: 0 · Blocked chimneys: **1** · Open/closed/boiler/other flues: 0 +- Extract fans: 0 · Passive vents: 0 · Flueless gas fires: 0 +- Sheltered sides: 0 + +### Lighting +- Low-energy fixed lighting: **11** bulbs (of 11 → 100% low-energy) + +### Electricity meter / tariff +- Meter type: **Dual (Economy 7 / 7-hour off-peak)** ← load-bearing for #1684 +- Gas connection: **No** (all-electric dwelling) +- PV / wind / battery: none · Export-capable: yes (no generation) + +### Main heating system 1 +- **Electric storage heaters — modern slimline** (SAP Table 4a **402**) +- Fuel: **Electricity** · Charge control: **Manual** (SAP control 2401) +- Fraction: **50%** + +### Main heating system 2 +- **Electric direct-acting — panel, convector or radiant heaters** + (SAP Table 4a **691**) +- Fuel: **Electricity (on-peak)** · Control: **Appliance thermostats** (2602) +- Fraction: **50%** + +### Secondary heating +- **None** + +### Water heating +- **Electric immersion** cylinder (SAP WHC **903**), fuel electricity +- Immersion: **single** · on the Economy-7 (off-peak) meter +- Cylinder size: **Small band ≈110 L** (RdSAP Table 28 code 2) +- Insulation: **factory-fitted foam, 50 mm** · Cylinder thermostat: **No** +- Electric shower ×1 · Bath ×1 · Mixer shower ×0 +- Solar water heating: No · WWHRS: None + +--- + +## What to send back +1. **SAP score** (continuous + band) from the Elmhurst Input Summary. +2. The **SAP Worksheet** pages covering the **§10a/§12 fuel-cost lines** — the + space-heating cost split showing Main 1 (storage, low rate) vs Main 2 (panel, + high rate), and the per-fuel p/kWh applied. +3. (Nice to have) the per-end-use kWh (space heating, water) to pin alongside the + score as `space_heating_kwh_per_yr` / `hot_water_kwh_per_yr`. diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index c464e9579..448c00c29 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -2883,24 +2883,25 @@ def _main_2_space_heating_fuel_cost_gbp_per_kwh( prices: PriceTable, ) -> float: """Main heating system 2's space-heating fuel rate (£/kWh) for the - legacy/off-peak scalar cost path. Keyed on main 2's OWN fuel via the same - `_space_heating_fuel_cost_gbp_per_kwh` logic (off-peak Table 12a split when - main 2 is electric, flat Table 32 rate otherwise) — so a wood-log or other - non-electric second main is not billed at main 1's electric rate. Returns - 0.0 when no second main is lodged (multiplied by main 2's 0 kWh). + legacy/off-peak scalar cost path. Keyed on main 2's OWN system via the same + `_space_heating_fuel_cost_gbp_per_kwh` logic Main 1 uses (off-peak Table 12a + Grid 1 SH high/low split when main 2 is electric, flat Table 32 rate + otherwise). Returns 0.0 when no second main is lodged (multiplied by main + 2's 0 kWh). - Scoped to a NON-electric second main (the unambiguous correction): a wood/ - oil/coal second main billed at main 1's rate is plainly wrong. An ELECTRIC - second main keeps main 1's space-heating scalar — its off-peak Table 12a - high/low split is the deferred §10a off-peak slice, and applying the split - per-system here regresses the off-peak electric cohort (certs 13 Parkers - Hill / 34 Dunley Road).""" + An ELECTRIC main 2 is billed at its OWN Table 12a Grid 1 SH row, not main + 1's rate (#1684). On a HOMOGENEOUS off-peak cohort (storage+storage, + direct+direct) main 2's own rate already equals main 1's, so this is a + no-op; it only corrects the HETEROGENEOUS case — e.g. off-peak STORAGE main + 1 (5.5 p/kWh low) + on-peak DIRECT-ACTING panel main 2 (15.29 p/kWh high), + where inheriting main 1's storage low rate under-costs the on-peak panel + half and over-rates SAP (LRHA WAVE 3 fixture 497655371974 read +15.3). This + keeps the scalar cost consistent with `_main_2_high_rate_fraction`, which + already resolves per-system.""" details = epc.sap_heating.main_heating_details if epc.sap_heating else None if not details or len(details) < 2: return 0.0 main_2 = details[1] - if _is_electric_main(main_2): - return _space_heating_fuel_cost_gbp_per_kwh(details[0], tariff, prices) return _space_heating_fuel_cost_gbp_per_kwh(main_2, tariff, prices) diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index 72e1ab6d2..95cd1e887 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -620,31 +620,81 @@ def test_off_peak_main_2_non_electric_priced_at_own_fuel() -> None: assert abs(rate - 0.0423) <= 1e-9 -def test_off_peak_main_2_electric_keeps_main_1_scalar() -> None: - # Arrange — both mains electric on an off-peak tariff. The electric - # second-main off-peak Table 12a split is the deferred §10a slice, so the - # helper keeps main 1's space-heating scalar (no per-system split here) to - # avoid regressing the off-peak electric cohort. +def test_off_peak_main_2_electric_billed_at_own_table_12a_rate() -> None: + # Arrange — HETEROGENEOUS electric mains on an off-peak (7-hour) tariff: + # Main 1 = modern-slimline STORAGE heaters (SAP 402, Table 12a + # OTHER_STORAGE_HEATERS → charged wholly off-peak → 5.5 p/kWh low rate); + # Main 2 = DIRECT-ACTING panel/convector heaters (SAP 691, Table 12a + # OTHER_DIRECT_ACTING_ELECTRIC → 100% high rate on 7-hour → 15.29 p/kWh). + # An electric Main 2 must be billed at its OWN Table 12a Grid 1 SH row — + # the on-peak panel half cannot ride Main 1's off-peak storage rate (§1684). + # This is the same per-system resolution Main 1 and a non-electric Main 2 + # already use; the earlier "keep Main 1's scalar" deferral was a no-op on + # the HOMOGENEOUS cohort (storage+storage, direct+direct → equal rates) and + # only mis-billed the heterogeneous case, over-rating SAP (LRHA WAVE 3 + # fixture 497655371974 storage+panel read +15.3 vs PasHub). from domain.sap10_calculator.tables.table_12a import Tariff - main_1 = MainHeatingDetail( - has_fghrs=False, main_fuel_type=29, heat_emitter_type=0, - emitter_temperature="NA", main_heating_control=2106, - main_heating_category=10, sap_main_heating_code=691, + main_1_storage = MainHeatingDetail( + has_fghrs=False, main_fuel_type=30, heat_emitter_type=0, + emitter_temperature="NA", main_heating_control=2401, + main_heating_category=10, sap_main_heating_code=402, main_heating_fraction=50, ) - main_2 = MainHeatingDetail( - has_fghrs=False, main_fuel_type=29, heat_emitter_type=0, - emitter_temperature="NA", main_heating_control=2106, + main_2_direct = MainHeatingDetail( + has_fghrs=False, main_fuel_type=30, heat_emitter_type=0, + emitter_temperature="NA", main_heating_control=2602, main_heating_category=10, sap_main_heating_code=691, main_heating_fraction=50, ) + epc = make_minimal_sap10_epc( + sap_building_parts=[make_building_part(construction_age_band="D")], + sap_heating=make_sap_heating( + main_heating_details=[main_1_storage, main_2_direct] + ), + ) + + # Act + main_2_rate = _main_2_space_heating_fuel_cost_gbp_per_kwh( + epc, Tariff.SEVEN_HOUR, SAP_10_2_SPEC_PRICES + ) + main_2_own_rate = _space_heating_fuel_cost_gbp_per_kwh( + main_2_direct, Tariff.SEVEN_HOUR, SAP_10_2_SPEC_PRICES + ) + main_1_storage_rate = _space_heating_fuel_cost_gbp_per_kwh( + main_1_storage, Tariff.SEVEN_HOUR, SAP_10_2_SPEC_PRICES + ) + + # Assert — Main 2 bills at its own direct-acting on-peak rate (15.29 p), + # NOT Main 1's storage low rate (5.5 p). + assert abs(main_2_rate - main_2_own_rate) <= 1e-9 + assert abs(main_2_rate - 0.1529) <= 1e-4 + assert main_2_rate > main_1_storage_rate + 0.09 + + +def test_off_peak_main_2_electric_homogeneous_storage_unchanged() -> None: + # Arrange — HOMOGENEOUS storage+storage on a 7-hour tariff. Per-system + # billing is a no-op here (both mains resolve to the same off-peak storage + # rate), which is why flipping the deferral (§1684) leaves the off-peak + # electric cohort untouched. Locks that invariant. + from domain.sap10_calculator.tables.table_12a import Tariff + + def _storage() -> MainHeatingDetail: + return MainHeatingDetail( + has_fghrs=False, main_fuel_type=30, heat_emitter_type=0, + emitter_temperature="NA", main_heating_control=2401, + main_heating_category=10, sap_main_heating_code=402, + main_heating_fraction=50, + ) + + main_1 = _storage() + main_2 = _storage() epc = make_minimal_sap10_epc( sap_building_parts=[make_building_part(construction_age_band="D")], sap_heating=make_sap_heating(main_heating_details=[main_1, main_2]), ) - # Act — main 2's rate equals main 1's space-heating scalar. + # Act main_2_rate = _main_2_space_heating_fuel_cost_gbp_per_kwh( epc, Tariff.SEVEN_HOUR, SAP_10_2_SPEC_PRICES ) @@ -652,7 +702,7 @@ def test_off_peak_main_2_electric_keeps_main_1_scalar() -> None: main_1, Tariff.SEVEN_HOUR, SAP_10_2_SPEC_PRICES ) - # Assert + # Assert — identical storage rates; the flip does not disturb this cohort. assert abs(main_2_rate - main_1_rate) <= 1e-9