diff --git a/packages/domain/src/domain/sap/calculator.py b/packages/domain/src/domain/sap/calculator.py index 9de3186a..7c49190b 100644 --- a/packages/domain/src/domain/sap/calculator.py +++ b/packages/domain/src/domain/sap/calculator.py @@ -78,7 +78,14 @@ class WindowInput: @dataclass(frozen=True) class CalculatorInputs: """Synthetic SAP 10.3 calculator inputs. The cert→inputs mapper - (S-A7b) produces one of these from an `EpcPropertyData`.""" + (S-A7b) produces one of these from an `EpcPropertyData`. + + Fuel-cost fields are per-end-use because SAP §12 / Table 32 charges + different tariffs for space heating vs hot water vs lighting/pumps + depending on the dwelling's tariff (e.g. Economy-7 charges space + heating at the off-peak rate but lighting at standard). For single- + tariff dwellings the three fields are equal. + """ dimensions: Dimensions heat_transmission: HeatTransmission @@ -94,7 +101,9 @@ class CalculatorInputs: hot_water_kwh_per_yr: float pumps_fans_kwh_per_yr: float lighting_kwh_per_yr: float - fuel_unit_cost_gbp_per_kwh: float + space_heating_fuel_cost_gbp_per_kwh: float + hot_water_fuel_cost_gbp_per_kwh: float + other_fuel_cost_gbp_per_kwh: float co2_factor_kg_per_kwh: float @@ -256,7 +265,12 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: + inputs.pumps_fans_kwh_per_yr + inputs.lighting_kwh_per_yr ) - total_cost = delivered_fuel_kwh * inputs.fuel_unit_cost_gbp_per_kwh + total_cost = ( + main_fuel_kwh * inputs.space_heating_fuel_cost_gbp_per_kwh + + inputs.hot_water_kwh_per_yr * inputs.hot_water_fuel_cost_gbp_per_kwh + + (inputs.pumps_fans_kwh_per_yr + inputs.lighting_kwh_per_yr) + * inputs.other_fuel_cost_gbp_per_kwh + ) ecf = energy_cost_factor(total_cost_gbp=total_cost, total_floor_area_m2=tfa) sap_int = sap_rating_integer(ecf=ecf) sap_cont = sap_rating(ecf=ecf) diff --git a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py index 63804b57..82f344c1 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -125,6 +125,18 @@ _DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K: Final[float] = 250.0 _DEFAULT_PUMPS_FANS_KWH_PER_YR: Final[float] = 130.0 +# SAP 10.2 Table 4a "electric heating" range that picks up an Economy-7 +# off-peak tariff for the space-heating fuel cost: electric storage +# heaters (401-409), high-heat-retention storage heaters (421-425), and +# direct-electric room/boiler heating (191-196). Hot water and lighting +# on these dwellings still bill at the on-peak standard rate. +_E7_SPACE_HEATING_CODES: Final[frozenset[int]] = frozenset( + list(range(191, 197)) + list(range(401, 410)) + list(range(421, 426)) +) +# Table 32 code 31 — Economy-7 "7h low" off-peak rate. +_E7_LOW_RATE_P_PER_KWH: Final[float] = 5.50 + + def _dwelling_exposure(dwelling_type: Optional[str]) -> DwellingExposure: """Map `EpcPropertyData.dwelling_type` to which envelope surfaces are party (not heat-loss). Mid-floor flats/maisonettes lose both floor + @@ -272,6 +284,24 @@ def _fuel_cost_gbp_per_kwh(main: Optional[MainHeatingDetail]) -> float: return fuel_unit_price_p_per_kwh(_main_fuel_code(main)) * _PENCE_TO_GBP +def _is_electric_storage_or_direct(main: Optional[MainHeatingDetail]) -> bool: + """RdSAP convention: electric storage heaters + direct-electric main + systems bill space heating at the off-peak rate while hot water + + lighting + pumps stay on the on-peak/standard rate.""" + if main is None: + return False + code = main.sap_main_heating_code + return code is not None and code in _E7_SPACE_HEATING_CODES + + +def _space_heating_fuel_cost_gbp_per_kwh(main: Optional[MainHeatingDetail]) -> float: + """Off-peak rate when the main heating is electric-storage / direct- + electric, else the standard main-fuel rate.""" + if _is_electric_storage_or_direct(main): + return _E7_LOW_RATE_P_PER_KWH * _PENCE_TO_GBP + return _fuel_cost_gbp_per_kwh(main) + + def _co2_factor_kg_per_kwh(main: Optional[MainHeatingDetail]) -> float: """SAP 10.3 Table 12 CO2 emission factor by Table 32 fuel code.""" code = _main_fuel_code(main) @@ -390,6 +420,8 @@ def cert_to_inputs(epc: EpcPropertyData) -> CalculatorInputs: hot_water_kwh_per_yr=hw_kwh, pumps_fans_kwh_per_yr=_DEFAULT_PUMPS_FANS_KWH_PER_YR, lighting_kwh_per_yr=lighting_kwh, - fuel_unit_cost_gbp_per_kwh=_fuel_cost_gbp_per_kwh(main), + space_heating_fuel_cost_gbp_per_kwh=_space_heating_fuel_cost_gbp_per_kwh(main), + hot_water_fuel_cost_gbp_per_kwh=_fuel_cost_gbp_per_kwh(main), + other_fuel_cost_gbp_per_kwh=_fuel_cost_gbp_per_kwh(main), co2_factor_kg_per_kwh=_co2_factor_kg_per_kwh(main), ) diff --git a/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py index c818a182..ec854d31 100644 --- a/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/tests/test_cert_to_inputs.py @@ -205,14 +205,60 @@ def test_main_heating_efficiency_reads_sap_main_heating_code() -> None: def test_mains_gas_fuel_cost_in_gbp_per_kwh() -> None: # Arrange — Table 12 mains-gas unit price is 3.48 p/kWh; mapper must - # report this as £0.0348/kWh (decimal-pound, not pence). + # report this as £0.0348/kWh (decimal-pound, not pence). For mains- + # gas dwellings all three end-use fuel costs collapse to the same value. epc = _typical_semi_detached_epc() # Act inputs = cert_to_inputs(epc) # Assert - assert inputs.fuel_unit_cost_gbp_per_kwh == 0.0348 + assert inputs.space_heating_fuel_cost_gbp_per_kwh == 0.0348 + assert inputs.hot_water_fuel_cost_gbp_per_kwh == 0.0348 + assert inputs.other_fuel_cost_gbp_per_kwh == 0.0348 + + +def test_electric_storage_heater_space_heating_at_off_peak_rate() -> None: + # Arrange — RdSAP convention: when the main heating is electric- + # storage (code 401-409) or direct-electric (191-196), space heating + # is charged at the 7h-low off-peak rate (Table 32 code 31, 5.5p/kWh) + # while hot water + lighting + pumps remain on standard electricity + # (code 30, 13.19p/kWh). Critical fix for the 5/7 worst residuals on + # storage-heated dwellings. + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=3, + region_code="1", + dwelling_type="Detached bungalow", + sap_building_parts=[ + make_building_part( + floor_dimensions=[ + make_floor_dimension(total_floor_area_m2=_TYPICAL_TFA_M2, floor=0), + ], + ), + ], + sap_heating=make_sap_heating( + main_heating_details=[ + MainHeatingDetail( + has_fghrs=False, + main_fuel_type=29, + heat_emitter_type=1, + emitter_temperature=1, + main_heating_control=2106, + main_heating_category=7, + sap_main_heating_code=402, + ), + ], + ), + ) + + # Act + inputs = cert_to_inputs(epc) + + # Assert + assert inputs.space_heating_fuel_cost_gbp_per_kwh == 0.055 + assert inputs.hot_water_fuel_cost_gbp_per_kwh == 0.1319 + assert inputs.other_fuel_cost_gbp_per_kwh == 0.1319 def test_mid_floor_flat_dwelling_type_zeroes_floor_and_roof_heat_transmission() -> None: diff --git a/packages/domain/src/domain/sap/tests/test_calculator.py b/packages/domain/src/domain/sap/tests/test_calculator.py index b53b573b..5e17e07f 100644 --- a/packages/domain/src/domain/sap/tests/test_calculator.py +++ b/packages/domain/src/domain/sap/tests/test_calculator.py @@ -89,7 +89,9 @@ def _baseline_inputs() -> CalculatorInputs: hot_water_kwh_per_yr=2400.0, pumps_fans_kwh_per_yr=100.0, lighting_kwh_per_yr=600.0, - fuel_unit_cost_gbp_per_kwh=0.07, + space_heating_fuel_cost_gbp_per_kwh=0.07, + hot_water_fuel_cost_gbp_per_kwh=0.07, + other_fuel_cost_gbp_per_kwh=0.07, co2_factor_kg_per_kwh=0.21, ) @@ -118,7 +120,7 @@ def test_calculator_returns_twelve_month_breakdown_and_plausible_sap_score() -> + result.lighting_kwh_per_yr ) assert result.total_fuel_cost_gbp == pytest.approx( - expected_fuel * inputs.fuel_unit_cost_gbp_per_kwh, rel=1e-6 + expected_fuel * inputs.space_heating_fuel_cost_gbp_per_kwh, rel=1e-6 ) @@ -194,3 +196,27 @@ def test_ecf_uses_table_12_energy_cost_deflator() -> None: / (inputs.dimensions.total_floor_area_m2 + 45.0) ) assert result.ecf == pytest.approx(expected_ecf, rel=1e-6) + + +def test_split_tariff_charges_space_heating_at_off_peak_rate() -> None: + # Arrange — Economy-7 dwelling: storage-heater space heating at the + # 7h-low rate (~5.5 p/kWh), everything else on standard (13.19 p/kWh). + # Verifies the split-tariff cost line aggregates correctly per SAP §12. + base = _baseline_inputs() + e7 = replace( + base, + space_heating_fuel_cost_gbp_per_kwh=0.055, + hot_water_fuel_cost_gbp_per_kwh=0.1319, + other_fuel_cost_gbp_per_kwh=0.1319, + ) + + # Act + r_e7 = calculate_sap_from_inputs(e7) + + # Assert + expected_cost = ( + r_e7.main_heating_fuel_kwh_per_yr * 0.055 + + r_e7.hot_water_kwh_per_yr * 0.1319 + + (r_e7.pumps_fans_kwh_per_yr + r_e7.lighting_kwh_per_yr) * 0.1319 + ) + assert r_e7.total_fuel_cost_gbp == pytest.approx(expected_cost, rel=1e-6)