From 398692703fc2c58fafb0e54a748aabe381b73e79 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 24 Jul 2026 14:04:08 +0000 Subject: [PATCH] =?UTF-8?q?Bill=20secondary=20electric=20heating=20at=20th?= =?UTF-8?q?e=20dwelling's=20=C2=A712=20tariff,=20not=20the=20meter=20defau?= =?UTF-8?q?lt=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../test_pashub_sap_accuracy_lrha_wave3.py | 29 +++++++++++++++++-- .../sap10_calculator/rdsap/cert_to_inputs.py | 29 ++++++++++++------- .../rdsap/test_cert_to_inputs.py | 20 +++++++++++-- 3 files changed, 63 insertions(+), 15 deletions(-) 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 628b46555..5a978c2b5 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 @@ -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: diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 0f9df93ef..c464e9579 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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), 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 d1c31bace..72e1ab6d2 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -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