mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +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