diff --git a/packages/domain/src/domain/sap/calculator.py b/packages/domain/src/domain/sap/calculator.py index a4522f1a..d5a7c6a8 100644 --- a/packages/domain/src/domain/sap/calculator.py +++ b/packages/domain/src/domain/sap/calculator.py @@ -110,6 +110,14 @@ class CalculatorInputs: # collapse to a single credit at the export rate (Table 12 code 60). pv_generation_kwh_per_yr: float = 0.0 pv_export_credit_gbp_per_kwh: float = 0.0 + # Secondary heating — SAP 10.2 Table 11 routes a fraction of space + # heating demand to a secondary system (0.10 for gas/oil/solid main + # systems; 0.15-0.20 for electric room/storage heaters). Fraction + # 0.0 disables secondary handling (default for ports that don't yet + # split heating). + secondary_heating_fraction: float = 0.0 + secondary_heating_efficiency: float = 1.0 + secondary_heating_fuel_cost_gbp_per_kwh: float = 0.0 @dataclass(frozen=True) @@ -125,6 +133,7 @@ class MonthlyEntry: utilisation_factor: float space_heat_requirement_kwh: float main_heating_fuel_kwh: float + secondary_heating_fuel_kwh: float = 0.0 @dataclass(frozen=True) @@ -140,6 +149,7 @@ class SapResult: co2_kg_per_yr: float space_heating_kwh_per_yr: float main_heating_fuel_kwh_per_yr: float + secondary_heating_fuel_kwh_per_yr: float hot_water_kwh_per_yr: float pumps_fans_kwh_per_yr: float lighting_kwh_per_yr: float @@ -222,7 +232,15 @@ def _solve_month( total_gains_w=g_total, days_in_month=_DAYS_IN_MONTH[month - 1], ) - fuel = q_heat / inputs.main_heating_efficiency if inputs.main_heating_efficiency > 0 else 0.0 + sec_frac = inputs.secondary_heating_fraction + q_main = q_heat * (1.0 - sec_frac) + q_secondary = q_heat * sec_frac + fuel_main = q_main / inputs.main_heating_efficiency if inputs.main_heating_efficiency > 0 else 0.0 + fuel_secondary = ( + q_secondary / inputs.secondary_heating_efficiency + if inputs.secondary_heating_efficiency > 0 + else 0.0 + ) return MonthlyEntry( month=month, @@ -233,7 +251,8 @@ def _solve_month( heat_loss_rate_w=loss_rate_w, utilisation_factor=eta, space_heat_requirement_kwh=q_heat, - main_heating_fuel_kwh=fuel, + main_heating_fuel_kwh=fuel_main, + secondary_heating_fuel_kwh=fuel_secondary, ) @@ -264,8 +283,10 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: space_heating_kwh = sum(e.space_heat_requirement_kwh for e in monthly) main_fuel_kwh = sum(e.main_heating_fuel_kwh for e in monthly) + secondary_fuel_kwh = sum(e.secondary_heating_fuel_kwh for e in monthly) delivered_fuel_kwh = ( main_fuel_kwh + + secondary_fuel_kwh + inputs.hot_water_kwh_per_yr + inputs.pumps_fans_kwh_per_yr + inputs.lighting_kwh_per_yr @@ -274,6 +295,7 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: total_cost = max( 0.0, main_fuel_kwh * inputs.space_heating_fuel_cost_gbp_per_kwh + + secondary_fuel_kwh * inputs.secondary_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 @@ -292,6 +314,7 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: co2_kg_per_yr=co2, space_heating_kwh_per_yr=space_heating_kwh, main_heating_fuel_kwh_per_yr=main_fuel_kwh, + secondary_heating_fuel_kwh_per_yr=secondary_fuel_kwh, hot_water_kwh_per_yr=inputs.hot_water_kwh_per_yr, pumps_fans_kwh_per_yr=inputs.pumps_fans_kwh_per_yr, lighting_kwh_per_yr=inputs.lighting_kwh_per_yr, 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 ffcc7e7a..fe18d5ec 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -141,6 +141,40 @@ _INSTANTANEOUS_WATER_CODES: Final[frozenset[int]] = frozenset({907, 909}) _PV_ANNUAL_YIELD_KWH_PER_KWP: Final[float] = 850.0 +# SAP 10.2 Table 11 — fraction of space heating supplied by a secondary +# system, keyed on the main system's category. +# Cat 1, 2 (gas/oil/solid boiler): 0.10 +# Cat 4 (heat pump): 0.00 (HP eff includes any secondary) +# Cat 5 (warm air): 0.10 +# Cat 7 (electric storage): 0.15 (not-fan-assisted average) +# Cat 10 (room heaters): 0.20 +# Heat networks (cat 3, 6) → 0.10 per Table 11. +_SECONDARY_HEATING_FRACTION_BY_CATEGORY: Final[dict[int, float]] = { + 1: 0.10, + 2: 0.10, + 3: 0.10, + 5: 0.10, + 6: 0.10, + 7: 0.15, + 10: 0.20, +} +_SECONDARY_HEATING_FRACTION_DEFAULT: Final[float] = 0.10 + + +# SAP §A.2.2 forcing rule: "A secondary system is always included for +# the SAP calculation when the main system (or main system 1 when there +# are two systems) is electric storage heaters or off-peak electric +# underfloor heating. This applies to main heating codes 401 to 407, 409 +# and 421. Portable electric heaters (693) are used in the calculation +# if no secondary system has been identified." +# For gas/oil/solid boiler main systems, the cert calculator only includes +# secondary when one has actually been lodged on the cert. +_DEFAULT_SECONDARY_HEATING_CODE: Final[int] = 693 +_FORCE_SECONDARY_FOR_MAIN_CODES: Final[frozenset[int]] = frozenset( + list(range(401, 410)) + [421] +) + + # SAP 10.2 Table 12 code 60 — PV export tariff. The calculator uses this # rate as the per-kWh PV cost credit applied against total annual fuel # cost in the ECF numerator. @@ -488,6 +522,66 @@ def _hot_water_fuel_cost_gbp_per_kwh( return _fuel_cost_gbp_per_kwh(main, prices) +def _secondary_fraction( + main: Optional[MainHeatingDetail], secondary_heating_type: object +) -> float: + """SAP 10.2 Table 11 lookup by main heating category, applied only + when (a) the cert has a secondary system lodged OR (b) the main + heating code is in the §A.2.2 forced-secondary set (electric storage + heaters). Returns 0.0 when neither applies — the most common case + for gas/oil main systems whose cert doesn't lodge a secondary.""" + if main is None: + return 0.0 + code = main.sap_main_heating_code + has_lodged_secondary = secondary_heating_type is not None + force = code is not None and code in _FORCE_SECONDARY_FOR_MAIN_CODES + if not has_lodged_secondary and not force: + return 0.0 + cat = main.main_heating_category + if cat is None: + return _SECONDARY_HEATING_FRACTION_DEFAULT + return _SECONDARY_HEATING_FRACTION_BY_CATEGORY.get( + cat, _SECONDARY_HEATING_FRACTION_DEFAULT + ) + + +def _secondary_efficiency( + sap_heating, main_code: Optional[int], main_fuel: Optional[int] +) -> float: + """Look up secondary efficiency from cert's secondary_heating_type + code, falling back to portable electric heater (code 693, eff 1.0) + per SAP §A.2.2 default.""" + code = _int_or_none(sap_heating.secondary_heating_type) + if code is None: + code = _DEFAULT_SECONDARY_HEATING_CODE + return seasonal_efficiency(code, None, None) + + +def _secondary_fuel_cost_gbp_per_kwh( + sap_heating, + main: Optional[MainHeatingDetail], + meter_type: object, + prices: PriceTable, +) -> float: + """Secondary fuel cost. When secondary_fuel_type is missing, default + to portable-electric (code 30 standard electricity, or off-peak + under E7-eligible meter). The cert's secondary is an electric room + heater per the §A.2.2 default.""" + sec_fuel = sap_heating.secondary_fuel_type + if sec_fuel is None: + # Default to electricity since the default secondary system is + # portable electric heaters (code 693). + if _is_off_peak_meter(meter_type, fuel_is_electric=True): + return prices.e7_low_rate_p_per_kwh * _PENCE_TO_GBP + return prices.standard_electricity_p_per_kwh * _PENCE_TO_GBP + # When secondary_fuel_type is electricity, apply off-peak if applicable. + if _is_electric_water(sec_fuel) and _is_off_peak_meter( + meter_type, fuel_is_electric=True + ): + return prices.e7_low_rate_p_per_kwh * _PENCE_TO_GBP + return prices.unit_price_p_per_kwh(sec_fuel) * _PENCE_TO_GBP + + def _pv_generation_kwh_per_yr(epc: EpcPropertyData) -> float: """Annual PV generation (kWh/yr) summed across all photovoltaic arrays on the cert. SAP 10.2 Appendix M: yield = peak power × annual @@ -699,4 +793,13 @@ def cert_to_inputs( co2_factor_kg_per_kwh=_co2_factor_kg_per_kwh(main), pv_generation_kwh_per_yr=_pv_generation_kwh_per_yr(epc), pv_export_credit_gbp_per_kwh=_pv_export_credit_gbp_per_kwh(prices), + secondary_heating_fraction=_secondary_fraction( + main, epc.sap_heating.secondary_heating_type + ), + secondary_heating_efficiency=_secondary_efficiency( + epc.sap_heating, main_code, main_fuel + ), + secondary_heating_fuel_cost_gbp_per_kwh=_secondary_fuel_cost_gbp_per_kwh( + epc.sap_heating, main, epc.sap_energy_source.meter_type, prices + ), )