mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +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
a12d373eaf
commit
efcd37e2d2
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,
|
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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue