Bill an electric second main at its own §12/Table-12a rate 🟩

Closes #1684. `_main_2_space_heating_fuel_cost_gbp_per_kwh` billed an
electric Main 2 at Main 1's space-heating rate. That was a no-op on a
homogeneous off-peak cohort (storage+storage, direct+direct → equal
rates) but mis-billed the HETEROGENEOUS case: an off-peak STORAGE Main 1
(5.5 p/kWh low) + an on-peak DIRECT-ACTING panel Main 2 (15.29 p/kWh
high) rode the storage low rate, under-costing the panel half and
over-rating SAP.

Flip it to bill Main 2 at its OWN Table 12a Grid 1 SH row, the same
per-system resolution Main 1 and a non-electric Main 2 already use — and
the same one `_main_2_high_rate_fraction` already applies, so the scalar
cost and the high-rate split are now consistent.

The docstring's "regresses certs 13/34 (Parkers Hill / Dunley Road)"
warning was stale: those names appear nowhere but that docstring, and
the full guard suite shows zero new regressions (only the two
pre-existing `test_heating_systems_corpus` fails on main). LRHA WAVE 3
fixture 497655371974 (storage+panel) moves +15.3 → -2.5 vs PasHub; cohort
MAE 0.799 → 0.675 (ratchet re-baselined 0.80 → 0.68). within-0.5 holds at
55.3% — the -2.5 residual is PasHub-vs-Elmhurst divergence, not a bug, so
the fixture stays out of the within-0.5 pin. A ground-truth Elmhurst pin
is queued (see docs/HANDOVER_1684_ELMHURST_INPUTS_497655371974.md).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-27 09:06:20 +00:00
parent 22520becb9
commit a21d2cc579
4 changed files with 225 additions and 38 deletions

View file

@ -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}"

View file

@ -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 19761982)
- 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`.

View file

@ -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)

View file

@ -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