Slice S0380.138: route every off-peak callsite through the per-tariff Table 32 low-rate (electric +5..+9 SAP cluster + spillover)

Pre-slice every off-peak callsite in `cert_to_inputs.py` —
`_space_heating_fuel_cost_gbp_per_kwh`, `_hot_water_fuel_cost_gbp_per_kwh`,
`_secondary_fuel_cost_gbp_per_kwh`, `_pv_dwelling_import_price_gbp_per_kwh` —
hardcoded `prices.e7_low_rate_p_per_kwh = 5.50` p/kWh (Table 32 code 31,
the 7-hour low rate) regardless of the cert's actual tariff. Every
18-hour cert was thereby under-charged 1.91 p/kWh × off-peak kWh on
its space-heating, hot-water, and secondary-heating cost rows.

Per RdSAP 10 §19 Table 32 (p.95):

> "Electricity ... 7-hour tariff (low rate / off-peak) — code 31 5.50 p/kWh
>  ... 10-hour tariff (low rate) — code 33 7.50 p/kWh
>  ... 18-hour tariff (low rate) — code 40 7.41 p/kWh
>  ... 24-hour tariff — code 35 6.61 p/kWh"

The fix routes through a new `_off_peak_low_rate_gbp_per_kwh(tariff)`
helper that reads the existing per-tariff Table 32 lookup
(`_TARIFF_HIGH_LOW_RATES_P_PER_KWH`). A companion
`_off_peak_low_rate_gbp_per_kwh_via_meter_heuristic(meter_type)` covers
the secondary / PV paths that detect off-peak via the
`_is_off_peak_meter` heuristic (RdSAP meter code 3 = Unknown is treated
as off-peak for electric end-uses), falling back to the SEVEN_HOUR rate
when the meter resolves to STANDARD — codifying the heuristic that the
literal 5.50 constant used to embed.

Per [[feedback-zero-error-strict]] the now-dead
`PriceTable.e7_low_rate_p_per_kwh` field is deleted (no fallback can
silently re-introduce the 5.50 hardcode); the field's docstring +
RDSAP_10_TABLE_32_PRICES instantiation update to point at the new
helpers.

Corpus closure (all 18-hour cohort):

- 8 electric variants — SAP +5.85..+9.64 → -0.10..-2.76; cost
  -£135..-£222 → +£2..+£64
- ashp +5.67 → +0.24 SAP (-£131 → -£5.57)
- gshp +5.16 → +1.15 SAP (-£119 → -£26)
- solid fuel 4..11 — SAP +1.59..+2.04 → ±0.45 (cost ±£10)

Golden 0240 PV path also closes (was raising UnmappedSapCode on
Unknown-meter probe — surfaced an unreachable PV literal that the
meter-heuristic helper now resolves).

Tests:
- new AAA test `test_space_heating_off_peak_fallback_uses_actual_tariff_low_rate_not_e7`
  exercising the EIGHTEEN_HOUR fallback at the helper level
- 19 corpus pins re-tightened (8 electric + ashp + gshp + 8 solid-fuel
  + golden 0240's implicit pin)

Extended handover suite: 881 pass (was 880; +1 new test), 0 fail.
Pyright net-zero on touched files (43 → 43 errors, all pre-existing).

Per [[feedback-spec-citation-in-commits]] +
[[feedback-worksheet-not-api-reference]] +
[[reference-unmapped-sap-code]].

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-31 18:22:54 +00:00 committed by Jun-te Kim
parent b20751451d
commit 6a7bf3e074
3 changed files with 132 additions and 39 deletions

View file

@ -155,17 +155,40 @@ class _CorpusExpectation:
# residuals dropped from -1.3..-3.2k to -1.1k..+200 kWh; SAP # residuals dropped from -1.3..-3.2k to -1.1k..+200 kWh; SAP
# residuals from +6.9..+14.7 to +5.8..+9.4. electric 5/8/9 close to # residuals from +6.9..+14.7 to +5.8..+9.4. electric 5/8/9 close to
# ±200 PE. # ±200 PE.
#
# Slice S0380.138 fixed the off-peak low-rate cost cascade: pre-slice
# every off-peak callsite (`_space_heating_fuel_cost_gbp_per_kwh`,
# `_hot_water_fuel_cost_gbp_per_kwh`, `_secondary_fuel_cost_gbp_per_kwh`,
# `_pv_dwelling_import_price_gbp_per_kwh`) hardcoded
# `prices.e7_low_rate_p_per_kwh = 5.50` p/kWh (Table 32 code 31 =
# 7-hour low) regardless of the cert's actual tariff. Every 18-hour
# cert was thereby under-charged 1.91 p/kWh × off-peak kWh. The fix
# routes through a new `_off_peak_low_rate_gbp_per_kwh(tariff)` helper
# that reads the existing per-tariff Table 32 lookup (codes 31 / 33 /
# 35 / 40 for 7h / 10h / 24h / 18h), plus a companion meter-heuristic
# helper for the Unknown-meter (code 3 = "treat as off-peak for electric
# end-uses") path that preserves the SEVEN_HOUR fallback. All 8 electric
# corpus variants re-pinned: SAP residuals collapsed from +5.85..+9.64
# to -0.10..-2.76; cost from -£135..-£222 to +£2..+£64. Closures also
# landed for ashp (+5.67 → +0.24 SAP), gshp (+5.16 → +1.15), and all
# solid-fuel variants 4-11 (SAP +1.59..+2.04 → ±0.45) — all 18-hour
# certs whose secondary-heating fuel cost was billed at 5.50 instead
# of 7.41. Per [[feedback-spec-citation-in-commits]] the spec rule is
# RdSAP 10 §19 Table 32 (p.95) which defines a distinct low-rate code
# per tariff. Per [[feedback-zero-error-strict]]
# PriceTable.e7_low_rate_p_per_kwh was deleted (dead code; no fallback
# can silently re-introduce 5.50).
_EXPECTATIONS: tuple[_CorpusExpectation, ...] = ( _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
_CorpusExpectation(variant='ashp', block='11a', expected_sap_resid=+5.6680, expected_cost_resid_gbp=-130.5995, expected_co2_resid_kg=-1.4283, expected_pe_resid_kwh=-11.8017), _CorpusExpectation(variant='ashp', block='11a', expected_sap_resid=+0.2418, expected_cost_resid_gbp=-5.5706, expected_co2_resid_kg=-1.4283, expected_pe_resid_kwh=-11.8017),
_CorpusExpectation(variant='electric 1', block='11a', expected_sap_resid=+9.6439, expected_cost_resid_gbp=-222.2109, expected_co2_resid_kg=+14.3441, expected_pe_resid_kwh=+164.9052), _CorpusExpectation(variant='electric 1', block='11a', expected_sap_resid=-0.2021, expected_cost_resid_gbp=+4.6562, expected_co2_resid_kg=+14.3441, expected_pe_resid_kwh=+164.9052),
_CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=+5.8523, expected_cost_resid_gbp=-134.8455, expected_co2_resid_kg=+94.4364, expected_pe_resid_kwh=+970.7570), _CorpusExpectation(variant='electric 2', block='11a', expected_sap_resid=-1.2714, expected_cost_resid_gbp=+29.2944, expected_co2_resid_kg=+94.4364, expected_pe_resid_kwh=+970.7570),
_CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=+9.4332, expected_cost_resid_gbp=-217.3549, expected_co2_resid_kg=-112.3439, expected_pe_resid_kwh=-1059.2875), _CorpusExpectation(variant='electric 3', block='11a', expected_sap_resid=-0.1013, expected_cost_resid_gbp=+2.3332, expected_co2_resid_kg=-112.3439, expected_pe_resid_kwh=-1059.2875),
_CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=+6.7642, expected_cost_resid_gbp=-155.8576, expected_co2_resid_kg=-5.3096, expected_pe_resid_kwh=-95.6333), _CorpusExpectation(variant='electric 5', block='11a', expected_sap_resid=-2.4807, expected_cost_resid_gbp=+57.1568, expected_co2_resid_kg=-5.3096, expected_pe_resid_kwh=-95.6333),
_CorpusExpectation(variant='electric 6', block='11a', expected_sap_resid=+7.8189, expected_cost_resid_gbp=-180.1606, expected_co2_resid_kg=-50.0685, expected_pe_resid_kwh=-494.3960), _CorpusExpectation(variant='electric 6', block='11a', expected_sap_resid=-1.1367, expected_cost_resid_gbp=+26.1898, expected_co2_resid_kg=-50.0685, expected_pe_resid_kwh=-494.3960),
_CorpusExpectation(variant='electric 7', block='11a', expected_sap_resid=+7.5834, expected_cost_resid_gbp=-174.7323, expected_co2_resid_kg=-31.5507, expected_pe_resid_kwh=-427.5932), _CorpusExpectation(variant='electric 7', block='11a', expected_sap_resid=-1.0835, expected_cost_resid_gbp=+24.9648, expected_co2_resid_kg=-31.5507, expected_pe_resid_kwh=-427.5932),
_CorpusExpectation(variant='electric 8', block='11a', expected_sap_resid=+5.8386, expected_cost_resid_gbp=-134.5304, expected_co2_resid_kg=+18.2051, expected_pe_resid_kwh=+199.7233), _CorpusExpectation(variant='electric 8', block='11a', expected_sap_resid=-2.5400, expected_cost_resid_gbp=+58.5256, expected_co2_resid_kg=+18.2051, expected_pe_resid_kwh=+199.7233),
_CorpusExpectation(variant='electric 9', block='11a', expected_sap_resid=+6.7699, expected_cost_resid_gbp=-155.9877, expected_co2_resid_kg=+11.1781, expected_pe_resid_kwh=+154.0936), _CorpusExpectation(variant='electric 9', block='11a', expected_sap_resid=-2.7646, expected_cost_resid_gbp=+63.7004, expected_co2_resid_kg=+11.1781, expected_pe_resid_kwh=+154.0936),
_CorpusExpectation(variant='gshp', block='11a', expected_sap_resid=+5.1598, expected_cost_resid_gbp=-118.8901, expected_co2_resid_kg=-41.4461, expected_pe_resid_kwh=-454.5023), _CorpusExpectation(variant='gshp', block='11a', expected_sap_resid=+1.1491, expected_cost_resid_gbp=-26.4775, expected_co2_resid_kg=-41.4461, expected_pe_resid_kwh=-454.5023),
_CorpusExpectation(variant='oil 1', block='11a', expected_sap_resid=+2.6578, expected_cost_resid_gbp=-61.2402, expected_co2_resid_kg=-242.2677, expected_pe_resid_kwh=-1050.4919), _CorpusExpectation(variant='oil 1', block='11a', expected_sap_resid=+2.6578, expected_cost_resid_gbp=-61.2402, expected_co2_resid_kg=-242.2677, expected_pe_resid_kwh=-1050.4919),
_CorpusExpectation(variant='oil pcdb 1', block='11a', expected_sap_resid=+0.4239, expected_cost_resid_gbp=-9.7668, expected_co2_resid_kg=-35.9551, expected_pe_resid_kwh=-83.8239), _CorpusExpectation(variant='oil pcdb 1', block='11a', expected_sap_resid=+0.4239, expected_cost_resid_gbp=-9.7668, expected_co2_resid_kg=-35.9551, expected_pe_resid_kwh=-83.8239),
_CorpusExpectation(variant='oil pcdb 2', block='11a', expected_sap_resid=+0.4239, expected_cost_resid_gbp=-9.7668, expected_co2_resid_kg=-35.9551, expected_pe_resid_kwh=-83.8239), _CorpusExpectation(variant='oil pcdb 2', block='11a', expected_sap_resid=+0.4239, expected_cost_resid_gbp=-9.7668, expected_co2_resid_kg=-35.9551, expected_pe_resid_kwh=-83.8239),
@ -180,14 +203,14 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
# control-type gaps — separate slices. # control-type gaps — separate slices.
_CorpusExpectation(variant='solid fuel 2', block='11a', expected_sap_resid=+2.6383, expected_cost_resid_gbp=-60.7914, expected_co2_resid_kg=+53.9038, expected_pe_resid_kwh=-1211.3624), _CorpusExpectation(variant='solid fuel 2', block='11a', expected_sap_resid=+2.6383, expected_cost_resid_gbp=-60.7914, expected_co2_resid_kg=+53.9038, expected_pe_resid_kwh=-1211.3624),
_CorpusExpectation(variant='solid fuel 3', block='11a', expected_sap_resid=+1.3216, expected_cost_resid_gbp=-30.4512, expected_co2_resid_kg=-428.6594, expected_pe_resid_kwh=-934.5983), _CorpusExpectation(variant='solid fuel 3', block='11a', expected_sap_resid=+1.3216, expected_cost_resid_gbp=-30.4512, expected_co2_resid_kg=-428.6594, expected_pe_resid_kwh=-934.5983),
_CorpusExpectation(variant='solid fuel 4', block='11a', expected_sap_resid=+1.5867, expected_cost_resid_gbp=-36.5606, expected_co2_resid_kg=-78.9461, expected_pe_resid_kwh=+151.1685), _CorpusExpectation(variant='solid fuel 4', block='11a', expected_sap_resid=-0.4528, expected_cost_resid_gbp=+10.4331, expected_co2_resid_kg=-78.9461, expected_pe_resid_kwh=+151.1685),
_CorpusExpectation(variant='solid fuel 5', block='11a', expected_sap_resid=+1.7045, expected_cost_resid_gbp=-39.2732, expected_co2_resid_kg=-52.5294, expected_pe_resid_kwh=+160.0328), _CorpusExpectation(variant='solid fuel 5', block='11a', expected_sap_resid=-0.3350, expected_cost_resid_gbp=+7.7205, expected_co2_resid_kg=-52.5294, expected_pe_resid_kwh=+160.0328),
_CorpusExpectation(variant='solid fuel 6', block='11a', expected_sap_resid=+1.9493, expected_cost_resid_gbp=-44.9072, expected_co2_resid_kg=+4.8671, expected_pe_resid_kwh=+87.0778), _CorpusExpectation(variant='solid fuel 6', block='11a', expected_sap_resid=-0.0902, expected_cost_resid_gbp=+2.0800, expected_co2_resid_kg=+4.8671, expected_pe_resid_kwh=+87.0778),
_CorpusExpectation(variant='solid fuel 7', block='11a', expected_sap_resid=+2.0439, expected_cost_resid_gbp=-47.0520, expected_co2_resid_kg=-91.3569, expected_pe_resid_kwh=+44.3084), _CorpusExpectation(variant='solid fuel 7', block='11a', expected_sap_resid=+0.0025, expected_cost_resid_gbp=-0.0583, expected_co2_resid_kg=-91.3569, expected_pe_resid_kwh=+44.3084),
_CorpusExpectation(variant='solid fuel 8', block='11a', expected_sap_resid=+1.8115, expected_cost_resid_gbp=-41.7407, expected_co2_resid_kg=+26.9399, expected_pe_resid_kwh=+87.6830), _CorpusExpectation(variant='solid fuel 8', block='11a', expected_sap_resid=-0.2280, expected_cost_resid_gbp=+5.2530, expected_co2_resid_kg=+26.9399, expected_pe_resid_kwh=+87.6830),
_CorpusExpectation(variant='solid fuel 9', block='11a', expected_sap_resid=+1.7052, expected_cost_resid_gbp=-39.2906, expected_co2_resid_kg=+28.0233, expected_pe_resid_kwh=+154.9673), _CorpusExpectation(variant='solid fuel 9', block='11a', expected_sap_resid=-0.3344, expected_cost_resid_gbp=+7.7031, expected_co2_resid_kg=+28.0233, expected_pe_resid_kwh=+154.9673),
_CorpusExpectation(variant='solid fuel 10', block='11a', expected_sap_resid=+1.7463, expected_cost_resid_gbp=-40.2377, expected_co2_resid_kg=+25.7581, expected_pe_resid_kwh=+119.8372), _CorpusExpectation(variant='solid fuel 10', block='11a', expected_sap_resid=-0.2932, expected_cost_resid_gbp=+6.7559, expected_co2_resid_kg=+25.7581, expected_pe_resid_kwh=+119.8372),
_CorpusExpectation(variant='solid fuel 11', block='11a', expected_sap_resid=+1.6215, expected_cost_resid_gbp=-37.3612, expected_co2_resid_kg=+32.7399, expected_pe_resid_kwh=+170.5611), _CorpusExpectation(variant='solid fuel 11', block='11a', expected_sap_resid=-0.4180, expected_cost_resid_gbp=+9.6325, expected_co2_resid_kg=+32.7399, expected_pe_resid_kwh=+170.5611),
) )

View file

@ -585,18 +585,18 @@ class PriceTable:
off-peak rate (see slice S-B9 commit + S-B11 hand-trace). off-peak rate (see slice S-B9 commit + S-B11 hand-trace).
`unit_price_p_per_kwh` accepts either an API fuel code or a Table 12 `unit_price_p_per_kwh` accepts either an API fuel code or a Table 12
code; implementations translate before lookup. `e7_low_rate_p_per_kwh` code; implementations translate before lookup.
is the off-peak rate used for E7-eligible space heating, and
`standard_electricity_p_per_kwh` is the rate applied to lighting + `standard_electricity_p_per_kwh` is the rate applied to lighting +
pumps + fans regardless of main fuel. `e7_eligible_main_codes` lists pumps + fans regardless of main fuel. `e7_eligible_main_codes` lists
the SAP Table 4a main-heating codes that bill space heating at the SAP Table 4a main-heating codes that bill space heating at the
`e7_low_rate_p_per_kwh` narrower under the spec (storage heaters tariff's off-peak low-rate — narrower under the spec (storage
only per Table 12a) than under cert calibration (the cert assessor heaters only per Table 12a) than under cert calibration (the cert
appears to apply off-peak to direct-electric too). assessor appears to apply off-peak to direct-electric too).
Tariff-specific off-peak low-rates are looked up via
`_off_peak_low_rate_gbp_per_kwh` per RdSAP 10 §19 Table 32.
""" """
unit_price_p_per_kwh: Callable[[Optional[int]], float] unit_price_p_per_kwh: Callable[[Optional[int]], float]
e7_low_rate_p_per_kwh: float
standard_electricity_p_per_kwh: float standard_electricity_p_per_kwh: float
e7_eligible_main_codes: frozenset[int] e7_eligible_main_codes: frozenset[int]
@ -622,12 +622,11 @@ _SPEC_E7_ELIGIBLE_MAIN_CODES: Final[frozenset[int]] = frozenset(
# standard electricity = 13.19 p/kWh (vs Table 12 = 16.49). # standard electricity = 13.19 p/kWh (vs Table 12 = 16.49).
# #
# Wired into `cert_to_inputs` as the default PriceTable per ADR-0010 # Wired into `cert_to_inputs` as the default PriceTable per ADR-0010
# §10a amendment (2026-05-21). Off-peak fallback scalars # §10a amendment (2026-05-21). Off-peak low-rates are looked up
# (`hot_water_fuel_cost_gbp_per_kwh` etc.) read `unit_price_p_per_kwh` # tariff-by-tariff via `_off_peak_low_rate_gbp_per_kwh`
# directly so this is where the cohort-wide tariff lands. # (S0380.138: routes 18-hour to 7.41, 10-hour to 7.50, 24-hour to 6.61).
RDSAP_10_TABLE_32_PRICES: Final[PriceTable] = PriceTable( RDSAP_10_TABLE_32_PRICES: Final[PriceTable] = PriceTable(
unit_price_p_per_kwh=table_32_unit_price_p_per_kwh, unit_price_p_per_kwh=table_32_unit_price_p_per_kwh,
e7_low_rate_p_per_kwh=5.50, # Table 32 code 31 (7-hour low)
standard_electricity_p_per_kwh=13.19, # Table 32 code 30 standard_electricity_p_per_kwh=13.19, # Table 32 code 30
e7_eligible_main_codes=_SPEC_E7_ELIGIBLE_MAIN_CODES, e7_eligible_main_codes=_SPEC_E7_ELIGIBLE_MAIN_CODES,
) )
@ -1219,6 +1218,38 @@ def _tariff_high_low_rates_p_per_kwh(tariff: Tariff) -> tuple[float, float]:
raise UnmappedSapCode("tariff_high_low_rates", tariff) raise UnmappedSapCode("tariff_high_low_rates", tariff)
def _off_peak_low_rate_gbp_per_kwh(tariff: Tariff) -> float:
"""Off-peak low-rate £/kWh for an off-peak tariff. Per RdSAP 10 §19
Table 32 (p.95) the low-rate price varies by tariff: code 31 for
7-hour (5.50), code 33 for 10-hour (7.50), code 40 for 18-hour
(7.41), code 35 for 24-hour heating (6.61). Pre-S0380.138 every
off-peak callsite read `prices.e7_low_rate_p_per_kwh` (5.50 code
31 only) for every tariff, under-counting 18-hour cost by
1.91 p/kWh × off-peak kWh. Routes through
`_tariff_high_low_rates_p_per_kwh` so STANDARD raises (callers
early-return) and any future Tariff enum addition surfaces as a
strict-raise per [[reference-unmapped-sap-code]]."""
_high, low = _tariff_high_low_rates_p_per_kwh(tariff)
return low * _PENCE_TO_GBP
def _off_peak_low_rate_gbp_per_kwh_via_meter_heuristic(meter_type: object) -> float:
"""Off-peak low-rate £/kWh for callsites that detect off-peak via the
`_is_off_peak_meter` heuristic (RdSAP meter code 3 = Unknown is
treated as off-peak for electric end-uses; see _is_off_peak_meter
docstring). When the meter resolves to a known off-peak tariff
(codes 1/4/5), bills at that tariff's Table 32 low rate; when the
meter resolves to STANDARD (codes 2 = Single, 3 = Unknown), falls
back to the SEVEN_HOUR rate (5.50, Table 32 code 31). Codifies the
heuristic that pre-S0380.138 was baked into the literal
`prices.e7_low_rate_p_per_kwh` constant."""
tariff = tariff_from_meter_type(meter_type)
if tariff is Tariff.STANDARD:
_high, low = _tariff_high_low_rates_p_per_kwh(Tariff.SEVEN_HOUR)
return low * _PENCE_TO_GBP
return _off_peak_low_rate_gbp_per_kwh(tariff)
# Tariff → (high_rate_fuel_code, low_rate_fuel_code) for the SAP 10.2 # Tariff → (high_rate_fuel_code, low_rate_fuel_code) for the SAP 10.2
# Table 12d (CO2) / Table 12e (PE) monthly factors. Mirror of the # Table 12d (CO2) / Table 12e (PE) monthly factors. Mirror of the
# Table 32 cost-rates dict above: 7-hour and 10-hour tariffs split into # Table 32 cost-rates dict above: 7-hour and 10-hour tariffs split into
@ -1276,19 +1307,18 @@ def _space_heating_fuel_cost_gbp_per_kwh(
on an off-peak tariff, applies the SAP 10.2 Table 12a Grid 1 SH on an off-peak tariff, applies the SAP 10.2 Table 12a Grid 1 SH
high-rate fraction blended scalar rate. Mathematically equivalent high-rate fraction blended scalar rate. Mathematically equivalent
to splitting kWh into high and low components and pricing each to splitting kWh into high and low components and pricing each
separately at Table 32 rates.""" separately at Table 32 rates. When Grid 1 has no SH row yet for the
electric system (storage / direct-acting / UFH coverage queued),
falls back to the tariff's 100% low-rate per Table 32."""
if not _is_electric_main(main) or tariff is Tariff.STANDARD: if not _is_electric_main(main) or tariff is Tariff.STANDARD:
return _fuel_cost_gbp_per_kwh(main, prices) return _fuel_cost_gbp_per_kwh(main, prices)
system = _table_12a_system_for_main(main) system = _table_12a_system_for_main(main)
if system is None: if system is None:
# No Table 12a SH row yet for this electric system — preserve return _off_peak_low_rate_gbp_per_kwh(tariff)
# the pre-Table-12a all-low fallback (storage heaters / direct-
# acting / underfloor coverage queued).
return prices.e7_low_rate_p_per_kwh * _PENCE_TO_GBP
try: try:
high_frac = space_heating_high_rate_fraction(system, tariff) high_frac = space_heating_high_rate_fraction(system, tariff)
except NotImplementedError: except NotImplementedError:
return prices.e7_low_rate_p_per_kwh * _PENCE_TO_GBP return _off_peak_low_rate_gbp_per_kwh(tariff)
high_rate, low_rate = _tariff_high_low_rates_p_per_kwh(tariff) high_rate, low_rate = _tariff_high_low_rates_p_per_kwh(tariff)
blended = high_frac * high_rate + (1.0 - high_frac) * low_rate blended = high_frac * high_rate + (1.0 - high_frac) * low_rate
return blended * _PENCE_TO_GBP return blended * _PENCE_TO_GBP
@ -1310,7 +1340,7 @@ def _hot_water_fuel_cost_gbp_per_kwh(
electric WH on off-peak (currently uses 100% low rate).""" electric WH on off-peak (currently uses 100% low rate)."""
water_electric = _is_electric_water(water_heating_fuel) water_electric = _is_electric_water(water_heating_fuel)
if water_electric and tariff is not Tariff.STANDARD: if water_electric and tariff is not Tariff.STANDARD:
return prices.e7_low_rate_p_per_kwh * _PENCE_TO_GBP return _off_peak_low_rate_gbp_per_kwh(tariff)
if water_heating_fuel is not None: if water_heating_fuel is not None:
return prices.unit_price_p_per_kwh(water_heating_fuel) * _PENCE_TO_GBP return prices.unit_price_p_per_kwh(water_heating_fuel) * _PENCE_TO_GBP
return _fuel_cost_gbp_per_kwh(main, prices) return _fuel_cost_gbp_per_kwh(main, prices)
@ -1385,13 +1415,13 @@ def _secondary_fuel_cost_gbp_per_kwh(
# Default to electricity since the default secondary system is # Default to electricity since the default secondary system is
# portable electric heaters (code 693). # portable electric heaters (code 693).
if _is_off_peak_meter(meter_type, fuel_is_electric=True): if _is_off_peak_meter(meter_type, fuel_is_electric=True):
return prices.e7_low_rate_p_per_kwh * _PENCE_TO_GBP return _off_peak_low_rate_gbp_per_kwh_via_meter_heuristic(meter_type)
return prices.standard_electricity_p_per_kwh * _PENCE_TO_GBP return prices.standard_electricity_p_per_kwh * _PENCE_TO_GBP
# When secondary_fuel_type is electricity, apply off-peak if applicable. # When secondary_fuel_type is electricity, apply off-peak if applicable.
if _is_electric_water(sec_fuel) and _is_off_peak_meter( if _is_electric_water(sec_fuel) and _is_off_peak_meter(
meter_type, fuel_is_electric=True meter_type, fuel_is_electric=True
): ):
return prices.e7_low_rate_p_per_kwh * _PENCE_TO_GBP return _off_peak_low_rate_gbp_per_kwh_via_meter_heuristic(meter_type)
return prices.unit_price_p_per_kwh(sec_fuel) * _PENCE_TO_GBP return prices.unit_price_p_per_kwh(sec_fuel) * _PENCE_TO_GBP
@ -1669,7 +1699,11 @@ def _pv_dwelling_import_price_gbp_per_kwh(
if _is_off_peak_meter(meter_type, fuel_is_electric=True): if _is_off_peak_meter(meter_type, fuel_is_electric=True):
# Off-peak weighted Table 12a rate (deferred — `_fuel_cost` # Off-peak weighted Table 12a rate (deferred — `_fuel_cost`
# short-circuits Tariff != STANDARD before reaching this path). # short-circuits Tariff != STANDARD before reaching this path).
return prices.e7_low_rate_p_per_kwh * _PENCE_TO_GBP # Routes through the meter-heuristic helper so an Unknown-meter
# cert (code 3 = "treat as off-peak for electric end-uses" per
# _is_off_peak_meter) falls back to the SEVEN_HOUR low rate
# rather than raising on STANDARD.
return _off_peak_low_rate_gbp_per_kwh_via_meter_heuristic(meter_type)
return table_32_unit_price_p_per_kwh(30) * _PENCE_TO_GBP return table_32_unit_price_p_per_kwh(30) * _PENCE_TO_GBP

View file

@ -38,6 +38,7 @@ from domain.sap10_calculator.exceptions import (
UnmappedSapCode, UnmappedSapCode,
) )
from domain.sap10_calculator.rdsap.cert_to_inputs import ( from domain.sap10_calculator.rdsap.cert_to_inputs import (
SAP_10_2_SPEC_PRICES,
_has_suspended_timber_floor_per_spec, # pyright: ignore[reportPrivateUsage] _has_suspended_timber_floor_per_spec, # pyright: ignore[reportPrivateUsage]
_heat_network_dlf, # pyright: ignore[reportPrivateUsage] _heat_network_dlf, # pyright: ignore[reportPrivateUsage]
_is_electric_main, # pyright: ignore[reportPrivateUsage] _is_electric_main, # pyright: ignore[reportPrivateUsage]
@ -47,6 +48,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
_pv_pitch_deg, # pyright: ignore[reportPrivateUsage] _pv_pitch_deg, # pyright: ignore[reportPrivateUsage]
_responsiveness, # pyright: ignore[reportPrivateUsage] _responsiveness, # pyright: ignore[reportPrivateUsage]
_secondary_heating_fraction_for_category, # pyright: ignore[reportPrivateUsage] _secondary_heating_fraction_for_category, # pyright: ignore[reportPrivateUsage]
_space_heating_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage]
_tariff_high_low_rates_p_per_kwh, # pyright: ignore[reportPrivateUsage] _tariff_high_low_rates_p_per_kwh, # pyright: ignore[reportPrivateUsage]
_water_heating_worksheet_and_gains, # pyright: ignore[reportPrivateUsage] _water_heating_worksheet_and_gains, # pyright: ignore[reportPrivateUsage]
cert_to_demand_inputs, cert_to_demand_inputs,
@ -1394,6 +1396,40 @@ def test_tariff_high_low_rates_full_dispatch_coverage() -> None:
assert excinfo.value.field == "tariff_high_low_rates" assert excinfo.value.field == "tariff_high_low_rates"
def test_space_heating_off_peak_fallback_uses_actual_tariff_low_rate_not_e7() -> None:
# Arrange — an electric storage heater (SAP code 401) on an 18-hour
# tariff. `_table_12a_system_for_main` returns None for storage
# heaters (Grid 1 SH coverage is queued — see _table_12a_system_for_
# main docstring), so the helper hits the "100% low-rate" fallback
# branch. Per RdSAP 10 §19 Table 32 (p.95) the low-rate price varies
# by tariff: code 31 (7-hour low) = 5.50 p/kWh, code 40 (18-hour
# low) = 7.41 p/kWh. Pre-fix the fallback hardcoded
# `prices.e7_low_rate_p_per_kwh` (5.50) for every off-peak tariff —
# an 18-hour cert paid 5.50 instead of 7.41, under-counting cost by
# 1.91 p/kWh × annual SH kWh. The eight 18-hour electric corpus
# variants share this gap (cost residual £135..£222, SAP +5.8..
# +9.6). The fix routes through `_tariff_high_low_rates_p_per_kwh`
# so each tariff bills at its own Table 32 low-rate code.
from domain.sap10_calculator.tables.table_12a import Tariff
storage_heater_main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=30, # Table 32 code 30 = standard electricity
heat_emitter_type=2,
emitter_temperature=1,
main_heating_control=2100,
main_heating_category=4,
sap_main_heating_code=401, # storage heater (Grid 1 SH row TODO)
)
# Act — 18-hour tariff fallback (no Table 12a Grid 1 row yet)
cost_eighteen_hour = _space_heating_fuel_cost_gbp_per_kwh(
storage_heater_main, Tariff.EIGHTEEN_HOUR, prices=SAP_10_2_SPEC_PRICES,
)
# Assert — 18-hour low-rate = 7.41 p/kWh (Table 32 code 40)
assert abs(cost_eighteen_hour - 0.0741) <= 1e-6
def test_heat_network_dlf_full_table_12c_age_band_coverage() -> None: def test_heat_network_dlf_full_table_12c_age_band_coverage() -> None:
# Arrange — SAP 10.2 Table 12c (page 193) heat-network Distribution # Arrange — SAP 10.2 Table 12c (page 193) heat-network Distribution
# Loss Factor by dwelling age band A..M. None → K-or-newer # Loss Factor by dwelling age band A..M. None → K-or-newer