mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
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:
parent
bd84a2f190
commit
398692703f
3 changed files with 63 additions and 15 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue