"""SAP 10.2 calculator orchestrator. Drives the 12-month heat-balance loop from a typed `CalculatorInputs` aggregate and emits a typed `SapResult`. This module is the physics assembly only — the RdSAP cert→inputs mapping lives in `domain.sap10_calculator.rdsap.cert_to_inputs`. Splitting the two keeps orchestration testable against synthetic inputs without dragging in cert-shape assumptions. Per-month worksheet flow (§§5-13): 1. External temp / wind / horizontal solar from `monthly_external_ temp_c_override` tuple if set (postcode demand cascade), else Appendix U Tables U1-U3 by region. 2. Internal gains (§5 + Appendix L) given TFA and month. 3. Solar gains (§6 + Appendix U §U3.2) summed over the window list. 4. HLC = HLC_T (already supplied) + HLC_V = ach × volume × 0.33. 5. Thermal time constant τ = TMP × TFA / (3.6 × HLC) for utilisation η. 6. Mean internal temperature (§7 + Table 9b/9c) and utilisation factor (Table 9a) — supplied as monthly tuples from cert_to_inputs. 7. Useful space-heating requirement (Table 9c step 10). 8. Delivered fuel kWh = Q_heat / main-heating efficiency. Annual aggregation: - ECF = Table 12 deflator × total cost / (TFA + 45); SAP rating from §13 piecewise log/linear (slice 23 — constants pinned by ADR-0010). - CO2 per end-use uses per-end-use factors on CalculatorInputs: gas end-uses (main, hot water) use the annual Table 12 factor; electricity end-uses (secondary, pumps/fans, lighting, electric shower) use the Σ(kWh_m × Table 12d_m) / Σ kWh_m effective annual. - Primary Energy: same shape with Table 12 / Table 12e factors. - Environmental Impact Rating from §14 (log/linear on CO2/m²). The factor-per-end-use machinery is the slice-32/33 closure of the U985 Block 2 (demand cascade) §12 / §13a line refs. See `worksheet/tests/test_section_cascade_pins.py` for the conformance suite. Reference: SAP 10.2 specification (14-03-2025) §§5-14 (pages 23-44), Tables 9a/9b/9c (pages 183-185), Table 12/12a/12d/12e (pages 191-195), Appendix L + U. RdSAP10 Table 32 (p.95) for fuel prices/CO2/PE factors. """ from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import Final, Optional, TYPE_CHECKING from domain.sap10_calculator.climate.appendix_u import external_temperature_c if TYPE_CHECKING: from datatypes.epc.domain.epc_property_data import EpcPropertyData from domain.sap10_calculator.worksheet.dimensions import Dimensions from domain.sap10_calculator.worksheet.energy_requirements import EnergyRequirementsResult from domain.sap10_calculator.worksheet.fuel_cost import FuelCostResult from domain.sap10_calculator.worksheet.heat_transmission import HeatTransmission from domain.sap10_calculator.worksheet.rating import ( ECF_LOG_THRESHOLD, ENERGY_COST_DEFLATOR, FLOOR_AREA_OFFSET_M2, energy_cost_factor, sap_rating, sap_rating_integer, ) _AIR_HEAT_CAPACITY_WH_PER_M3_K: Final[float] = 0.33 _TIME_CONSTANT_DIVISOR_KJ_TO_WH: Final[float] = 3.6 # §9a default — used as `CalculatorInputs.energy_requirements` default for # synthetic constructions that bypass cert_to_inputs. All-zero fuel; the # calculator's read path falls through to the existing inline q/η math. _ZERO_ENERGY_REQUIREMENTS_RESULT: Final[EnergyRequirementsResult] = EnergyRequirementsResult( secondary_heating_fraction=0.0, main_heating_total_fraction=1.0, main_2_of_main_fraction=0.0, main_1_of_total_fraction=1.0, main_2_of_total_fraction=0.0, main_1_efficiency_pct=100.0, main_2_efficiency_pct=0.0, secondary_efficiency_pct=100.0, cooling_seer=0.0, main_1_fuel_monthly_kwh=(0.0,) * 12, main_2_fuel_monthly_kwh=(0.0,) * 12, secondary_fuel_monthly_kwh=(0.0,) * 12, main_1_fuel_kwh_per_yr=0.0, main_2_fuel_kwh_per_yr=0.0, secondary_fuel_kwh_per_yr=0.0, cooling_fuel_kwh_per_yr=0.0, ) # §10a default — used as `CalculatorInputs.fuel_cost` default for synthetic # constructions that bypass cert_to_inputs. All-zero cost; calculator # delegation falls through to the existing inline cost math when this is # the default (slice 2a doesn't yet route through `inputs.fuel_cost`). _ZERO_FUEL_COST_RESULT: Final[FuelCostResult] = FuelCostResult( main_1_high_rate_fraction=1.0, main_1_low_rate_fraction=0.0, main_1_high_rate_cost_gbp=0.0, main_1_low_rate_cost_gbp=0.0, main_1_other_fuel_cost_gbp=0.0, main_1_total_cost_gbp=0.0, main_2_high_rate_fraction=1.0, main_2_low_rate_fraction=0.0, main_2_high_rate_cost_gbp=0.0, main_2_low_rate_cost_gbp=0.0, main_2_other_fuel_cost_gbp=0.0, main_2_total_cost_gbp=0.0, secondary_high_rate_fraction=1.0, secondary_low_rate_fraction=0.0, secondary_high_rate_cost_gbp=0.0, secondary_low_rate_cost_gbp=0.0, secondary_other_fuel_cost_gbp=0.0, secondary_total_cost_gbp=0.0, water_high_rate_fraction=1.0, water_low_rate_fraction=0.0, water_high_rate_cost_gbp=0.0, water_low_rate_cost_gbp=0.0, water_other_fuel_cost_gbp=0.0, instant_shower_cost_gbp=0.0, space_cooling_cost_gbp=0.0, pumps_fans_cost_gbp=0.0, lighting_cost_gbp=0.0, additional_standing_charges_gbp=0.0, pv_credit_gbp=0.0, appendix_q_saved_gbp=0.0, appendix_q_used_gbp=0.0, total_cost_gbp=0.0, ) @dataclass(frozen=True) class CalculatorInputs: """Synthetic SAP 10.2 calculator inputs. The cert→inputs mapper (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 # SAP10.2 (25)m — effective monthly air-change rate (12-tuple Jan..Dec). # Per-month because ventilation HLC varies with wind speed (Table U2) # and MV mode (§2 lines 24a-d). Constant-monthly inputs work too: # pass `(ach,) * 12` to model a single rate across all months. monthly_infiltration_ach: tuple[float, ...] # SAP10.2 (73)m — total internal gains W per month (Jan..Dec). # Per-month because lighting/appliances cosine-modulate and pumps/fans # zero out in summer per Table 5a. Produced by §5 orchestrator # `internal_gains_from_cert` (called from cert_to_inputs). internal_gains_monthly_w: tuple[float, ...] # SAP10.2 (83)m — total solar gains W per month (Jan..Dec). Produced # by §6 orchestrator `solar_gains_from_cert` upstream; the calculator # only indexes into it per month, no recomputation here. solar_gains_monthly_w: tuple[float, ...] # SAP10.2 (93)m — adjusted mean internal temperature °C per month, and # (94)m — utilisation factor (whole-dwelling Ti) per month. Both come # from §7 orchestrator `mean_internal_temperature_monthly` upstream. # The calculator stops iterating η in _solve_month — Table 9c is a # sequential chain (steps 1-9), not a fixed-point loop. mean_internal_temp_monthly_c: tuple[float, ...] utilisation_factor_monthly: tuple[float, ...] # SAP10.2 (98c)m — total space heating requirement kWh per month from # §8 orchestrator `space_heating_monthly_kwh`. Includes the spec summer # clamp (Jun..Sep = 0). Calculator stops calling the per-month leaf # `monthly_heat_requirement_kwh` directly; just indexes here. space_heating_monthly_kwh: tuple[float, ...] region: int control_type: int responsiveness: float living_area_fraction: float control_temperature_adjustment_c: float thermal_mass_parameter_kj_per_m2_k: float main_heating_efficiency: float hot_water_kwh_per_yr: float pumps_fans_kwh_per_yr: float lighting_kwh_per_yr: float # Unregulated annual delivered electricity — output-only, NOT fed # into ECF / cost / CO2 / primary energy / sap_score (regulated # energy only). Surfaced for ADR-0014 BillDerivation's APPLIANCES + # COOKING sections. `cooking_kwh_per_yr` is the SAP 10.2 Appendix L # L20 (p.91) ELECTRICITY figure (138 + 28×N), not the L18 cooking # heat gain. `appliances_kwh_per_yr` is the L13/L14/L16a annual E_A. appliances_kwh_per_yr: float cooking_kwh_per_yr: 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 # SAP 10.2 Table 12a Grid 2 split — MEV/MVHR fans on off-peak # tariffs (7-hour: 0.71 high-frac; 10-hour: 0.58 high-frac) bill # at a DIFFERENT blended rate than "all other uses" (7-hour: 0.90; # 10-hour: 0.80). Cert_to_inputs supplies the MEV-kWh-weighted # blended rate here for pumps_fans on off-peak; None on standard- # tariff certs (no split applies) and on certs without MEV/MVHR. # When None the legacy `other_fuel_cost_gbp_per_kwh` applies to # the whole pumps_fans stream. pumps_fans_fuel_cost_gbp_per_kwh: Optional[float] = None # Pre-computed monthly external temperature (°C). When provided, the # calculator's per-month solve uses this directly instead of looking up # `external_temperature_c(region, month)`. Set by cert_to_inputs from # either UK-average (rating cascade) or PCDB postcode (demand cascade). monthly_external_temp_c_override: Optional[tuple[float, ...]] = None # Per-end-use effective CO2 factors. For electricity end-uses with # known monthly kWh distribution, cert_to_inputs computes the days- # weighted average Table 12d factor: Σ(kWh_m × CO2_m) / Σ(kWh_m). Gas # end-uses keep the annual factor. Default None → calculator falls # back to the global `co2_factor_kg_per_kwh` (legacy synthetic path). main_heating_co2_factor_kg_per_kwh: Optional[float] = None secondary_heating_co2_factor_kg_per_kwh: Optional[float] = None hot_water_co2_factor_kg_per_kwh: Optional[float] = None pumps_fans_co2_factor_kg_per_kwh: Optional[float] = None lighting_co2_factor_kg_per_kwh: Optional[float] = None electric_shower_kwh_per_yr: float = 0.0 electric_shower_co2_factor_kg_per_kwh: Optional[float] = None # Primary energy factors per end-use (Table 12 "Primary energy factor" # column). Used by §14 to derive the cert's `energy_consumption_current` # (which is PRIMARY energy per m²). For a single-fuel dwelling all # three collapse to the same value. space_heating_primary_factor: float = 1.0 hot_water_primary_factor: float = 1.0 # Standard-electricity PE factor per RdSAP10 Table 32 (p.95) / SAP10.2 # Table 12 = 1.501. Table 12e (p.195) provides monthly overrides — see # the per-end-use PE factor fields below for the monthly cascade. other_primary_factor: float = 1.501 # Per-end-use effective PE factors. For electricity end-uses with known # monthly kWh distribution, cert_to_inputs computes the days-weighted # Table 12e factor Σ(kWh_m × PE_m) / Σ(kWh_m). Gas end-uses keep the # annual Table 12 factor. None → calculator falls back to the global # `space_heating_primary_factor` / `hot_water_primary_factor` / # `other_primary_factor` (legacy synthetic path). secondary_heating_primary_factor: Optional[float] = None pumps_fans_primary_factor: Optional[float] = None lighting_primary_factor: Optional[float] = None electric_shower_primary_factor: Optional[float] = None # Generation offsets — applied as a cost credit against the ECF # numerator. SAP 10.2 Appendix M: PV self-consumption + export # 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 # SAP 10.2 Appendix M1 §3-4 PV onsite/export split. When both are # set, the PE cascade (and follow-up CO2/cost wiring) applies # IMPORT factors to the onsite-consumed portion and EXPORT factors # to the exported portion. None → legacy fall-through that credits # all PV at the IMPORT factor (over-credits the exported portion; # used by synthetic CalculatorInputs constructions in unit tests). pv_dwelling_kwh_per_yr: Optional[float] = None pv_exported_kwh_per_yr: Optional[float] = None # SAP 10.2 Appendix M1 §8 — per-cert PE factors for the PV split. # Mirrors the §7 CO2 cascade shape: the dwelling factor is the # effective monthly Table 12e IMPORT factor (Σ(E_PV,dw,m × PE_30,m) / # Σ(E_PV,dw,m)); the exported factor is the effective monthly # Table 12e factor for code 60 ("electricity sold to grid, PV"). # Both are precomputed in cert_to_inputs from the PV split. None # falls back to the legacy annual values: `other_primary_factor` # (1.501, standard electricity) for the dwelling portion and # `pv_export_primary_factor` (0.501) for the exported portion — # preserves synthetic CalculatorInputs constructions. pv_dwelling_primary_factor: Optional[float] = None pv_exported_primary_factor: Optional[float] = None # Legacy annual fall-back for the exported PE factor (synthetic # constructions or zero-export months that yield no effective # monthly value). SAP 10.2 Table 12 code 60 = 0.501. pv_export_primary_factor: float = 0.501 # SAP 10.2 Appendix M1 §6 (p.94) — IMPORT price for onsite-consumed # PV generation. cert_to_inputs supplies this from Table 12a (standard # tariff or weighted off-peak per the dwelling's meter); synthetic # constructions leave it None to fall back to the legacy single-rate # credit at the EXPORT price. When set, the calculator's synthetic # cost fallback (the `fuel_cost is _ZERO` branch) credits onsite kWh # at this IMPORT price and exported kWh at `pv_export_credit_gbp_per_kwh`. pv_dwelling_import_price_gbp_per_kwh: Optional[float] = None # SAP 10.2 Appendix M1 §7 — per-cert CO2 factors for the PV split. # The dwelling factor is the effective monthly Table 12d IMPORT # factor (Σ(E_PV,dw,m × CO2_30,m) / Σ(E_PV,dw,m)); the exported # factor is the effective monthly Table 12d code-60 ("electricity # sold to grid, PV") factor. Both are computed in cert_to_inputs. # Synthetic CalculatorInputs constructions leave these None → no # PV CO2 credit applied (legacy behaviour). pv_dwelling_co2_factor_kg_per_kwh: Optional[float] = None pv_exported_co2_factor_kg_per_kwh: Optional[float] = None # 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 # SAP10.2 (107)m — space cooling requirement kWh per month from §8c # orchestrator `space_cooling_monthly_kwh`. Includes spec Jun-Aug # inclusion mask + 1-kWh clamp. Default (0,)*12 for backwards # compatibility — every cert without `has_fixed_air_conditioning` # collapses cooling to zero. space_cooling_monthly_kwh: tuple[float, ...] = (0.0,) * 12 # SAP10.2 (109) — Fabric Energy Efficiency precomputed by cert_to_inputs # via `fabric_energy_efficiency_kwh_per_m2_yr` from the §8/§8c results. # Default 0.0 for backwards compatibility — synthetic CalculatorInputs # constructions without cert_to_inputs leave it unset. fabric_energy_efficiency_kwh_per_m2_yr: float = 0.0 # SAP10.2 §9a — per-system energy requirements (201)..(221) precomputed # by cert_to_inputs via `space_heating_fuel_monthly_kwh`. Calculator # reads `main_1_fuel_monthly_kwh` and `secondary_fuel_monthly_kwh` for # per-month fuel attribution; existing `main_heating_efficiency` / # `secondary_heating_efficiency` / `secondary_heating_fraction` fields # are now redundant inputs (kept for backwards compat + audit). energy_requirements: EnergyRequirementsResult = field( default_factory=lambda: _ZERO_ENERGY_REQUIREMENTS_RESULT ) # SAP10.2 §10a — fuel-cost line refs (240)..(255) precomputed by # cert_to_inputs via `fuel_cost(...)`. Default zero result so non- # cert constructions keep working through the inline cost math # (calculator routes through `inputs.fuel_cost.total_cost_gbp` only # when the precompute lodges a non-zero `total_cost_gbp`). fuel_cost: FuelCostResult = field( default_factory=lambda: _ZERO_FUEL_COST_RESULT ) # Table 32 standing charges (electric off-peak high-rate code + # mains gas) — added to `total_cost` when the calculator's off- # peak fallback path fires. STANDARD-tariff certs route through # `fuel_cost.additional_standing_charges_gbp` instead and ignore # this field. cert_to_inputs sets this via `additional_standing_ # charges_gbp(main_fuel_code, water_heating_fuel_code, tariff)`. standing_charges_gbp: float = 0.0 @dataclass(frozen=True) class MonthlyEntry: """Per-month worksheet outputs for downstream audit. SAP 10.2 §§5-9.""" month: int external_temp_c: float internal_temp_c: float internal_gains_w: float solar_gains_w: float heat_loss_rate_w: float utilisation_factor: float space_heat_requirement_kwh: float main_heating_fuel_kwh: float secondary_heating_fuel_kwh: float = 0.0 space_cool_requirement_kwh: float = 0.0 @dataclass(frozen=True) class SapResult: """Calculator output. `sap_score` is the rounded RdSAP-style integer (1-100+); `sap_score_continuous` keeps the un-rounded value for sensitivity analysis.""" sap_score: int sap_score_continuous: float ecf: float total_fuel_cost_gbp: float co2_kg_per_yr: float space_heating_kwh_per_yr: float space_cooling_kwh_per_yr: float fabric_energy_efficiency_kwh_per_m2_yr: float main_heating_fuel_kwh_per_yr: float main_2_heating_fuel_kwh_per_yr: float secondary_heating_fuel_kwh_per_yr: float space_cooling_fuel_kwh_per_yr: float hot_water_kwh_per_yr: float pumps_fans_kwh_per_yr: float lighting_kwh_per_yr: float # Unregulated annual delivered electricity for ADR-0014 # BillDerivation (APPLIANCES + COOKING sections). Output-only — these # do NOT contribute to ecf / total_fuel_cost_gbp / co2_kg_per_yr / # primary_energy_kwh_per_yr / sap_score. `cooking_kwh_per_yr` is the # SAP 10.2 Appendix L L20 (p.91) ELECTRICITY estimate (138 + 28×N); # the bill adapter should treat it as an electricity carrier (a # gas-cooker split, if ever needed, is a separate follow-up). appliances_kwh_per_yr: float cooking_kwh_per_yr: float primary_energy_kwh_per_yr: float primary_energy_kwh_per_m2: float monthly: tuple[MonthlyEntry, ...] intermediate: dict[str, float] def _time_constant_h(*, tmp_kj_per_m2_k: float, tfa_m2: float, hlc_w_per_k: float) -> float: if hlc_w_per_k <= 0: return float("inf") return tmp_kj_per_m2_k * tfa_m2 / (_TIME_CONSTANT_DIVISOR_KJ_TO_WH * hlc_w_per_k) def _solve_month( *, inputs: CalculatorInputs, month: int, hlc_w_per_k: float, time_constant_h: float, heat_loss_parameter: float, ) -> MonthlyEntry: t_ext = ( inputs.monthly_external_temp_c_override[month - 1] if inputs.monthly_external_temp_c_override is not None else external_temperature_c(inputs.region, month) ) g_int = inputs.internal_gains_monthly_w[month - 1] g_sol = inputs.solar_gains_monthly_w[month - 1] # SAP 10.2 §7 Table 9c is a sequential chain (steps 1-9); the §7 # orchestrator computes (93)m and (94)m upstream and the calculator # consumes them by index. No fixed-point iteration here. _ = time_constant_h # τ now lives inside the §7 orchestrator _ = heat_loss_parameter t_int = inputs.mean_internal_temp_monthly_c[month - 1] eta = inputs.utilisation_factor_monthly[month - 1] loss_rate_w = max(0.0, hlc_w_per_k * (t_int - t_ext)) # SAP 10.2 §8 — (98c)m precomputed upstream by `space_heating_monthly_kwh` # (includes Table 9c summer clamp Jun..Sep). Calculator indexes directly. q_heat = inputs.space_heating_monthly_kwh[month - 1] # SAP 10.2 §9a — (211)m/(215)m precomputed upstream by # `space_heating_fuel_monthly_kwh`. Calculator stops doing q/η inline. fuel_main = inputs.energy_requirements.main_1_fuel_monthly_kwh[month - 1] fuel_secondary = inputs.energy_requirements.secondary_fuel_monthly_kwh[month - 1] # SAP 10.2 §8c — (107)m precomputed upstream by `space_cooling_monthly_kwh` # (includes Jun-Aug inclusion mask + post-f_C × f_intermittent clamp). q_cool = inputs.space_cooling_monthly_kwh[month - 1] return MonthlyEntry( month=month, external_temp_c=t_ext, internal_temp_c=t_int, internal_gains_w=g_int, solar_gains_w=g_sol, heat_loss_rate_w=loss_rate_w, utilisation_factor=eta, space_heat_requirement_kwh=q_heat, main_heating_fuel_kwh=fuel_main, secondary_heating_fuel_kwh=fuel_secondary, space_cool_requirement_kwh=q_cool, ) def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: """Run SAP 10.2 §§5-13 monthly loop on synthetic inputs; return a typed `SapResult`. Cert-shape mapping is the job of `cert_to_inputs` (S-A7b); this entry point is pure physics.""" tfa = inputs.dimensions.total_floor_area_m2 volume = inputs.dimensions.volume_m3 transmission_hlc = inputs.heat_transmission.total_w_per_k # SAP10.2 §3 line (38): ventilation HLC = 0.33 × (25)m × volume — # monthly because (25)m varies with Table U2 wind. HLC, HLP, and the # time constant τ all become 12-tuples. monthly_hlc_v = tuple( ach * volume * _AIR_HEAT_CAPACITY_WH_PER_M3_K for ach in inputs.monthly_infiltration_ach ) monthly_hlc = tuple(transmission_hlc + hv for hv in monthly_hlc_v) monthly_hlp = tuple(h / tfa if tfa > 0 else 0.0 for h in monthly_hlc) monthly_tau_h = tuple( _time_constant_h( tmp_kj_per_m2_k=inputs.thermal_mass_parameter_kj_per_m2_k, tfa_m2=tfa, hlc_w_per_k=h, ) for h in monthly_hlc ) monthly = tuple( _solve_month( inputs=inputs, month=m, hlc_w_per_k=monthly_hlc[m - 1], time_constant_h=monthly_tau_h[m - 1], heat_loss_parameter=monthly_hlp[m - 1], ) for m in range(1, 13) ) space_heating_kwh = sum(e.space_heat_requirement_kwh for e in monthly) space_cooling_kwh = sum(e.space_cool_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 ) # SAP10.2 §10a Fuel costs — line refs (240)..(255) precomputed by # cert_to_inputs._fuel_cost via the worksheet/fuel_cost orchestrator # (Table 32 prices, Table 12a fractions, Table 12 note (a) standing- # charge gating). Calculator unpacks the precompute when populated; # synthetic-test CalculatorInputs constructions that leave the slot # at its zero default still use the legacy inline cost math (scalar # cost fields × kWh). That legacy path is slated for removal once # the synthetic test corpus migrates to `fuel_cost=` (future ticket). if inputs.fuel_cost is not _ZERO_FUEL_COST_RESULT and ( inputs.fuel_cost.total_cost_gbp != 0.0 or inputs.fuel_cost.additional_standing_charges_gbp != 0.0 ): fuel_cost_result = inputs.fuel_cost total_cost = fuel_cost_result.total_cost_gbp main_heating_cost = ( fuel_cost_result.main_1_total_cost_gbp + fuel_cost_result.main_2_total_cost_gbp ) secondary_heating_cost = fuel_cost_result.secondary_total_cost_gbp hot_water_cost = ( fuel_cost_result.water_high_rate_cost_gbp + fuel_cost_result.water_low_rate_cost_gbp + fuel_cost_result.water_other_fuel_cost_gbp ) pumps_fans_cost = fuel_cost_result.pumps_fans_cost_gbp lighting_cost = fuel_cost_result.lighting_cost_gbp pv_credit = -fuel_cost_result.pv_credit_gbp else: # SAP 10.2 Appendix M1 §6 — synthetic-path β-split credit. When # cert_to_inputs supplies the split (E_PV,dw + E_PV,ex + dwelling # IMPORT price) credit onsite kWh at IMPORT and exported kWh at # EXPORT; otherwise fall through to the legacy single-rate credit # at the EXPORT price (preserves unit-test fixtures that lodge # only `pv_generation_kwh_per_yr` + `pv_export_credit_gbp_per_kwh`). if ( inputs.pv_dwelling_kwh_per_yr is not None and inputs.pv_exported_kwh_per_yr is not None and inputs.pv_dwelling_import_price_gbp_per_kwh is not None ): pv_credit = ( inputs.pv_dwelling_kwh_per_yr * inputs.pv_dwelling_import_price_gbp_per_kwh + inputs.pv_exported_kwh_per_yr * inputs.pv_export_credit_gbp_per_kwh ) else: pv_credit = ( inputs.pv_generation_kwh_per_yr * inputs.pv_export_credit_gbp_per_kwh ) main_heating_cost = main_fuel_kwh * inputs.space_heating_fuel_cost_gbp_per_kwh secondary_heating_cost = ( secondary_fuel_kwh * inputs.secondary_heating_fuel_cost_gbp_per_kwh ) hot_water_cost = ( inputs.hot_water_kwh_per_yr * inputs.hot_water_fuel_cost_gbp_per_kwh ) pumps_fans_rate = ( inputs.pumps_fans_fuel_cost_gbp_per_kwh if inputs.pumps_fans_fuel_cost_gbp_per_kwh is not None else inputs.other_fuel_cost_gbp_per_kwh ) pumps_fans_cost = inputs.pumps_fans_kwh_per_yr * pumps_fans_rate lighting_cost = inputs.lighting_kwh_per_yr * inputs.other_fuel_cost_gbp_per_kwh # SAP 10.2 §10a (PDF p.145) line (247a): instantaneous electric # showers route their (64a) kWh through the "other fuel" tariff # and add to (255) total cost. The `fuel_cost`-based path above # already includes this via `instant_shower_cost_gbp`; the # fallback scalar path was silently dropping it on TEN_HOUR / # zero-fuel-cost certs (cert 000565 surfaced this as a £93 # under-count once the upstream Elmhurst extractor began # reporting the shower roster correctly). electric_shower_cost = ( inputs.electric_shower_kwh_per_yr * inputs.other_fuel_cost_gbp_per_kwh ) total_cost = max( 0.0, main_heating_cost + secondary_heating_cost + hot_water_cost + electric_shower_cost + pumps_fans_cost + lighting_cost + inputs.standing_charges_gbp - pv_credit, ) 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) co2_factor = inputs.co2_factor_kg_per_kwh # Per-end-use effective CO2 factors (Table 12d monthly cascade for # electricity, annual for gas). cert_to_inputs supplies these from # monthly kWh × monthly Table 12d factors; synthetic constructions # without per-end-use values fall back to the legacy single factor. main_co2_factor = inputs.main_heating_co2_factor_kg_per_kwh or co2_factor secondary_co2_factor = inputs.secondary_heating_co2_factor_kg_per_kwh or co2_factor hot_water_co2_factor = inputs.hot_water_co2_factor_kg_per_kwh or co2_factor pumps_fans_co2_factor = inputs.pumps_fans_co2_factor_kg_per_kwh or co2_factor lighting_co2_factor = inputs.lighting_co2_factor_kg_per_kwh or co2_factor electric_shower_co2_factor = ( inputs.electric_shower_co2_factor_kg_per_kwh or co2_factor ) main_heating_co2 = main_fuel_kwh * main_co2_factor secondary_heating_co2 = secondary_fuel_kwh * secondary_co2_factor hot_water_co2 = inputs.hot_water_kwh_per_yr * hot_water_co2_factor pumps_fans_co2 = inputs.pumps_fans_kwh_per_yr * pumps_fans_co2_factor lighting_co2 = inputs.lighting_kwh_per_yr * lighting_co2_factor electric_shower_co2 = ( inputs.electric_shower_kwh_per_yr * electric_shower_co2_factor ) co2 = ( main_heating_co2 + secondary_heating_co2 + hot_water_co2 + pumps_fans_co2 + lighting_co2 + electric_shower_co2 ) # SAP 10.2 Appendix M1 §7 — subtract PV CO2 credit. Onsite consumption # offsets grid imports at the IMPORT CO2 factor (Table 12d weighted # by E_PV,dw,m); exports credit at the EXPORT CO2 factor (Table 12d # code 60 weighted by E_PV,ex,m). Both factors are precomputed in # cert_to_inputs; None preserves the legacy zero-credit behaviour # for synthetic CalculatorInputs constructions. if ( inputs.pv_dwelling_kwh_per_yr is not None and inputs.pv_dwelling_co2_factor_kg_per_kwh is not None ): co2 -= ( inputs.pv_dwelling_kwh_per_yr * inputs.pv_dwelling_co2_factor_kg_per_kwh ) if ( inputs.pv_exported_kwh_per_yr is not None and inputs.pv_exported_co2_factor_kg_per_kwh is not None ): co2 -= ( inputs.pv_exported_kwh_per_yr * inputs.pv_exported_co2_factor_kg_per_kwh ) # Per-end-use effective PE factors. Same shape as the CO2 cascade: # electricity end-uses use Table 12e (p.195) monthly factors weighted # by per-month kWh; gas end-uses use the annual Table 12 / Table 32 # PE factor. Defaults fall back to the legacy single-factor path so # synthetic CalculatorInputs constructions keep working. secondary_primary_factor = ( inputs.secondary_heating_primary_factor if inputs.secondary_heating_primary_factor is not None else inputs.space_heating_primary_factor ) pumps_fans_primary_factor = ( inputs.pumps_fans_primary_factor if inputs.pumps_fans_primary_factor is not None else inputs.other_primary_factor ) lighting_primary_factor = ( inputs.lighting_primary_factor if inputs.lighting_primary_factor is not None else inputs.other_primary_factor ) electric_shower_primary_factor = ( inputs.electric_shower_primary_factor if inputs.electric_shower_primary_factor is not None else inputs.other_primary_factor ) space_heating_primary_kwh = ( main_fuel_kwh * inputs.space_heating_primary_factor + secondary_fuel_kwh * secondary_primary_factor ) hot_water_primary_kwh = inputs.hot_water_kwh_per_yr * inputs.hot_water_primary_factor other_primary_kwh = ( inputs.pumps_fans_kwh_per_yr * pumps_fans_primary_factor + inputs.lighting_kwh_per_yr * lighting_primary_factor + inputs.electric_shower_kwh_per_yr * electric_shower_primary_factor ) # SAP 10.2 Appendix M1 §8: PV onsite consumption credits at IMPORT # PEF (offsets grid imports); PV exports credit at the EXPORT PEF # ("electricity sold to grid, PV" — Table 12 code 60 = 0.501). When # the cert→inputs cascade has computed the β-split (§3-4 in # `domain.sap10_calculator.worksheet.photovoltaic`), use it; fall # back to all-IMPORT for synthetic CalculatorInputs constructions # in unit tests (which don't supply the split). if ( inputs.pv_dwelling_kwh_per_yr is not None and inputs.pv_exported_kwh_per_yr is not None ): pv_dwelling_pe_factor = ( inputs.pv_dwelling_primary_factor if inputs.pv_dwelling_primary_factor is not None else inputs.other_primary_factor ) pv_exported_pe_factor = ( inputs.pv_exported_primary_factor if inputs.pv_exported_primary_factor is not None else inputs.pv_export_primary_factor ) pv_primary_offset_kwh = ( inputs.pv_dwelling_kwh_per_yr * pv_dwelling_pe_factor + inputs.pv_exported_kwh_per_yr * pv_exported_pe_factor ) else: pv_primary_offset_kwh = ( inputs.pv_generation_kwh_per_yr * inputs.other_primary_factor ) primary_energy_kwh = max( 0.0, space_heating_primary_kwh + hot_water_primary_kwh + other_primary_kwh - pv_primary_offset_kwh, ) primary_energy_per_m2 = primary_energy_kwh / tfa if tfa > 0 else 0.0 ht = inputs.heat_transmission intermediate: dict[str, float] = { "tfa_m2": inputs.dimensions.total_floor_area_m2, "volume_m3": inputs.dimensions.volume_m3, "storey_count": float(inputs.dimensions.storey_count), "walls_w_per_k": ht.walls_w_per_k, "roof_w_per_k": ht.roof_w_per_k, "floor_w_per_k": ht.floor_w_per_k, "party_walls_w_per_k": ht.party_walls_w_per_k, "windows_w_per_k": ht.windows_w_per_k, "roof_windows_w_per_k": ht.roof_windows_w_per_k, "doors_w_per_k": ht.doors_w_per_k, "thermal_bridging_w_per_k": ht.thermal_bridging_w_per_k, # Annual means for the back-compat single-float audit dict; full # monthly arrays are available via the upstream VentilationResult. "infiltration_ach": sum(inputs.monthly_infiltration_ach) / 12.0, "infiltration_w_per_k": sum(monthly_hlc_v) / 12.0, "heat_transfer_coefficient_w_per_k": sum(monthly_hlc) / 12.0, "heat_loss_parameter_w_per_m2k": sum(monthly_hlp) / 12.0, "time_constant_h": sum(monthly_tau_h) / 12.0, "internal_gains_annual_avg_w": sum(e.internal_gains_w for e in monthly) / 12.0, "mean_internal_temp_annual_avg_c": sum(e.internal_temp_c for e in monthly) / 12.0, "useful_space_heating_kwh_per_yr": space_heating_kwh, "main_heating_cost_gbp": main_heating_cost, "secondary_heating_cost_gbp": secondary_heating_cost, "hot_water_cost_gbp": hot_water_cost, "pumps_fans_cost_gbp": pumps_fans_cost, "lighting_cost_gbp": lighting_cost, "pv_export_credit_gbp": pv_credit, "ecf": ecf, "deflator": ENERGY_COST_DEFLATOR, "delivered_fuel_kwh_per_yr": delivered_fuel_kwh, "co2_factor_kg_per_kwh": co2_factor, "main_heating_co2_kg_per_yr": main_heating_co2, "secondary_heating_co2_kg_per_yr": secondary_heating_co2, "hot_water_co2_kg_per_yr": hot_water_co2, "pumps_fans_co2_kg_per_yr": pumps_fans_co2, "lighting_co2_kg_per_yr": lighting_co2, "space_heating_pe_kwh_per_m2": space_heating_primary_kwh / tfa if tfa > 0 else 0.0, "hot_water_pe_kwh_per_m2": hot_water_primary_kwh / tfa if tfa > 0 else 0.0, "other_pe_kwh_per_m2": other_primary_kwh / tfa if tfa > 0 else 0.0, "pv_pe_offset_kwh_per_m2": pv_primary_offset_kwh / tfa if tfa > 0 else 0.0, "floor_area_offset_m2": FLOOR_AREA_OFFSET_M2, "ecf_log_threshold": ECF_LOG_THRESHOLD, } return SapResult( sap_score=sap_int, sap_score_continuous=sap_cont, ecf=ecf, total_fuel_cost_gbp=total_cost, co2_kg_per_yr=co2, space_heating_kwh_per_yr=space_heating_kwh, space_cooling_kwh_per_yr=space_cooling_kwh, fabric_energy_efficiency_kwh_per_m2_yr=inputs.fabric_energy_efficiency_kwh_per_m2_yr, main_heating_fuel_kwh_per_yr=main_fuel_kwh, main_2_heating_fuel_kwh_per_yr=inputs.energy_requirements.main_2_fuel_kwh_per_yr, secondary_heating_fuel_kwh_per_yr=secondary_fuel_kwh, space_cooling_fuel_kwh_per_yr=inputs.energy_requirements.cooling_fuel_kwh_per_yr, 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, appliances_kwh_per_yr=inputs.appliances_kwh_per_yr, cooking_kwh_per_yr=inputs.cooking_kwh_per_yr, primary_energy_kwh_per_yr=primary_energy_kwh, primary_energy_kwh_per_m2=primary_energy_per_m2, monthly=monthly, intermediate=intermediate, ) class SapCalculator(ABC): """The contract a SAP calculator satisfies: an `EpcPropertyData` in, a typed `SapResult` out. `Sap10Calculator` is the SAP 10.2 implementation; a future methodology (e.g. SAP 10.3 / a successor) is another subclass. Consumers (e.g. `CalculatorRebaseliner`) depend on this abstraction, not on a concrete calculator — so the engine can be swapped without touching them. """ @abstractmethod def calculate(self, epc: "EpcPropertyData") -> SapResult: ... class Sap10Calculator(SapCalculator): """Deterministic SAP 10.2 calculator entry point. Maps an `EpcPropertyData` to typed `CalculatorInputs` via the RdSAP-driven `cert_to_inputs` mapper and runs the 12-month worksheet loop. Separating mapping (cert-shape rules, RdSAP defaults) from the physics orchestration (`calculate_sap_from_inputs`) lets either side be tested without dragging in the other — and lets product code that already has a populated `CalculatorInputs` (e.g. a future MeasureApplicator that emits modified inputs) skip the mapper. """ def calculate(self, epc: "EpcPropertyData") -> SapResult: from domain.sap10_calculator.rdsap.cert_to_inputs import cert_to_inputs return calculate_sap_from_inputs(cert_to_inputs(epc))