slice S-A7a: Sap10Calculator orchestrator (synthetic-input)

Wires SAP 10.3 §§5-13 into a 12-month heat-balance loop driven by a typed
CalculatorInputs aggregate, returning a typed SapResult with the score,
ECF, costs/CO2 totals, and a 12-entry monthly breakdown. Physics
assembly only — the cert→inputs mapper lands in S-A7b. η/T_internal
solved with two-pass iteration per SAP 10.3 §7.3.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-18 09:27:28 +00:00
parent 9106621aee
commit 684e2945ae
3 changed files with 470 additions and 0 deletions

View file

@ -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 certinputs 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,
)

View file

@ -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 certinputs 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)