mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
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:
parent
22520becb9
commit
a21d2cc579
4 changed files with 225 additions and 38 deletions
|
|
@ -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}"
|
||||
|
|
|
|||
130
docs/HANDOVER_1684_ELMHURST_INPUTS_497655371974.md
Normal file
130
docs/HANDOVER_1684_ELMHURST_INPUTS_497655371974.md
Normal 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 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`.
|
||||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue