Bill secondary electric heating at the dwelling's §12 tariff, not the meter default 🟩

An electric room/panel-heater dwelling (main SAP code 691) on a Dual meter is a
10-hour tariff by RdSAP 10 §12 Rule 3, so its main heating bills the secondary's
identical electricity at a 0.50-blended 11.09 p/kWh. Its SECONDARY heating,
however, resolved its tariff from the bare meter string — where a Dual meter
defaults to 7-hour (`tariff_from_meter_type`) — and billed at the 7-hour
all-high-rate 15.29 p/kWh. The ~10% secondary fraction was over-priced by ~4 p/kWh,
under-rating SAP by ~1 point across the LRHA WAVE 3 code-691 cohort.

Route the dwelling's resolved `_rdsap_tariff(epc)` into `_secondary_fuel_cost_gbp_
per_kwh` / `_secondary_off_peak_rate_gbp_per_kwh`, exactly as the main-heating cost
path (`_space_heating_fuel_cost_gbp_per_kwh`) and the secondary CO2 path
(`_secondary_heating_co2_factor_kg_per_kwh`) already do — removing the internal
meter-only resolution that diverged from the dwelling's single §12 tariff.

Storage-heater dwellings (7-hour by §12) are unchanged — their secondary correctly
stays at 15.29 p (regression-pinned in test_cert_to_inputs). LRHA WAVE 3 within-0.5
49.5% → 52.4%, MAE 0.977; gov-API, Elmhurst RealCert, Guinness and Wythenshawe
corpora unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-24 14:04:08 +00:00
parent bd84a2f190
commit 398692703f
3 changed files with 63 additions and 15 deletions

View file

@ -88,11 +88,19 @@ _MANIFEST_PATH = _FIXTURES_DIR / "manifest.json"
# 461178278096, which lodges no main heating at all (`MissingMainFuelType`,
# tolerated above pending a modelling decision). Floor/ceiling re-baselined to
# the corrected cohort.
# 2026-07-24 (accuracy tail audit): the secondary-heating COST path resolved its
# tariff from the bare meter string (a Dual meter defaulting to 7-hour, all
# high-rate) instead of the dwelling's SAP §12 tariff, so an electric room/panel-
# heater dwelling (10-hour by §12) billed its secondary at 15.29 p/kWh instead of
# the 11.09 p its main heating bills — under-rating SAP by ~1 point across the
# code-691 dwellings. Routed the resolved `_rdsap_tariff` into the secondary cost
# path (mirroring the CO2 path, which already did this) → within-0.5 49.5% →
# 52.4%, MAE 0.977. Storage-heater dwellings (7-hour by §12) are unchanged.
# 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.49
_MAX_SAP_MAE: float = 1.05
_MIN_WITHIN_HALF: float = 0.52
_MAX_SAP_MAE: float = 0.98
_KNOWN_GAP_REASON = (
"pashub `from_site_notes` mapper does not int-code a main-heating "
@ -153,6 +161,23 @@ def _evaluate(deal_id: str) -> Outcome:
)
@pytest.mark.skipif(
"462024626384" not in _BY_ID, reason="fixture 462024626384 not in manifest"
)
def test_electric_panel_secondary_prices_at_dwelling_tariff() -> None:
"""Regression: an electric room/panel-heater dwelling (main SAP code 691) on
a dual meter must bill its SECONDARY electric heating at the dwelling's SAP
§12 tariff (10-hour, Table 12a SH high-rate fraction 0.50 ~11.09 p/kWh)
the same rate as its main heating not the 7-hour all-high-rate (~15.29
p/kWh) that the bare meter string defaulted to. The mispriced secondary
under-rated SAP by ~1.4 (fixture 462024626384 scored 44.5 vs PasHub's 46);
the CO2 path already resolves the tariff via `_rdsap_tariff`, the cost path
now matches it."""
outcome = _evaluate("462024626384")
assert outcome.diff is not None
assert outcome.diff < 0.5, f"secondary mis-priced: diff {outcome.diff:.2f}"
@pytest.mark.skipif(not _FIXTURES, reason="no pashub_accuracy_lrha_wave3 fixtures/manifest")
@pytest.mark.parametrize("deal_id", _IDS)
def test_pashub_fixture_computes(deal_id: str) -> None:

View file

@ -3237,7 +3237,7 @@ def _secondary_efficiency(
return seasonal_efficiency(code, None, None)
def _secondary_off_peak_rate_gbp_per_kwh(meter_type: object) -> float:
def _secondary_off_peak_rate_gbp_per_kwh(tariff: Tariff) -> float:
"""SAP 10.2 Table 12a Grid 1 (PDF p.191) blended rate for an electric
secondary heater on an off-peak tariff. The secondary is a direct-
acting electric room heater (RdSAP 10 §A.2.2 default), so it sits on
@ -3247,12 +3247,14 @@ def _secondary_off_peak_rate_gbp_per_kwh(meter_type: object) -> float:
rate. Worksheet evidence simulated case 19 (242): "Space heating -
secondary (1.00*15.29 + 0.00*5.50)" → all at the 7-hour HIGH rate.
Mirrors `_space_heating_fuel_cost_gbp_per_kwh`: the meter resolves to
a tariff (the `_is_off_peak_meter` Unknown-code-3 heuristic falls
through to 7-hour, as in `_off_peak_low_rate_gbp_per_kwh_via_meter_
heuristic`); 18-/24-hour tariffs (absent from the Grid 1 direct-acting
row) fall back to the tariff's Table 32 low rate."""
tariff = tariff_from_meter_type(meter_type)
Takes the dwelling's resolved SAP §12 tariff (`_rdsap_tariff`), the
SAME tariff the main heating bills on a room-heater dwelling on a
Dual meter is 10-hour by §12 Rule 3, so its secondary must blend at
0.50 (11.09 p) like the main, not 1.00 (15.29 p). Resolving from the
bare meter string instead defaulted a Dual meter to 7-hour and
over-priced the secondary (mirrors the CO2 path, which already keys
on `_rdsap_tariff`). 18-/24-hour tariffs (absent from the Grid 1
direct-acting row) fall back to the tariff's Table 32 low rate."""
if tariff is Tariff.STANDARD:
tariff = Tariff.SEVEN_HOUR
try:
@ -3271,23 +3273,27 @@ def _secondary_fuel_cost_gbp_per_kwh(
main: Optional[MainHeatingDetail],
meter_type: object,
prices: PriceTable,
tariff: Tariff,
) -> float:
"""Secondary fuel cost. When secondary_fuel_type is missing, default
to portable-electric (code 30 standard electricity, or off-peak
under E7-eligible meter). The cert's secondary is an electric room
heater per the §A.2.2 default."""
heater per the §A.2.2 default. `tariff` is the dwelling's resolved
SAP §12 tariff, so an off-peak secondary bills at the same tariff as
the main heating rather than a meter-string default (see
`_secondary_off_peak_rate_gbp_per_kwh`)."""
sec_fuel = sap_heating.secondary_fuel_type
if sec_fuel is None:
# Default to electricity since the default secondary system is
# portable electric heaters (code 693).
if _is_off_peak_meter(meter_type, fuel_is_electric=True):
return _secondary_off_peak_rate_gbp_per_kwh(meter_type)
return _secondary_off_peak_rate_gbp_per_kwh(tariff)
return prices.standard_electricity_p_per_kwh * _PENCE_TO_GBP
# When secondary_fuel_type is electricity, apply off-peak if applicable.
if _is_electric_water(sec_fuel) and _is_off_peak_meter(
meter_type, fuel_is_electric=True
):
return _secondary_off_peak_rate_gbp_per_kwh(meter_type)
return _secondary_off_peak_rate_gbp_per_kwh(tariff)
# Normalise colliding gov-API enum codes (e.g. 9 dual fuel, whose
# value collides with Table-32 9 = LPG SC11F) before the price lookup,
# exactly as the main-fuel boundary does — otherwise the same-value
@ -8943,7 +8949,8 @@ def cert_to_inputs(
secondary_heating_efficiency=secondary_efficiency_value,
energy_requirements=energy_requirements_result,
secondary_heating_fuel_cost_gbp_per_kwh=_secondary_fuel_cost_gbp_per_kwh(
epc.sap_heating, main, epc.sap_energy_source.meter_type, prices
epc.sap_heating, main, epc.sap_energy_source.meter_type, prices,
_rdsap_tariff(epc),
),
space_heating_primary_factor=_main_heating_primary_factor(
main, _rdsap_tariff(epc),

View file

@ -89,6 +89,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
_pv_eligible_demand_monthly_kwh, # pyright: ignore[reportPrivateUsage]
_primary_loss_applies, # pyright: ignore[reportPrivateUsage]
_rdsap_extract_fans_default, # pyright: ignore[reportPrivateUsage]
_rdsap_tariff, # pyright: ignore[reportPrivateUsage]
_pv_overshading_factor, # pyright: ignore[reportPrivateUsage]
_pv_pitch_deg, # pyright: ignore[reportPrivateUsage]
_responsiveness, # pyright: ignore[reportPrivateUsage]
@ -3185,6 +3186,7 @@ def test_dual_fuel_secondary_api_enum_9_prices_as_dual_fuel_not_lpg() -> None:
gas_boiler_main,
2, # standard (single-rate) meter
SAP_10_2_SPEC_PRICES,
_rdsap_tariff(dual_fuel_secondary_epc),
)
secondary_factor_code = _secondary_fuel_code(dual_fuel_secondary_epc)
@ -4080,6 +4082,8 @@ def test_secondary_electric_off_peak_bills_at_table_12a_direct_acting_high_rate(
main_heating_category=None,
sap_main_heating_code=402, # electric storage heaters
)
from dataclasses import replace
dual_meter_off_peak_epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
@ -4089,16 +4093,28 @@ def test_secondary_electric_off_peak_bills_at_table_12a_direct_acting_high_rate(
# secondary_fuel_type omitted → §A.2.2 portable electric default
),
)
# A storage-heater dwelling on a Dual meter resolves to the 7-hour tariff
# by SAP §12; set it on the cert so the cost path reads the dwelling's
# tariff (`_rdsap_tariff`), the same source the main heating bills on —
# NOT the bare meter string. (A room/panel-heater dwelling on a Dual meter
# is instead 10-hour, so its secondary blends 0.50 → 11.09 p, not 15.29.)
dual_meter_off_peak_epc = replace(
dual_meter_off_peak_epc,
sap_energy_source=replace(
dual_meter_off_peak_epc.sap_energy_source, meter_type="1"
),
)
# Act
secondary_rate_gbp_per_kwh = _secondary_fuel_cost_gbp_per_kwh(
dual_meter_off_peak_epc.sap_heating,
storage_heater_main,
1, # Dual meter → 7-hour off-peak tariff
dual_meter_off_peak_epc.sap_energy_source.meter_type,
SAP_10_2_SPEC_PRICES,
_rdsap_tariff(dual_meter_off_peak_epc),
)
# Assert — 1.00 × 15.29 p + 0.00 × 5.50 p = 15.29 p/kWh = £0.1529.
# Assert — storage main → 7-hour: 1.00 × 15.29 p + 0.00 × 5.50 p = £0.1529.
assert abs(secondary_rate_gbp_per_kwh - 0.1529) <= 1e-6