Slice S0380.61: wire RdSAP §12 dispatch + Table 12a high-rate fractions into cost scalars

Builds on S0380.60. The three scalar fuel-cost helpers
(`_space_heating_fuel_cost_gbp_per_kwh`, `_hot_water_fuel_cost_gbp_
per_kwh`, `_other_fuel_cost_gbp_per_kwh`) now consume a
`tariff: Tariff` argument computed once at the call site via
`_rdsap_tariff(epc)` — replacing the previous binary all-low /
all-high override that biased HP-on-Dual-meter cost by £±1k on
cert 000565.

Three pieces wired:
1. `_rdsap_tariff(epc)` — applies §12 dispatch consulting BOTH
   main heating systems (per "the main system or either main
   system if there are two") + PCDB Table 362 "or database"
   branch. Replaces `tariff_from_meter_type(meter_type)` at the
   three cost-helper call sites.
2. `_TARIFF_HIGH_LOW_RATES_P_PER_KWH` — RdSAP 10 Table 32 page 95
   (high, low) p/kWh tuples per Tariff enum. Codes 31/32 (E7),
   33/34 (E10), 38/40 (E18), 35 (24-hour single rate).
3. `_table_12a_system_for_main(main)` — maps a Table 4a SAP code
   (211-217, 221-227, 521-524) to the Grid 1 SH row:
   `ASHP_APP_N` (when PCDB Table 362 record) or `ASHP_OTHER`
   (default). Other electric carriers (storage 401-409, underfloor
   421-422, electric boilers 191-196, CPSU 192) return None until
   a fixture surfaces them — those mains fall back to the
   pre-Table-12a `e7_low_rate_p_per_kwh` scalar.

Cost helpers now:
- `_space_heating_fuel_cost_gbp_per_kwh(main, tariff, prices)`:
  Off-peak + electric main + `Table12aSystem` recognised →
  blended rate = high_frac × high_rate + (1-high_frac) × low_rate.
  STANDARD or unknown Table12aSystem → preserve legacy fallback.
- `_other_fuel_cost_gbp_per_kwh(tariff, prices)`: Off-peak →
  blended via Grid 2 `ALL_OTHER_USES` row (0.90 high on 7-hour,
  0.80 high on 10-hour).
- `_hot_water_fuel_cost_gbp_per_kwh(water_fuel, main, tariff,
  prices)`: signature swap (meter_type → tariff) for consistency.
  Behavioural change deferred (HW Grid 1 WH-row split is its own
  slice).

Cert 000565 cascade impact (HP code 224 + Dual → §12 Rule 3 →
TEN_HOUR + Table 12a ASHP_OTHER SH 0.60 high, ALL_OTHER_USES 0.80
high):
- space_heating tariff: 0.094 → 0.11808 ✓ matches worksheet
- other_fuel tariff:     0.165 → 0.13244 ✓ matches worksheet
- hot_water tariff:      gas 0.0364 (Table 12 mains gas) — vs
  worksheet 0.0348 (Table 32 mains gas; price-table divergence is
  a separate concern outside this slice)
- total_fuel_cost_gbp:   Δ −1,081 → −310 (71% reduction)
- sap_score_continuous:  Δ +13.81 → +3.61

Cohort regression check: 427 pass + 10 expected 000565 fails.
Test `test_off_peak_meter_routes_electric_costs_to_low_rate`
updated to expect the spec-correct Table 12a-blended 0.14311
(was 0.1649 under the pre-S0380.61 "empirical" override).

Spec source: SAP 10.2 Table 12a Grid 1 (SH) + Grid 2 (other uses)
page 191. RdSAP 10 §12 page 62 dispatch (verified Slice S0380.60).
RdSAP 10 Table 32 page 95 prices.

Pyright net-zero on both touched files (34 / 11; baseline 34 / 11).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-28 23:55:23 +00:00 committed by Jun-te Kim
parent a12d373eaf
commit efcd37e2d2
2 changed files with 148 additions and 36 deletions

View file

@ -91,7 +91,12 @@ from domain.sap10_calculator.tables.table_12 import (
unit_price_p_per_kwh,
)
from domain.sap10_calculator.tables.table_12a import (
OtherUse,
Table12aSystem,
Tariff,
other_use_high_rate_fraction,
rdsap_tariff_for_cert,
space_heating_high_rate_fraction,
tariff_from_meter_type,
)
from domain.sap10_calculator.tables.table_32 import (
@ -588,6 +593,38 @@ def _water_heating_main(
return details[0]
def _rdsap_tariff(epc: EpcPropertyData) -> Tariff:
"""Resolve the cert's Table 12a tariff column via RdSAP 10 §12
Rules 1-4 (page 62). Consults BOTH main heating systems §12
says "the main system (or either main system if there are two)"
for the rules. The "or database" Rule 3 branch fires when a main
lodges a PCDB Table 362 heat-pump record (regardless of SAP
code).
Cert 000565 (Main 1 ASHP SAP 224 + Main 2 gas combi PCDB 15100,
Dual meter) Rule 3 on Main 1 TEN_HOUR, matching the
worksheet's "10 Hour Off Peak" lodging.
"""
details = epc.sap_heating.main_heating_details if epc.sap_heating else []
main_1 = details[0] if details else None
main_2 = details[1] if len(details) >= 2 else None
def _hp_db(detail: Optional[MainHeatingDetail]) -> bool:
return (
detail is not None
and detail.main_heating_index_number is not None
and heat_pump_record(detail.main_heating_index_number) is not None
)
return rdsap_tariff_for_cert(
epc.sap_energy_source.meter_type,
main_1_sap_code=main_1.sap_main_heating_code if main_1 else None,
main_2_sap_code=main_2.sap_main_heating_code if main_2 else None,
main_1_is_heat_pump_database=_hp_db(main_1),
main_2_is_heat_pump_database=_hp_db(main_2),
)
def _water_heating_fuel_code(epc: EpcPropertyData) -> Optional[int]:
"""Fuel code for water heating per the cert's WHC routing. Prefers
an explicitly-lodged `water_heating_fuel`; otherwise falls back to
@ -749,38 +786,99 @@ def _is_electric_water(water_heating_fuel: Optional[int]) -> bool:
return False
# RdSAP 10 Table 32 (page 95) — (high_rate_p, low_rate_p) per tariff.
# Codes 31-34 cover E7/E10 directly; 38/40 cover 18-hour; 35 is the
# single-rate 24-hour heating tariff (no high/low split).
_TARIFF_HIGH_LOW_RATES_P_PER_KWH: Final[dict[Tariff, tuple[float, float]]] = {
Tariff.SEVEN_HOUR: (15.29, 5.50), # Table 32 codes 32, 31
Tariff.TEN_HOUR: (14.68, 7.50), # Table 32 codes 34, 33
Tariff.EIGHTEEN_HOUR: (13.67, 7.41), # Table 32 codes 38, 40
Tariff.TWENTY_FOUR_HOUR: (6.61, 6.61), # Table 32 code 35 (no split)
}
def _table_12a_system_for_main(
main: Optional[MainHeatingDetail],
) -> Optional[Table12aSystem]:
"""Map a main heating system to its Table 12a Grid 1 (SH) row.
Heat pumps lodge as `ASHP_APP_N` when a PCDB Table 362 record is
available (Appendix N efficiency cascade) and `ASHP_OTHER`
otherwise. The "other" rows split by water-heating route for
SH-cost purposes the differentiation doesn't matter (the SH
column carries the same fraction across ASHP_OTHER / _OFF_PEAK_
IMMERSION / _NO_IMMERSION on Grid 1), so ASHP_OTHER is the
canonical default.
Coverage as fixtures land:
- ASHP / GSHP (codes 211-224, 521-524, PCDB index) wired
- Storage heaters (401-409) TODO
- Underfloor heating (421-422) TODO
- Direct-acting electric (191) / CPSU (192) / electric storage
boiler (193, 195) TODO
"""
if main is None:
return None
code = main.sap_main_heating_code
has_pcdb_hp = (
main.main_heating_index_number is not None
and heat_pump_record(main.main_heating_index_number) is not None
)
# ASHP — Table 4a rows 211-217 (earlier generations) + 221-227
# (2013+) cover the air-source space. Warm-air ASHPs are 521-524.
if code is not None and (
211 <= code <= 217 or 221 <= code <= 227 or 521 <= code <= 524
):
return Table12aSystem.ASHP_APP_N if has_pcdb_hp else Table12aSystem.ASHP_OTHER
return None
def _space_heating_fuel_cost_gbp_per_kwh(
main: Optional[MainHeatingDetail],
meter_type: object,
tariff: Tariff,
prices: PriceTable,
) -> float:
"""Space heating bills at the main fuel's rate. When the dwelling is
on an off-peak tariff (meter_type != standard) AND the main fuel is
electricity, bill at the off-peak rate instead. Trusts the cert's
meter_type rather than inferring tariff from heating code.
TODO: SAP 10.2 Table 12a applies a per-system high/low rate split
rather than the binary all-low / all-high implemented here. For
HP carriers on E7 the split is ~33% high / 67% low (cert 000565
empirically implies that split); single-rate biases the cost
£±1k vs the worksheet. Table 12a needs its own cascade slice."""
if _is_electric_main(main) and _is_off_peak_meter(meter_type, fuel_is_electric=True):
"""Space heating bills at the main fuel's rate. For electric mains
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."""
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 _fuel_cost_gbp_per_kwh(main, prices)
try:
high_frac = space_heating_high_rate_fraction(system, tariff)
except NotImplementedError:
return prices.e7_low_rate_p_per_kwh * _PENCE_TO_GBP
rates = _TARIFF_HIGH_LOW_RATES_P_PER_KWH.get(tariff)
if rates is None:
return prices.e7_low_rate_p_per_kwh * _PENCE_TO_GBP
high_rate, low_rate = rates
blended = high_frac * high_rate + (1.0 - high_frac) * low_rate
return blended * _PENCE_TO_GBP
def _hot_water_fuel_cost_gbp_per_kwh(
water_heating_fuel: Optional[int],
main: Optional[MainHeatingDetail],
meter_type: object,
tariff: Tariff,
prices: PriceTable,
) -> float:
"""Hot water bills at the *water-heating* fuel's rate. When the
dwelling is on an off-peak tariff AND the water-heating fuel is
electricity (immersion etc.), bill HW at the off-peak rate too
the cert assessor treats the immersion as running on the timer."""
water-heating fuel is electric AND tariff is off-peak, bill at the
off-peak rate (immersion / HP DHW running on the timer). When the
water fuel is a non-electric fuel (gas / oil / LPG), tariff is
not consulted those fuels are single-rate per Table 32. For
cert 000565 HW routes to gas combi via WHC 914 tariff branch
not taken. TODO: Table 12a Grid 1 WH high-rate-fraction split for
electric WH on off-peak (currently uses 100% low rate)."""
water_electric = _is_electric_water(water_heating_fuel)
if water_electric and _is_off_peak_meter(meter_type, fuel_is_electric=True):
if water_electric and tariff is not Tariff.STANDARD:
return prices.e7_low_rate_p_per_kwh * _PENCE_TO_GBP
if water_heating_fuel is not None:
return prices.unit_price_p_per_kwh(water_heating_fuel) * _PENCE_TO_GBP
@ -1136,16 +1234,27 @@ def _pv_dwelling_import_price_gbp_per_kwh(
def _other_fuel_cost_gbp_per_kwh(
meter_type: object, prices: PriceTable
tariff: Tariff, prices: PriceTable
) -> float:
"""Pumps, fans, and lighting are always electric. When the dwelling
is on an off-peak tariff, billing splits between off-peak and high
rates per Table 12a (~0.90 high-rate, 0.10 low-rate for "other
uses"). Empirically the cert software applies the standard rate
here regardless of meter type, so we keep `standard_electricity_p_per_kwh`
even for off-peak dwellings."""
_ = meter_type
return prices.standard_electricity_p_per_kwh * _PENCE_TO_GBP
is on an off-peak tariff, applies the Table 12a Grid 2
ALL_OTHER_USES high-rate fraction blended Table 32 rate. Standard
tariff bypasses to the prices table's flat scalar (preserves the
cohort fixture cost cascade at 1e-4)."""
if tariff is Tariff.STANDARD:
return prices.standard_electricity_p_per_kwh * _PENCE_TO_GBP
try:
high_frac = other_use_high_rate_fraction(
OtherUse.ALL_OTHER_USES, tariff,
)
except NotImplementedError:
return prices.standard_electricity_p_per_kwh * _PENCE_TO_GBP
rates = _TARIFF_HIGH_LOW_RATES_P_PER_KWH.get(tariff)
if rates is None:
return prices.standard_electricity_p_per_kwh * _PENCE_TO_GBP
high_rate, low_rate = rates
blended = high_frac * high_rate + (1.0 - high_frac) * low_rate
return blended * _PENCE_TO_GBP
# Water-heating codes that say "inherit from the main system" — the
@ -3285,16 +3394,16 @@ def cert_to_inputs(
pumps_fans_kwh_per_yr=pumps_fans_kwh,
lighting_kwh_per_yr=lighting_kwh,
space_heating_fuel_cost_gbp_per_kwh=_space_heating_fuel_cost_gbp_per_kwh(
main, epc.sap_energy_source.meter_type, prices
main, _rdsap_tariff(epc), prices
),
hot_water_fuel_cost_gbp_per_kwh=_hot_water_fuel_cost_gbp_per_kwh(
_water_heating_fuel_code(epc),
_water_heating_main(epc),
epc.sap_energy_source.meter_type,
_rdsap_tariff(epc),
prices,
),
other_fuel_cost_gbp_per_kwh=_other_fuel_cost_gbp_per_kwh(
epc.sap_energy_source.meter_type, prices
_rdsap_tariff(epc), prices
),
co2_factor_kg_per_kwh=_co2_factor_kg_per_kwh(main),
# SAP10.2 Table 12d (p.194) per-end-use effective CO2 factors. For

View file

@ -805,11 +805,14 @@ def test_main_heating_control_code_maps_to_sap_control_type() -> None:
def test_off_peak_meter_routes_electric_costs_to_low_rate() -> None:
# Arrange — RdSAP rule (per S-B15): we trust the cert's lodged
# meter_type as the tariff source of truth. SAP10 code 2 = off-peak
# (Economy-7 dual rate). On an off-peak meter, electric space heating
# and electric hot water bill at the 7h-low rate (9.4p/kWh). Other
# electric uses (lighting + pumps) stay on standard rate.
# Arrange — RdSAP 10 §12 page 62: Dual meter + storage heater (SAP
# code 402) → Rule 2 → 7-hour tariff. Electric SH and electric HW
# bill at the 7h low rate (E7 low fallback for systems without a
# Table 12a SH row yet). Other electric uses (lighting + pumps)
# now apply Table 12a Grid 2 ALL_OTHER_USES + SEVEN_HOUR = 0.90
# high → blended 0.90 * 15.29 + 0.10 * 5.50 = 14.311 p/kWh per
# Slice S0380.61 (was 16.49 under the pre-Table-12a empirical
# override).
epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=3,
@ -837,7 +840,7 @@ def test_off_peak_meter_routes_electric_costs_to_low_rate() -> None:
],
),
)
epc.sap_energy_source.meter_type = 1 # off-peak (empirical SAP10 enum)
epc.sap_energy_source.meter_type = 1 # Dual → §12 dispatch → 7-hour for storage
# Act
inputs = cert_to_inputs(epc)
@ -845,7 +848,7 @@ def test_off_peak_meter_routes_electric_costs_to_low_rate() -> None:
# Assert
assert inputs.space_heating_fuel_cost_gbp_per_kwh == 0.094
assert inputs.hot_water_fuel_cost_gbp_per_kwh == 0.094
assert inputs.other_fuel_cost_gbp_per_kwh == 0.1649
assert abs(inputs.other_fuel_cost_gbp_per_kwh - 0.14311) < 1e-5
def test_standard_meter_keeps_electric_costs_on_standard_rate() -> None: