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: