From 6a7bf3e07447d906be285d4bce7caf5b148e62c8 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 31 May 2026 18:22:54 +0000 Subject: [PATCH] Slice S0380.138: route every off-peak callsite through the per-tariff Table 32 low-rate (electric +5..+9 SAP cluster + spillover) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../tests/test_heating_systems_corpus.py | 59 +++++++++----- .../sap10_calculator/rdsap/cert_to_inputs.py | 76 ++++++++++++++----- .../rdsap/tests/test_cert_to_inputs.py | 36 +++++++++ 3 files changed, 132 insertions(+), 39 deletions(-) diff --git a/backend/documents_parser/tests/test_heating_systems_corpus.py b/backend/documents_parser/tests/test_heating_systems_corpus.py index 26ff5e00..fc72783c 100644 --- a/backend/documents_parser/tests/test_heating_systems_corpus.py +++ b/backend/documents_parser/tests/test_heating_systems_corpus.py @@ -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), ) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 8f89ddd8..c52801df 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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 diff --git a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py index ea4a87a8..dda4a481 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -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