diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 8227764b..1614ef05 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -6927,6 +6927,26 @@ def _fuel_cost( main_1_high_rate_gbp_per_kwh = ( table_32_unit_price_p_per_kwh(main_fuel_code) * _PENCE_TO_GBP ) + # Main heating system 2 is costed at ITS OWN fuel price (SAP 10.2 §10a + # worksheet line (213) bills main system 2's fuel separately from main 1's + # (211)) — Table 32 unit price keyed on main 2's fuel code. Pre-fix this + # column reused `main_1_high_rate_gbp_per_kwh`, charging a dual-fuel second + # main (e.g. wood logs SAP code 633, fuel 6 @ 4.23 p/kWh) at the main-1 + # electric rate (13.19 p/kWh), grossly over-costing electric+wood + # room-heater dwellings (cert 10032957680 "Copse Cottage" +£850/yr -> + # SAP -23). Falls back to main 1's price only when no second main is lodged. + main_2_detail = ( + epc.sap_heating.main_heating_details[1] + if epc.sap_heating + and len(epc.sap_heating.main_heating_details or []) >= 2 + else None + ) + main_2_fuel_code = _main_fuel_code(main_2_detail) + main_2_high_rate_gbp_per_kwh = ( + table_32_unit_price_p_per_kwh(main_2_fuel_code) * _PENCE_TO_GBP + if main_2_fuel_code is not None + else main_1_high_rate_gbp_per_kwh + ) water_high_rate_gbp_per_kwh = ( table_32_unit_price_p_per_kwh(water_heating_fuel_code) * _PENCE_TO_GBP @@ -6976,7 +6996,7 @@ def _fuel_cost( main_1_low_rate_gbp_per_kwh=0.0, main_1_high_rate_fraction=1.0, main_2_kwh_per_yr=main_2_kwh, - main_2_high_rate_gbp_per_kwh=main_1_high_rate_gbp_per_kwh, + main_2_high_rate_gbp_per_kwh=main_2_high_rate_gbp_per_kwh, main_2_low_rate_gbp_per_kwh=0.0, main_2_high_rate_fraction=1.0 if main_2_kwh > 0.0 else 0.0, secondary_kwh_per_yr=secondary_kwh, diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index e1ca3f7e..4f04720a 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -473,6 +473,60 @@ def test_heat_network_primary_loss_uses_p1_h3_all_months_per_table_3() -> None: assert abs(sum(wh_result.primary_loss_monthly_kwh) - expected_primary_kwh) <= 1e-6 +def test_dual_main_system_2_costed_at_its_own_fuel_price() -> None: + # Arrange — SAP 10.2 §10a: main heating system 2's fuel cost (worksheet + # line (213)) is billed at ITS OWN Table 32 fuel price, separately from + # main system 1's (211). A dwelling with main-1 = electric room heaters + # (SAP 691, fuel 30 standard electricity @ 13.19 p/kWh) and main-2 = + # wood-log room heaters (SAP 633, fuel 6 @ 4.23 p/kWh), 50/50 split, on a + # single-rate meter (so the §10a standard path runs). Pre-fix the main-2 + # column reused main-1's electric price, charging the wood at 13.19 p/kWh + # and grossly over-costing electric+wood dwellings (cert 10032957680 + # "Copse Cottage" SAP 21.75 -> 45.94 vs lodged 45). + main_1_electric = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=30, # standard electricity + heat_emitter_type=0, + emitter_temperature="NA", + main_heating_control=2106, + main_heating_category=10, + sap_main_heating_code=691, + main_heating_fraction=50, + ) + main_2_wood = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=6, # wood logs — Table 32 = 4.23 p/kWh + heat_emitter_type=0, + emitter_temperature="NA", + main_heating_control=2106, + main_heating_category=10, + sap_main_heating_code=633, + main_heating_fraction=50, + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=4, + country_code="ENG", + sap_building_parts=[make_building_part(construction_age_band="D")], + sap_heating=make_sap_heating( + main_heating_details=[main_1_electric, main_2_wood], + ), + ) + + # Act + inputs = cert_to_inputs(epc) + result = Sap10Calculator().calculate(epc) + fc = inputs.fuel_cost + main_2_kwh = result.main_2_heating_fuel_kwh_per_yr + + # Assert — main-2 wood billed at 4.23 p/kWh (0.0423 £/kWh), NOT main-1's + # electric 13.19 p/kWh. (213) main_2_total_cost = kWh × wood price. + assert main_2_kwh > 0.0 + assert abs(fc.main_2_total_cost_gbp - main_2_kwh * 0.0423) <= 1e-6 + # And explicitly NOT the electric rate (the pre-fix value). + assert abs(fc.main_2_total_cost_gbp - main_2_kwh * 0.1319) > 1.0 + + def test_cylinder_thermostat_assumed_present_per_sap_9_4_9() -> None: # Arrange — SAP 10.2 §9.4.9 (PDF p.32): "A cylinder thermostat should be # assumed to be present when the domestic hot water is obtained from a diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index df64ed59..e3447b47 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -194,7 +194,7 @@ _CORPUS = Path( # stress worksheet (simulated case 46): closed its last ventilation residual # (our Jan ACH 9.14 -> 9.0748 exact; SAP 29 -> 30 = accredited Elmhurst). _MIN_WITHIN_HALF_SAP = 0.72 -_MAX_SAP_MAE = 0.82 +_MAX_SAP_MAE = 0.80 _MAX_CO2_MAE_TONNES = 0.09 # t CO2 / yr vs co2_emissions_current _MAX_PE_PER_M2_MAE = 3.7 # kWh / m2 / yr vs energy_consumption_current