§7 slice 4: calculator + cert_to_inputs wired to §7 orchestrator (atomic)

Adds CalculatorInputs.mean_internal_temp_monthly_c (93)m and
CalculatorInputs.utilisation_factor_monthly (94)m. _solve_month indexes
directly into both — the 2-pass η fixed-point loop is gone (SAP10.2 §7
Table 9c is sequential, not iterative).

cert_to_inputs computes per-month HTC = transmission HLC + 0.33·V·(25)m,
sums (73)m + (83)m for total gains, and calls
mean_internal_temperature_monthly to populate both new fields. Single
codepath for all callers.

Synthetic test fixtures (_baseline_inputs, _baseline_dwelling) compute
their MIT + η via the §7 orchestrator too — preserves consistency with
the cert path while keeping the BRE worked-example trace asserting the
new spec-correct per-zone η values.

Atomic with cert_to_inputs (originally planned as slice 4 + slice 5):
introducing the calculator fields without populating them in cert_to_inputs
would break every cert-driven test. e2e SAP-score tests (000490 within 1
point, 000474 within 7 points) still pass with the new sequential η path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-20 21:43:55 +00:00
parent ff5d8c70c1
commit 8ec9da4742
4 changed files with 146 additions and 48 deletions

View file

@ -88,6 +88,13 @@ class CalculatorInputs:
# 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, ...]
region: int
control_type: int
responsiveness: float
@ -182,30 +189,14 @@ def _solve_month(
g_sol = inputs.solar_gains_monthly_w[month - 1]
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,
)
# 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))
q_heat = monthly_heat_requirement_kwh(
heat_transfer_coefficient_w_per_k=hlc_w_per_k,

View file

@ -67,6 +67,10 @@ from domain.sap.worksheet.heat_transmission import (
DwellingExposure,
heat_transmission_from_cert,
)
from domain.sap.climate.appendix_u import external_temperature_c
from domain.sap.worksheet.mean_internal_temperature import (
mean_internal_temperature_monthly,
)
from domain.sap.worksheet.solar_gains import solar_gains_from_cert
from domain.sap.worksheet.ventilation import (
MechanicalVentilationKind,
@ -886,6 +890,40 @@ def cert_to_inputs(
internal_gains_result.total_internal_gains_monthly_w
)
solar_gains_monthly_w = solar_gains_from_cert(
epc=epc,
region=_region_index(epc.region_code),
overshading=_INTERNAL_GAINS_DEFAULT_OVERSHADING,
).total_solar_gains_monthly_w
# SAP10.2 §7 — compose (93)m + (94)m via the orchestrator. Per-month HTC
# = transmission HLC + 0.33·V·(25)m. Table 4e control adjustment is 0
# for the Elmhurst corpus (cert-side mapping is a future slice).
control_type_value = _control_type(main)
responsiveness_value = _responsiveness(main)
living_area_fraction_value = _living_area_fraction(epc.habitable_rooms_count)
monthly_total_gains_w = tuple(
internal_gains_monthly_w[m] + solar_gains_monthly_w[m] for m in range(12)
)
monthly_htc_w_per_k = tuple(
ht.total_w_per_k + 0.33 * dim.volume_m3 * ventilation.effective_monthly_ach[m]
for m in range(12)
)
mit_result = mean_internal_temperature_monthly(
monthly_external_temp_c=tuple(
external_temperature_c(_region_index(epc.region_code), m)
for m in range(1, 13)
),
monthly_total_gains_w=monthly_total_gains_w,
monthly_heat_transfer_coefficient_w_per_k=monthly_htc_w_per_k,
thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K,
total_floor_area_m2=dim.total_floor_area_m2,
control_type=control_type_value,
responsiveness=responsiveness_value,
living_area_fraction=living_area_fraction_value,
control_temperature_adjustment_c=0.0,
)
return CalculatorInputs(
dimensions=dim,
heat_transmission=ht,
@ -900,15 +938,15 @@ def cert_to_inputs(
# (Elmhurst data shows them all as `window_location = External wall`);
# both pass-throughs are empty. Per-fixture §6 conformance is
# exercised separately in `test_solar_gains.py`.
solar_gains_monthly_w=solar_gains_from_cert(
epc=epc,
region=_region_index(epc.region_code),
overshading=_INTERNAL_GAINS_DEFAULT_OVERSHADING,
).total_solar_gains_monthly_w,
solar_gains_monthly_w=solar_gains_monthly_w,
# SAP10.2 (93)m + (94)m — adjusted MIT and whole-dwelling η. From
# the §7 orchestrator above (Table 9c steps 1-9 sequential, per-zone η).
mean_internal_temp_monthly_c=mit_result.adjusted_mean_internal_temp_monthly,
utilisation_factor_monthly=mit_result.utilisation_factor_whole_monthly,
region=_region_index(epc.region_code),
control_type=_control_type(main),
responsiveness=_responsiveness(main),
living_area_fraction=_living_area_fraction(epc.habitable_rooms_count),
control_type=control_type_value,
responsiveness=responsiveness_value,
living_area_fraction=living_area_fraction_value,
control_temperature_adjustment_c=0.0,
thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K,
main_heating_efficiency=eff,

View file

@ -27,8 +27,12 @@ from domain.sap.calculator import (
CalculatorInputs,
calculate_sap_from_inputs,
)
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.mean_internal_temperature import (
mean_internal_temperature_monthly,
)
def _baseline_dwelling() -> CalculatorInputs:
@ -60,18 +64,36 @@ def _baseline_dwelling() -> CalculatorInputs:
total_external_element_area_m2=200.0, # synthetic placeholder
total_w_per_k=150.0,
)
internal_gains_monthly_w = (450.0,) * 12
solar_gains_monthly_w = (
70.1510, 118.4419, 161.4420, 202.5589, 231.7608, 232.9177,
223.3279, 200.6543, 175.3023, 130.5274, 83.7805, 60.2212,
)
ext_temp_monthly_c = tuple(external_temperature_c(0, m) for m in range(1, 13))
total_gains_monthly_w = tuple(
internal_gains_monthly_w[m] + solar_gains_monthly_w[m] for m in range(12)
)
htc_monthly_w_per_k = tuple(
ht.total_w_per_k + 0.33 * dim.volume_m3 * 0.7 for _ in range(12)
)
mit_result = mean_internal_temperature_monthly(
monthly_external_temp_c=ext_temp_monthly_c,
monthly_total_gains_w=total_gains_monthly_w,
monthly_heat_transfer_coefficient_w_per_k=htc_monthly_w_per_k,
thermal_mass_parameter_kj_per_m2_k=250.0,
total_floor_area_m2=dim.total_floor_area_m2,
control_type=2,
responsiveness=1.0,
living_area_fraction=0.30,
)
return CalculatorInputs(
dimensions=dim,
heat_transmission=ht,
monthly_infiltration_ach=(0.7,) * 12,
internal_gains_monthly_w=(450.0,) * 12,
# Hand-computed solar (S + N 4 m² panes, g⊥=0.63 FF=0.7 Z=0.77,
# UK-avg region 0, vertical) — captured at HEAD so the trace fixture
# matches the pre-§6-wiring numerical baseline.
solar_gains_monthly_w=(
70.1510, 118.4419, 161.4420, 202.5589, 231.7608, 232.9177,
223.3279, 200.6543, 175.3023, 130.5274, 83.7805, 60.2212,
),
internal_gains_monthly_w=internal_gains_monthly_w,
solar_gains_monthly_w=solar_gains_monthly_w,
mean_internal_temp_monthly_c=mit_result.adjusted_mean_internal_temp_monthly,
utilisation_factor_monthly=mit_result.utilisation_factor_whole_monthly,
region=0,
control_type=2,
responsiveness=1.0,

View file

@ -25,8 +25,12 @@ from domain.sap.calculator import (
SapResult,
calculate_sap_from_inputs,
)
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.mean_internal_temperature import (
mean_internal_temperature_monthly,
)
def _baseline_inputs() -> CalculatorInputs:
@ -56,6 +60,28 @@ def _baseline_inputs() -> CalculatorInputs:
total_external_element_area_m2=200.0, # synthetic placeholder
total_w_per_k=150.0,
)
internal_gains_monthly_w = (450.0,) * 12
solar_gains_monthly_w = (
70.1510, 118.4419, 161.4420, 202.5589, 231.7608, 232.9177,
223.3279, 200.6543, 175.3023, 130.5274, 83.7805, 60.2212,
)
ext_temp_monthly_c = tuple(external_temperature_c(0, m) for m in range(1, 13))
total_gains_monthly_w = tuple(
internal_gains_monthly_w[m] + solar_gains_monthly_w[m] for m in range(12)
)
htc_monthly_w_per_k = tuple(
ht.total_w_per_k + 0.33 * dim.volume_m3 * 0.7 for _ in range(12)
)
mit_result = mean_internal_temperature_monthly(
monthly_external_temp_c=ext_temp_monthly_c,
monthly_total_gains_w=total_gains_monthly_w,
monthly_heat_transfer_coefficient_w_per_k=htc_monthly_w_per_k,
thermal_mass_parameter_kj_per_m2_k=250.0,
total_floor_area_m2=dim.total_floor_area_m2,
control_type=2,
responsiveness=1.0,
living_area_fraction=0.30,
)
return CalculatorInputs(
dimensions=dim,
heat_transmission=ht,
@ -63,15 +89,14 @@ def _baseline_inputs() -> CalculatorInputs:
# Synthetic baseline internal gains: 450 W constant. Real
# per-month variation lives in §5 orchestrator output; tracer
# tests don't need the modulation to verify the SAP loop.
internal_gains_monthly_w=(450.0,) * 12,
internal_gains_monthly_w=internal_gains_monthly_w,
# Hand-computed solar (S + N 4 m² panes, g⊥=0.63 FF=0.7 Z=0.77,
# UK-avg region 0, vertical) — captured from §6 leaves at HEAD
# so this synthetic baseline keeps the same heat-balance gains
# as before the §6 wiring slice.
solar_gains_monthly_w=(
70.1510, 118.4419, 161.4420, 202.5589, 231.7608, 232.9177,
223.3279, 200.6543, 175.3023, 130.5274, 83.7805, 60.2212,
),
# UK-avg region 0, vertical) — captured from §6 leaves at HEAD.
solar_gains_monthly_w=solar_gains_monthly_w,
# §7 (93)m + (94)m precomputed from the orchestrator above so the
# baseline reflects spec-correct sequential per-zone η.
mean_internal_temp_monthly_c=mit_result.adjusted_mean_internal_temp_monthly,
utilisation_factor_monthly=mit_result.utilisation_factor_whole_monthly,
region=0,
control_type=2,
responsiveness=1.0,
@ -108,6 +133,28 @@ def test_calculator_consumes_solar_gains_monthly_w_field_for_per_month_solar() -
assert monthly.solar_gains_w == 100.0
def test_calculator_consumes_mean_internal_temp_and_utilisation_monthly_fields() -> None:
# Arrange — replace baseline inputs' MIT + η with explicit known 12-tuples.
# The §7 orchestrator produces these upstream; the calculator must just
# look them up, not iterate or recompute. 18.0 °C MIT + 0.8 η constant
# everywhere — distinct enough that any leftover iteration would drift.
explicit_mit = (18.0,) * 12
explicit_eta = (0.8,) * 12
inputs = replace(
_baseline_inputs(),
mean_internal_temp_monthly_c=explicit_mit,
utilisation_factor_monthly=explicit_eta,
)
# Act
result = calculate_sap_from_inputs(inputs)
# Assert
for monthly in result.monthly:
assert monthly.internal_temp_c == 18.0
assert monthly.utilisation_factor == 0.8
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()