"""SAP10 §20.1 cost reconstruction: predicted total fuel cost + ECF. ECF = 0.42 * total_cost / (TFA + 45) (SAP rating relationship) Total cost (gbp/yr) = (space_kwh * space_fuel_price + dhw_kwh * dhw_fuel_price + lighting_kwh * elec_price) / 100 [pence -> pounds] Standing charges are deliberately omitted at this slice -- they add a fuel-mix-conditional offset the tree-based model can learn (ADR-0008, "+ Lighting" scope). """ from __future__ import annotations from math import log10 from typing import Final, Optional from domain.sap10_ml.sap_efficiencies import fuel_unit_price_p_per_kwh # SAP10 deflator applied to total cost before the rating equation (Table 32). _DEFLATOR: float = 0.42 # Electricity standard tariff fuel code (Table 32) — used for lighting + PV credit. _ELECTRICITY_STANDARD_CODE: int = 30 # Annual PV yield (kWh / kWp / yr) by SAP10.2 region. Derived from Table 6e # climate data integrated over an average roof (South-facing, 30 deg pitch, # average overshading). Southern England ~ 900, Scotland ~ 700, Highland ~ 600. _PV_YIELD_BY_REGION: Final[dict[int, float]] = { 1: 920, 2: 950, 3: 970, 4: 960, 5: 920, 6: 880, 7: 850, 8: 830, 9: 820, 10: 820, 11: 830, 12: 880, 13: 850, 14: 770, 15: 740, 16: 700, 17: 650, 18: 700, 19: 690, 20: 680, 21: 870, 22: 870, } _PV_YIELD_UK_AVG: Final[float] = 850.0 def predicted_pv_generation_kwh( pv_total_peak_power_kw: Optional[float], region_code: Optional[str], ) -> float: """Annual PV generation (kWh/yr) for the dwelling's PV array(s). Linear in peak power; uses a SAP-region yield factor with South-facing, 30 deg pitch, average overshading assumptions (SAP10.2 Table 6e). """ if pv_total_peak_power_kw is None or pv_total_peak_power_kw <= 0: return 0.0 yield_factor = _PV_YIELD_UK_AVG if region_code is not None: try: yield_factor = _PV_YIELD_BY_REGION.get(int(region_code), _PV_YIELD_UK_AVG) except (TypeError, ValueError): pass return pv_total_peak_power_kw * yield_factor def predicted_total_fuel_cost_gbp( predicted_space_heating_kwh: float, predicted_hot_water_kwh: float, predicted_lighting_kwh: float, main_fuel_code: Optional[int], water_heating_fuel_code: Optional[int], predicted_pv_kwh: float = 0.0, ) -> float: """Annual regulated fuel cost (gbp/yr). Skips standing charges; sums delivered kWh * unit price across the three included end uses (lighting always at standard electricity). Slice 17a: subtracts predicted_pv_kwh * standard electricity price as a flat PV credit. SAP10.2 splits PV between self-consumption and export with separate rates; both are 13.19 p/kWh in Table 32 so a single rate is fine at this fidelity. """ space_p_per_kwh = fuel_unit_price_p_per_kwh(main_fuel_code) dhw_p_per_kwh = fuel_unit_price_p_per_kwh(water_heating_fuel_code) light_p_per_kwh = fuel_unit_price_p_per_kwh(_ELECTRICITY_STANDARD_CODE) pv_p_per_kwh = fuel_unit_price_p_per_kwh(_ELECTRICITY_STANDARD_CODE) total_pence = ( predicted_space_heating_kwh * space_p_per_kwh + predicted_hot_water_kwh * dhw_p_per_kwh + predicted_lighting_kwh * light_p_per_kwh - predicted_pv_kwh * pv_p_per_kwh ) return total_pence / 100.0 def predicted_ecf( predicted_total_cost_gbp: float, total_floor_area_m2: Optional[float], ) -> float: """SAP rating Energy Cost Factor: 0.42 * total_cost / (TFA + 45).""" if total_floor_area_m2 is None or total_floor_area_m2 <= 0: return 0.0 return _DEFLATOR * predicted_total_cost_gbp / (total_floor_area_m2 + 45.0) def predicted_log10_ecf(predicted_ecf_value: float) -> float: """log10(ECF). Returns 0.0 for non-positive input so the feature is finite for the (rare) all-PV property. The SAP rating formula uses log10(ECF) for ECF >= 3.5 (low-SAP region); in the high-SAP linear region the model can still use log10_ecf as a monotone proxy for SAP.""" if predicted_ecf_value <= 0: return 0.0 return log10(predicted_ecf_value)