mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
488492a927
commit
b732ceac83
2 changed files with 148 additions and 36 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue