§6 slice 5: CalculatorInputs.solar_gains_monthly_w + per-month index lookup

Adds the §6 (83)m output as a required 12-tuple field on CalculatorInputs;
_solve_month indexes into it directly instead of recomputing solar each
month via _solar_gains_w(windows, region, month).

Test (test_calculator_consumes_solar_gains_monthly_w_field_for_per_month_solar)
pins the read path: an explicit non-zero monthly tuple flows through
calculate_sap_from_inputs unchanged.

cert_to_inputs preserves identical behaviour during the migration by
computing the new field via the legacy _solar_gains_w leaf per month.
Slice 6 swaps that for solar_gains_from_cert; slice 7 deletes the legacy
leaf + WindowInput + windows field.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-20 20:51:49 +00:00
parent 377caea20a
commit 376cdb6bc3
4 changed files with 45 additions and 2 deletions

View file

@ -105,6 +105,10 @@ class CalculatorInputs:
# zero out in summer per Table 5a. Produced by §5 orchestrator
# `internal_gains_from_cert` (called from cert_to_inputs).
internal_gains_monthly_w: tuple[float, ...]
# SAP10.2 (83)m — total solar gains W per month (Jan..Dec). Produced
# 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, ...]
region: int
windows: tuple[WindowInput, ...]
control_type: int
@ -218,7 +222,7 @@ def _solve_month(
) -> MonthlyEntry:
t_ext = external_temperature_c(inputs.region, month)
g_int = inputs.internal_gains_monthly_w[month - 1]
g_sol = _solar_gains_w(windows=inputs.windows, region=inputs.region, month=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,

View file

@ -52,7 +52,7 @@ from domain.ml.sap_efficiencies import (
seasonal_efficiency,
water_heating_efficiency as _legacy_water_heating_efficiency,
)
from domain.sap.calculator import CalculatorInputs, WindowInput
from domain.sap.calculator import CalculatorInputs, WindowInput, _solar_gains_w
from domain.sap.tables.table_12 import (
co2_factor_kg_per_kwh,
primary_energy_factor,
@ -998,6 +998,18 @@ def cert_to_inputs(
# SAP10.2 line (73)m — total internal gains W/month from §5
# orchestrator (composed above).
internal_gains_monthly_w=internal_gains_monthly_w,
# SAP10.2 line (83)m — total solar gains W/month. Computed here via
# the legacy `_solar_gains_w` per-month leaf to preserve identical
# behaviour during the §6 wiring migration; the next slice swaps
# this for the §6 orchestrator `solar_gains_from_cert`.
solar_gains_monthly_w=tuple(
_solar_gains_w(
windows=_window_inputs(epc.sap_windows),
region=_region_index(epc.region_code),
month=m,
)
for m in range(1, 13)
),
region=_region_index(epc.region_code),
windows=_window_inputs(epc.sap_windows),
control_type=_control_type(main),

View file

@ -26,6 +26,7 @@ import pytest
from domain.sap.calculator import (
CalculatorInputs,
WindowInput,
_solar_gains_w,
calculate_sap_from_inputs,
)
from domain.sap.worksheet.dimensions import Dimensions
@ -85,6 +86,9 @@ def _baseline_dwelling() -> CalculatorInputs:
heat_transmission=ht,
monthly_infiltration_ach=(0.7,) * 12,
internal_gains_monthly_w=(450.0,) * 12,
solar_gains_monthly_w=tuple(
_solar_gains_w(windows=windows, region=0, month=m) for m in range(1, 13)
),
region=0,
windows=windows,
control_type=2,

View file

@ -24,6 +24,7 @@ from domain.sap.calculator import (
CalculatorInputs,
SapResult,
WindowInput,
_solar_gains_w,
calculate_sap_from_inputs,
)
from domain.sap.worksheet.dimensions import Dimensions
@ -84,6 +85,9 @@ def _baseline_inputs() -> CalculatorInputs:
# 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,
solar_gains_monthly_w=tuple(
_solar_gains_w(windows=windows, region=0, month=m) for m in range(1, 13)
),
region=0,
windows=windows,
control_type=2,
@ -102,6 +106,25 @@ def _baseline_inputs() -> CalculatorInputs:
)
def test_calculator_consumes_solar_gains_monthly_w_field_for_per_month_solar() -> None:
# Arrange — replace the baseline inputs' solar with an explicit known
# 12-tuple. The §6 orchestrator produces this upstream; the calculator
# must just look it up, not recompute from the legacy `windows` field.
# 100 W constant solar everywhere — distinct enough that any leftover
# _solar_gains_w(windows, ...) recomputation would land elsewhere.
explicit_solar = (100.0,) * 12
inputs = replace(
_baseline_inputs(), solar_gains_monthly_w=explicit_solar,
)
# Act
result = calculate_sap_from_inputs(inputs)
# Assert
for monthly in result.monthly:
assert monthly.solar_gains_w == 100.0
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()