diff --git a/packages/domain/src/domain/sap/calculator.py b/packages/domain/src/domain/sap/calculator.py new file mode 100644 index 00000000..881c8931 --- /dev/null +++ b/packages/domain/src/domain/sap/calculator.py @@ -0,0 +1,274 @@ +"""SAP 10.3 synthetic-input 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.sap.rdsap.cert_to_inputs` (Session A slice 7b). Splitting the two +keeps orchestration testable against synthetic inputs without dragging in +cert-shape assumptions. + +Each month: + + 1. External temperature, wind speed, horizontal solar irradiance from + Appendix U Tables U1-U3 by region + month. + 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) — iterated twice because each depends on the other; SAP + 10.3 §7.3 says two passes are sufficient. + 7. Useful space-heating requirement (Table 9c step 10). + 8. Delivered fuel kWh = Q_heat / main-heating efficiency. + +Annual totals = month sums; ECF = §13 Table 12 deflator × total cost / +(TFA + 45); SAP rating from §13 piecewise log/linear; CO2 from CO2 +emission factor × delivered fuel (single-fuel approximation in this +slice — slice S-A8 splits hot-water/lighting onto per-fuel factors). + +Reference: SAP 10.3 specification (13-01-2026) §§5-13 (pages 23-43), Table +9a/9b/9c (pages 184-186), Table 12 (page 191), Appendix L + U. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final + +from domain.sap.climate.appendix_u import external_temperature_c +from domain.sap.worksheet.dimensions import Dimensions +from domain.sap.worksheet.heat_transmission import HeatTransmission +from domain.sap.worksheet.internal_gains import internal_gains_w +from domain.sap.worksheet.mean_internal_temperature import mean_internal_temperature_c +from domain.sap.worksheet.rating import energy_cost_factor, sap_rating, sap_rating_integer +from domain.sap.worksheet.solar_gains import ( + Orientation, + surface_solar_flux_w_per_m2, + window_solar_gain_w, +) +from domain.sap.worksheet.space_heating import monthly_heat_requirement_kwh +from domain.sap.worksheet.utilisation_factor import utilisation_factor + + +_DAYS_IN_MONTH: Final[tuple[int, ...]] = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) +_AIR_HEAT_CAPACITY_WH_PER_M3_K: Final[float] = 0.33 +_TIME_CONSTANT_DIVISOR_KJ_TO_WH: Final[float] = 3.6 +_ETA_ITERATIONS: Final[int] = 2 + + +@dataclass(frozen=True) +class WindowInput: + """One glazed opening contributing solar gain. Orientation maps to a + Table U5 column and to Table U4 latitude via `surface_solar_flux_w_per_m2`. + `g_perpendicular`, `frame_factor`, `overshading_factor` come from + Tables 6b/6c/6d — supplied by the caller so this module remains + physics-only.""" + + area_m2: float + orientation: Orientation + pitch_deg: float + g_perpendicular: float + frame_factor: float + overshading_factor: float + + +@dataclass(frozen=True) +class CalculatorInputs: + """Synthetic SAP 10.3 calculator inputs. The cert→inputs mapper + (S-A7b) produces one of these from an `EpcPropertyData`.""" + + dimensions: Dimensions + heat_transmission: HeatTransmission + infiltration_ach: float + region: int + windows: tuple[WindowInput, ...] + 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 + fuel_unit_cost_gbp_per_kwh: float + co2_factor_kg_per_kwh: float + + +@dataclass(frozen=True) +class MonthlyEntry: + """Per-month worksheet outputs for downstream audit. SAP 10.3 §§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 + + +@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 + main_heating_fuel_kwh_per_yr: float + hot_water_kwh_per_yr: float + pumps_fans_kwh_per_yr: float + lighting_kwh_per_yr: float + monthly: tuple[MonthlyEntry, ...] + + +def _solar_gains_w( + *, windows: tuple[WindowInput, ...], region: int, month: int +) -> float: + total = 0.0 + for w in windows: + s = surface_solar_flux_w_per_m2( + orientation=w.orientation, + pitch_deg=w.pitch_deg, + region=region, + month=month, + ) + total += window_solar_gain_w( + area_m2=w.area_m2, + surface_flux_w_per_m2=s, + g_perpendicular=w.g_perpendicular, + frame_factor=w.frame_factor, + overshading_factor=w.overshading_factor, + ) + return total + + +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 = external_temperature_c(inputs.region, month) + g_int = internal_gains_w( + total_floor_area_m2=inputs.dimensions.total_floor_area_m2, + month=month, + ).total_w + g_sol = _solar_gains_w(windows=inputs.windows, region=inputs.region, month=month) + g_total = g_int + g_sol + + # SAP 10.3 §7.3: two-pass iteration. Seed η = 1, compute T_internal, + # recompute η from the resulting loss rate, then once more. + eta = 1.0 + t_int = 0.0 + loss_rate_w = 0.0 + for _ in range(_ETA_ITERATIONS): + t_int = mean_internal_temperature_c( + external_temp_c=t_ext, + heat_transfer_coefficient_w_per_k=hlc_w_per_k, + total_gains_w=g_total, + utilisation_factor=eta, + time_constant_h=time_constant_h, + heat_loss_parameter=heat_loss_parameter, + living_area_fraction=inputs.living_area_fraction, + control_type=inputs.control_type, + responsiveness=inputs.responsiveness, + control_temperature_adjustment_c=inputs.control_temperature_adjustment_c, + ) + loss_rate_w = max(0.0, hlc_w_per_k * (t_int - t_ext)) + eta = utilisation_factor( + total_gains_w=g_total, + heat_loss_rate_w=loss_rate_w, + time_constant_h=time_constant_h, + ) + + q_heat = monthly_heat_requirement_kwh( + heat_transfer_coefficient_w_per_k=hlc_w_per_k, + internal_temperature_c=t_int, + external_temperature_c=t_ext, + utilisation_factor=eta, + 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 + + 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, + ) + + +def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: + """Run SAP 10.3 §§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 + hlc_v = inputs.infiltration_ach * inputs.dimensions.volume_m3 * _AIR_HEAT_CAPACITY_WH_PER_M3_K + hlc = inputs.heat_transmission.total_w_per_k + hlc_v + hlp = hlc / tfa if tfa > 0 else 0.0 + tau_h = _time_constant_h( + tmp_kj_per_m2_k=inputs.thermal_mass_parameter_kj_per_m2_k, + tfa_m2=tfa, + hlc_w_per_k=hlc, + ) + + monthly = tuple( + _solve_month( + inputs=inputs, + month=m, + hlc_w_per_k=hlc, + time_constant_h=tau_h, + heat_loss_parameter=hlp, + ) + for m in range(1, 13) + ) + + 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) + delivered_fuel_kwh = ( + main_fuel_kwh + + inputs.hot_water_kwh_per_yr + + inputs.pumps_fans_kwh_per_yr + + inputs.lighting_kwh_per_yr + ) + total_cost = delivered_fuel_kwh * inputs.fuel_unit_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) + co2 = delivered_fuel_kwh * inputs.co2_factor_kg_per_kwh + + 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, + main_heating_fuel_kwh_per_yr=main_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, + monthly=monthly, + ) diff --git a/packages/domain/src/domain/sap/tests/__init__.py b/packages/domain/src/domain/sap/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/domain/src/domain/sap/tests/test_calculator.py b/packages/domain/src/domain/sap/tests/test_calculator.py new file mode 100644 index 00000000..b53b573b --- /dev/null +++ b/packages/domain/src/domain/sap/tests/test_calculator.py @@ -0,0 +1,196 @@ +"""Tests for the synthetic-input Sap10 calculator orchestrator. + +The orchestrator drives SAP 10.3's 12-month heat-balance loop from a +`CalculatorInputs` aggregate (geometry, envelope, ventilation, climate, +heating + the running-cost lines hot-water/pumps-fans/lighting). It +returns a typed `SapResult` carrying the SAP score, the cost/CO2 totals, +and a 12-entry `monthly` breakdown so downstream consumers can audit +month-by-month physics. + +Tests use synthetic inputs (not cert-derived) so that orchestration +behaviour is verified independently of the cert→inputs mapper (S-A7b). + +Reference: SAP 10.3 specification (13-01-2026) §§5-13 + Table 9c (the +worksheet step list) + Table 12 (Energy Cost Deflator 0.36). +""" + +from __future__ import annotations + +from dataclasses import replace + +import pytest + +from domain.sap.calculator import ( + CalculatorInputs, + SapResult, + WindowInput, + calculate_sap_from_inputs, +) +from domain.sap.worksheet.dimensions import Dimensions +from domain.sap.worksheet.heat_transmission import HeatTransmission +from domain.sap.worksheet.solar_gains import Orientation + + +def _baseline_inputs() -> CalculatorInputs: + """Reference dwelling for orchestrator tests — a 100 m² semi-detached + gas-boiler home in UK-average climate. Numbers chosen to land roughly + where a real RdSAP cert would: HLC ~150 W/K, τ ~100 h, SAP ~70.""" + dim = Dimensions( + total_floor_area_m2=100.0, + volume_m3=250.0, + storey_count=2, + avg_storey_height_m=2.5, + ground_floor_area_m2=50.0, + ground_floor_perimeter_m=30.0, + top_floor_area_m2=50.0, + gross_wall_area_m2=150.0, + party_wall_area_m2=50.0, + ) + ht = HeatTransmission( + walls_w_per_k=60.0, + roof_w_per_k=20.0, + floor_w_per_k=20.0, + party_walls_w_per_k=0.0, + windows_w_per_k=25.0, + doors_w_per_k=5.0, + thermal_bridging_w_per_k=20.0, + total_w_per_k=150.0, + ) + windows = ( + WindowInput( + area_m2=4.0, + orientation=Orientation.S, + pitch_deg=90.0, + g_perpendicular=0.63, + frame_factor=0.7, + overshading_factor=0.77, + ), + WindowInput( + area_m2=4.0, + orientation=Orientation.N, + pitch_deg=90.0, + g_perpendicular=0.63, + frame_factor=0.7, + overshading_factor=0.77, + ), + ) + return CalculatorInputs( + dimensions=dim, + heat_transmission=ht, + infiltration_ach=0.7, + region=0, + windows=windows, + control_type=2, + responsiveness=1.0, + living_area_fraction=0.30, + control_temperature_adjustment_c=0.0, + thermal_mass_parameter_kj_per_m2_k=250.0, + main_heating_efficiency=0.85, + 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, + co2_factor_kg_per_kwh=0.21, + ) + + +def test_calculator_returns_twelve_month_breakdown_and_plausible_sap_score() -> None: + # Arrange — baseline 100 m² gas-boiler dwelling in UK-average climate. + inputs = _baseline_inputs() + + # Act + result = calculate_sap_from_inputs(inputs) + + # Assert — sanity, not exact: tracer bullet that the 12-month loop runs + # end-to-end and lands in a believable SAP band for the inputs. + assert isinstance(result, SapResult) + assert len(result.monthly) == 12 + assert 1 <= result.sap_score <= 100 + assert result.space_heating_kwh_per_yr > 0 + assert result.total_fuel_cost_gbp > 0 + assert result.ecf > 0 + # The "main_heating_fuel + hot_water + pumps_fans + lighting" totals + # must reconcile with the cost line through the fuel unit cost. + expected_fuel = ( + result.main_heating_fuel_kwh_per_yr + + result.hot_water_kwh_per_yr + + result.pumps_fans_kwh_per_yr + + 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 + ) + + +def test_higher_main_heating_efficiency_reduces_fuel_use() -> None: + # Arrange — Direction check: doubling the boiler efficiency must halve + # the main-heating fuel kWh, holding everything else constant. + base = _baseline_inputs() + high_eff = replace(base, main_heating_efficiency=base.main_heating_efficiency * 2.0) + + # Act + r_base = calculate_sap_from_inputs(base) + r_high = calculate_sap_from_inputs(high_eff) + + # Assert + assert r_base.space_heating_kwh_per_yr == pytest.approx( + r_high.space_heating_kwh_per_yr, rel=1e-6 + ) + assert r_high.main_heating_fuel_kwh_per_yr == pytest.approx( + r_base.main_heating_fuel_kwh_per_yr / 2.0, rel=1e-6 + ) + assert r_high.sap_score >= r_base.sap_score + + +def test_colder_climate_region_increases_space_heating_demand() -> None: + # Arrange — Direction check: same dwelling in Shetland (region 20) must + # require more space-heating kWh than in Thames (region 1) because the + # external-temperature column in Table U1 is consistently lower. + base = _baseline_inputs() + thames = replace(base, region=1) + shetland = replace(base, region=20) + + # Act + r_thames = calculate_sap_from_inputs(thames) + r_shetland = calculate_sap_from_inputs(shetland) + + # Assert + assert r_shetland.space_heating_kwh_per_yr > r_thames.space_heating_kwh_per_yr + + +def test_zero_heat_transmission_collapses_space_heating_to_zero() -> None: + # Arrange — When HLC = 0 (perfect envelope) and there's no ventilation + # heat loss, no month can have a positive loss rate, so space heating + # must be zero across the year. Demonstrates the η-clamp in the loss + # path doesn't introduce spurious demand. + base = _baseline_inputs() + no_loss = replace( + base, + heat_transmission=HeatTransmission(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + infiltration_ach=0.0, + ) + + # Act + result = calculate_sap_from_inputs(no_loss) + + # Assert + assert result.space_heating_kwh_per_yr == 0.0 + assert result.main_heating_fuel_kwh_per_yr == 0.0 + + +def test_ecf_uses_table_12_energy_cost_deflator() -> None: + # Arrange — §13 Equation (7): ECF = 0.36 × cost / (TFA + 45). The + # orchestrator must report an ECF that reconciles with this formula + # given the cost it reported. + inputs = _baseline_inputs() + + # Act + result = calculate_sap_from_inputs(inputs) + + # Assert + expected_ecf = ( + 0.36 + * result.total_fuel_cost_gbp + / (inputs.dimensions.total_floor_area_m2 + 45.0) + ) + assert result.ecf == pytest.approx(expected_ecf, rel=1e-6)