mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
9106621aee
commit
684e2945ae
3 changed files with 470 additions and 0 deletions
274
packages/domain/src/domain/sap/calculator.py
Normal file
274
packages/domain/src/domain/sap/calculator.py
Normal 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 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,
|
||||
)
|
||||
0
packages/domain/src/domain/sap/tests/__init__.py
Normal file
0
packages/domain/src/domain/sap/tests/__init__.py
Normal file
196
packages/domain/src/domain/sap/tests/test_calculator.py
Normal file
196
packages/domain/src/domain/sap/tests/test_calculator.py
Normal 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 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)
|
||||
Loading…
Add table
Reference in a new issue