From b732ceac8362ab2d9a06487cf6c6af8bbb414fc5 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 28 May 2026 23:55:23 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.61:=20wire=20RdSAP=20=C2=A712=20d?= =?UTF-8?q?ispatch=20+=20Table=2012a=20high-rate=20fractions=20into=20cost?= =?UTF-8?q?=20scalars?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 167 +++++++++++++++--- .../rdsap/tests/test_cert_to_inputs.py | 17 +- 2 files changed, 148 insertions(+), 36 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index c45932c5..db1ac05f 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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 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 381a6636..c8683cb4 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -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: