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 from +6.9..+14.7 to +5.8..+9.4. electric 5/8/9 close to
# ±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, ...] = (
_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='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 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 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 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 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 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 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 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='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='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=-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=-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=-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=-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=-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=-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=-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=-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=+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 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),
@ -180,14 +203,14 @@ _EXPECTATIONS: tuple[_CorpusExpectation, ...] = (
# 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 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 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 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 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 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 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 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 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 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=-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=-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=+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=-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=-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=-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=-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).
`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`
is the off-peak rate used for E7-eligible space heating, and
code; implementations translate before lookup.
`standard_electricity_p_per_kwh` is the rate applied to lighting +
pumps + fans regardless of main fuel. `e7_eligible_main_codes` lists
the SAP Table 4a main-heating codes that bill space heating at
`e7_low_rate_p_per_kwh` narrower under the spec (storage heaters
only per Table 12a) than under cert calibration (the cert assessor
appears to apply off-peak to direct-electric too).
the SAP Table 4a main-heating codes that bill space heating at the
tariff's off-peak low-rate — narrower under the spec (storage
heaters only per Table 12a) than under cert calibration (the cert
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]
e7_low_rate_p_per_kwh: float
standard_electricity_p_per_kwh: float
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).
#
# Wired into `cert_to_inputs` as the default PriceTable per ADR-0010
# §10a amendment (2026-05-21). Off-peak fallback scalars
# (`hot_water_fuel_cost_gbp_per_kwh` etc.) read `unit_price_p_per_kwh`
# directly so this is where the cohort-wide tariff lands.
# §10a amendment (2026-05-21). Off-peak low-rates are looked up
# tariff-by-tariff via `_off_peak_low_rate_gbp_per_kwh`
# (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(
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
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)
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
# 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
@ -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
high-rate fraction blended scalar rate. Mathematically equivalent
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:
return _fuel_cost_gbp_per_kwh(main, prices)
system = _table_12a_system_for_main(main)
if system is None:
# No Table 12a SH row yet for this electric system — preserve
# 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
return _off_peak_low_rate_gbp_per_kwh(tariff)
try:
high_frac = space_heating_high_rate_fraction(system, tariff)
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)
blended = high_frac * high_rate + (1.0 - high_frac) * low_rate
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)."""
water_electric = _is_electric_water(water_heating_fuel)
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:
return prices.unit_price_p_per_kwh(water_heating_fuel) * _PENCE_TO_GBP
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
# portable electric heaters (code 693).
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
# 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 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
@ -1669,7 +1699,11 @@ def _pv_dwelling_import_price_gbp_per_kwh(
if _is_off_peak_meter(meter_type, fuel_is_electric=True):
# Off-peak weighted Table 12a rate (deferred — `_fuel_cost`
# 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

View file

@ -38,6 +38,7 @@ from domain.sap10_calculator.exceptions import (
UnmappedSapCode,
)
from domain.sap10_calculator.rdsap.cert_to_inputs import (
SAP_10_2_SPEC_PRICES,
_has_suspended_timber_floor_per_spec, # pyright: ignore[reportPrivateUsage]
_heat_network_dlf, # 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]
_responsiveness, # 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]
_water_heating_worksheet_and_gains, # pyright: ignore[reportPrivateUsage]
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"
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:
# Arrange — SAP 10.2 Table 12c (page 193) heat-network Distribution
# Loss Factor by dwelling age band A..M. None → K-or-newer