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, unit_price_p_per_kwh,
) )
from domain.sap10_calculator.tables.table_12a import ( from domain.sap10_calculator.tables.table_12a import (
OtherUse,
Table12aSystem,
Tariff, Tariff,
other_use_high_rate_fraction,
rdsap_tariff_for_cert,
space_heating_high_rate_fraction,
tariff_from_meter_type, tariff_from_meter_type,
) )
from domain.sap10_calculator.tables.table_32 import ( from domain.sap10_calculator.tables.table_32 import (
@ -588,6 +593,38 @@ def _water_heating_main(
return details[0] 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]: def _water_heating_fuel_code(epc: EpcPropertyData) -> Optional[int]:
"""Fuel code for water heating per the cert's WHC routing. Prefers """Fuel code for water heating per the cert's WHC routing. Prefers
an explicitly-lodged `water_heating_fuel`; otherwise falls back to 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 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( def _space_heating_fuel_cost_gbp_per_kwh(
main: Optional[MainHeatingDetail], main: Optional[MainHeatingDetail],
meter_type: object, tariff: Tariff,
prices: PriceTable, prices: PriceTable,
) -> float: ) -> float:
"""Space heating bills at the main fuel's rate. When the dwelling is """Space heating bills at the main fuel's rate. For electric mains
on an off-peak tariff (meter_type != standard) AND the main fuel is on an off-peak tariff, applies the SAP 10.2 Table 12a Grid 1 SH
electricity, bill at the off-peak rate instead. Trusts the cert's high-rate fraction blended scalar rate. Mathematically equivalent
meter_type rather than inferring tariff from heating code. to splitting kWh into high and low components and pricing each
separately at Table 32 rates."""
TODO: SAP 10.2 Table 12a applies a per-system high/low rate split if not _is_electric_main(main) or tariff is Tariff.STANDARD:
rather than the binary all-low / all-high implemented here. For return _fuel_cost_gbp_per_kwh(main, prices)
HP carriers on E7 the split is ~33% high / 67% low (cert 000565 system = _table_12a_system_for_main(main)
empirically implies that split); single-rate biases the cost if system is None:
£±1k vs the worksheet. Table 12a needs its own cascade slice.""" # No Table 12a SH row yet for this electric system — preserve
if _is_electric_main(main) and _is_off_peak_meter(meter_type, fuel_is_electric=True): # 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 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( def _hot_water_fuel_cost_gbp_per_kwh(
water_heating_fuel: Optional[int], water_heating_fuel: Optional[int],
main: Optional[MainHeatingDetail], main: Optional[MainHeatingDetail],
meter_type: object, tariff: Tariff,
prices: PriceTable, prices: PriceTable,
) -> float: ) -> float:
"""Hot water bills at the *water-heating* fuel's rate. When the """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 water-heating fuel is electric AND tariff is off-peak, bill at the
electricity (immersion etc.), bill HW at the off-peak rate too off-peak rate (immersion / HP DHW running on the timer). When the
the cert assessor treats the immersion as running on the timer.""" 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) 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 return prices.e7_low_rate_p_per_kwh * _PENCE_TO_GBP
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
@ -1136,16 +1234,27 @@ def _pv_dwelling_import_price_gbp_per_kwh(
def _other_fuel_cost_gbp_per_kwh( def _other_fuel_cost_gbp_per_kwh(
meter_type: object, prices: PriceTable tariff: Tariff, prices: PriceTable
) -> float: ) -> float:
"""Pumps, fans, and lighting are always electric. When the dwelling """Pumps, fans, and lighting are always electric. When the dwelling
is on an off-peak tariff, billing splits between off-peak and high is on an off-peak tariff, applies the Table 12a Grid 2
rates per Table 12a (~0.90 high-rate, 0.10 low-rate for "other ALL_OTHER_USES high-rate fraction blended Table 32 rate. Standard
uses"). Empirically the cert software applies the standard rate tariff bypasses to the prices table's flat scalar (preserves the
here regardless of meter type, so we keep `standard_electricity_p_per_kwh` cohort fixture cost cascade at 1e-4)."""
even for off-peak dwellings.""" if tariff is Tariff.STANDARD:
_ = meter_type return prices.standard_electricity_p_per_kwh * _PENCE_TO_GBP
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 # 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, pumps_fans_kwh_per_yr=pumps_fans_kwh,
lighting_kwh_per_yr=lighting_kwh, lighting_kwh_per_yr=lighting_kwh,
space_heating_fuel_cost_gbp_per_kwh=_space_heating_fuel_cost_gbp_per_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( hot_water_fuel_cost_gbp_per_kwh=_hot_water_fuel_cost_gbp_per_kwh(
_water_heating_fuel_code(epc), _water_heating_fuel_code(epc),
_water_heating_main(epc), _water_heating_main(epc),
epc.sap_energy_source.meter_type, _rdsap_tariff(epc),
prices, prices,
), ),
other_fuel_cost_gbp_per_kwh=_other_fuel_cost_gbp_per_kwh( 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), co2_factor_kg_per_kwh=_co2_factor_kg_per_kwh(main),
# SAP10.2 Table 12d (p.194) per-end-use effective CO2 factors. For # 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: def test_off_peak_meter_routes_electric_costs_to_low_rate() -> None:
# Arrange — RdSAP rule (per S-B15): we trust the cert's lodged # Arrange — RdSAP 10 §12 page 62: Dual meter + storage heater (SAP
# meter_type as the tariff source of truth. SAP10 code 2 = off-peak # code 402) → Rule 2 → 7-hour tariff. Electric SH and electric HW
# (Economy-7 dual rate). On an off-peak meter, electric space heating # bill at the 7h low rate (E7 low fallback for systems without a
# and electric hot water bill at the 7h-low rate (9.4p/kWh). Other # Table 12a SH row yet). Other electric uses (lighting + pumps)
# electric uses (lighting + pumps) stay on standard rate. # 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( epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2, total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=3, 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 # Act
inputs = cert_to_inputs(epc) inputs = cert_to_inputs(epc)
@ -845,7 +848,7 @@ def test_off_peak_meter_routes_electric_costs_to_low_rate() -> None:
# Assert # Assert
assert inputs.space_heating_fuel_cost_gbp_per_kwh == 0.094 assert inputs.space_heating_fuel_cost_gbp_per_kwh == 0.094
assert inputs.hot_water_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: def test_standard_meter_keeps_electric_costs_on_standard_rate() -> None: