"""Crude annual heat/hot-water/lighting demand approximations. Used by `transform.py` to populate the `predicted_*_kwh` features (slice 16d). These are deliberately coarse: they give the model a physics-shaped starting point that it can adjust against the cert's real kWh labels. See ADR-0008 for the "crude annual" rationale. Formulas: - predicted_space_heating_kwh ~= envelope_heat_loss_w_per_k * HDH_region / efficiency_main_heating where HDH_region is heating degree hours per year by SAP region. - predicted_hot_water_kwh (SAP10.2 Appendix J simplified) V_d ~= 25 * N_occupants + 36 (litres / day) Q_HW_useful ~= 4.18 * V_d * (55 - 12) * 365 * 1e-3 (kWh / yr) N_occupants defaulted from total_floor_area_m2 per SAP J Table 1b. - predicted_lighting_kwh (SAP10.2 Section L simplified) base ~= 9.3 * TFA * (1 - 0.5 * led_share - 0.4 * cfl_share) """ from __future__ import annotations from math import exp from typing import Final, Optional # SAP10.2 Table 6 / U6 heating degree hours per year by SAP region (K * h). # Coarse grouping: most regions cluster ~50-60k; using region_code if known. _HDH_BY_REGION: Final[dict[int, float]] = { 1: 51000, # Thames 2: 52000, # SE England 3: 50000, # Southern 4: 51000, # SW England 5: 53000, # Severn (5E + 5W treated together) 6: 54000, # Midlands 7: 55000, # W Pennines / Lancashire 8: 55000, # NW England / SW Scotland 9: 56000, # Borders / North Tyne 10: 56000, # NE England 11: 56000, # E Pennines / Yorkshire 12: 54000, # E Anglia 13: 56000, # Wales (Mid) 14: 58000, # W Scotland 15: 59000, # E Scotland 16: 60000, # NE Scotland 17: 62000, # Highland 18: 60000, # Western Isles 19: 60000, # Orkney 20: 62000, # Shetland 21: 54000, # Northern Ireland 22: 53000, # Isle of Man } _HDH_UK_AVG: Final[float] = 53000.0 def _hdh_for_region(region_code: Optional[str]) -> float: if region_code is None: return _HDH_UK_AVG try: code = int(region_code) except (TypeError, ValueError): return _HDH_UK_AVG return _HDH_BY_REGION.get(code, _HDH_UK_AVG) def predicted_space_heating_kwh( envelope_heat_loss_w_per_k: float, region_code: Optional[str], seasonal_efficiency_main: float, ventilation_heat_loss_w_per_k: float = 0.0, ) -> float: """Annual delivered space-heating kWh. delivered_kWh = HLC * HDH_region * 1e-3 / efficiency where HLC = envelope (conduction + bridging) + ventilation (infiltration), HDH is K*hours/year. SAP10.2 §5 routes both losses through the same demand pipeline. Ventilation defaults to 0 for back-compat with pre-slice-20a callers. """ hlc = envelope_heat_loss_w_per_k + ventilation_heat_loss_w_per_k if hlc <= 0 or seasonal_efficiency_main <= 0: return 0.0 hdh = _hdh_for_region(region_code) useful_kwh = hlc * hdh * 1e-3 return useful_kwh / seasonal_efficiency_main def _default_occupants_sap_j(total_floor_area_m2: float) -> float: """SAP10.2 Appendix J Table 1b default occupancy. N = 1 + 1.76 * (1 - exp(-0.000349 * (TFA - 13.9)^2)) + 0.0013 * (TFA - 13.9) for TFA > 13.9 m^2; otherwise N = 1. """ if total_floor_area_m2 <= 13.9: return 1.0 x = total_floor_area_m2 - 13.9 return 1.0 + 1.76 * (1.0 - exp(-0.000349 * x * x)) + 0.0013 * x def predicted_hot_water_kwh( total_floor_area_m2: Optional[float], seasonal_efficiency_water: float, *, cylinder_size: Optional[int] = None, cylinder_insulation_thickness_mm: Optional[int] = None, cylinder_insulation_type: Optional[int] = None, age_band: Optional[str] = None, has_wwhrs: bool = False, has_solar_water_heating: bool = False, ) -> float: """Annual delivered hot-water kWh per SAP10.2 Appendix J (slice 17b). Components (all kWh useful, sum then divided by efficiency for delivered): useful_demand = 4.18 * Vd * 43 * 365 / 3600 (Vd in litres/day) distribution_loss = useful_demand * 0.15 storage_loss = volume * insulation_factor * 365 * 0.6 primary_loss(age) = 245 (A-J) or 60 (K-M) wwhrs_credit = useful_demand * 0.12 if has_wwhrs solar_hw_credit = 250 if has_solar_water_heating Defaults follow RdSAP10 §11 / Table 29 for missing cylinder fields. """ if total_floor_area_m2 is None or total_floor_area_m2 <= 0: return 0.0 if seasonal_efficiency_water <= 0: return 0.0 n = _default_occupants_sap_j(total_floor_area_m2) vd_litres = 25.0 * n + 36.0 useful_kwh = 4.18 * vd_litres * (55.0 - 12.0) * 365.0 / 3600.0 distribution_loss = useful_kwh * 0.15 storage_loss = _cylinder_storage_loss_kwh( cylinder_size=cylinder_size, cylinder_insulation_thickness_mm=cylinder_insulation_thickness_mm, cylinder_insulation_type=cylinder_insulation_type, age_band=age_band, ) primary_loss = _primary_circuit_loss_kwh(age_band) wwhrs_credit = useful_kwh * 0.12 if has_wwhrs else 0.0 solar_credit = 250.0 if has_solar_water_heating else 0.0 total_useful = max( 0.0, useful_kwh + distribution_loss + storage_loss + primary_loss - wwhrs_credit - solar_credit, ) return total_useful / seasonal_efficiency_water # SAP10.2 cylinder volume by RdSAP10 size code (Table 28). _CYLINDER_VOLUME_L: Final[dict[int, float]] = {1: 110.0, 2: 160.0, 3: 210.0} # SAP10.2 Table 2 storage loss factor (kWh / litre / day) by insulation # thickness in mm. Lower number = better insulation. _STORAGE_LOSS_FACTOR: Final[dict[int, float]] = { 0: 0.0203, # uninsulated -> high loss 12: 0.0152, # 12 mm jacket 25: 0.0078, # 25 mm foam 38: 0.0056, 50: 0.0043, 80: 0.0025, 100: 0.0022, 150: 0.0014, 200: 0.0011, } # RdSAP10 Table 29 cylinder-insulation default by age band when unknown: # A-F -> 12 mm jacket, G-H -> 25 mm foam, I-M -> 38 mm foam. _AGE_TO_DEFAULT_CYLINDER_INS_MM: Final[dict[str, int]] = { "A": 12, "B": 12, "C": 12, "D": 12, "E": 12, "F": 12, "G": 25, "H": 25, "I": 38, "J": 38, "K": 38, "L": 38, "M": 38, } def _cylinder_storage_loss_kwh( cylinder_size: Optional[int], cylinder_insulation_thickness_mm: Optional[int], cylinder_insulation_type: Optional[int], age_band: Optional[str], ) -> float: """Annual cylinder storage loss (kWh useful, before efficiency division). Returns 0 when no cylinder is described AND age_band is unknown (assume instantaneous / combi without storage). Heated-space modifier 0.6. """ if cylinder_size is None and age_band is None: return 0.0 volume = _CYLINDER_VOLUME_L.get(cylinder_size or 1, 110.0) thickness = cylinder_insulation_thickness_mm if thickness is None and age_band is not None: thickness = _AGE_TO_DEFAULT_CYLINDER_INS_MM.get(age_band.upper()) if thickness is None: thickness = 38 factor = _nearest_storage_loss_factor(thickness) heated_space_modifier = 0.6 return volume * factor * 365.0 * heated_space_modifier def _nearest_storage_loss_factor(thickness_mm: int) -> float: """Pick the SAP10.2 Table 2 row with thickness closest <= the supplied value. For thicknesses below 12 mm, uses the uninsulated 0-row.""" candidates = sorted(_STORAGE_LOSS_FACTOR.keys()) chosen = candidates[0] for t in candidates: if t <= thickness_mm: chosen = t return _STORAGE_LOSS_FACTOR[chosen] def _primary_circuit_loss_kwh(age_band: Optional[str]) -> float: """Annual primary-pipework loss (kWh useful) by age band. RdSAP10 Table 29: pre-2007 (A-J) no primary insulation -> 245 kWh/yr; K, L, M -> full insulation -> 60 kWh/yr. Unknown -> 245. """ if age_band is None: return 245.0 return 60.0 if age_band.upper() in ("K", "L", "M") else 245.0 def predicted_lighting_kwh( total_floor_area_m2: Optional[float], cfl_count: Optional[int], led_count: Optional[int], incandescent_count: Optional[int], ) -> float: """Annual lighting kWh (SAP10.2 Section L simplified). Base demand ~ 9.3 * TFA kWh/yr; reduced by low-energy bulb share. LED bulbs cut consumption by ~50%, CFL by ~40%, incandescent by 0%. Missing counts treated as zero. DEPRECATED for SAP rating use. The spec-faithful Appendix L L1-L11 cascade is in `domain.sap10_calculator.worksheet.internal_gains.annual_lighting_kwh` and is what `cert_to_inputs` now plumbs into `inputs.lighting_kwh_per_yr`. This heuristic over-counts ~3× on the Elmhurst cohort (528 vs 140 kWh on 000474). Kept only for `domain.sap10_ml.ecf.energy_cost_factor` and `domain.sap10_ml.transform.transform_to_predictions` — legacy ML predictor callsites that pre-date the SAP rewrite. Rip when those migrate. See ADR-0010 amendment "Appendix L lighting (2026-05-22)". """ if total_floor_area_m2 is None or total_floor_area_m2 <= 0: return 0.0 cfl = cfl_count or 0 led = led_count or 0 inc = incandescent_count or 0 total_bulbs = max(1, cfl + led + inc) led_share = led / total_bulbs cfl_share = cfl / total_bulbs reduction = 0.5 * led_share + 0.4 * cfl_share return 9.3 * total_floor_area_m2 * (1.0 - reduction)