§5 slice 11: wire calculator.py to internal_gains_from_cert + drop legacy

Removes the legacy SAP-10.3-flavoured scalar internal_gains_w API (plus
its InternalGainsBreakdown dataclass, _default_occupancy_sap_j, and the
L5b/L8c fallback constants used only by the legacy path). Calculator
now indexes a CalculatorInputs.internal_gains_monthly_w 12-tuple per
month instead of recomputing inline.

cert_to_inputs:
  - _hot_water_fuel_kwh_per_yr now also returns the §4 (65)m
    heat_gains_monthly_kwh tuple (was discarded). Plumbed forward into
    internal_gains_from_cert via water_heating_gains bridge.
  - Calls §5 orchestrator with EpcPropertyData + dwelling_volume_m3 +
    (65)m + AVERAGE overshading (Table 6d default per note 1).
  - Falls back to (0.0,) * 12 internal gains when TFA missing.

CalculatorInputs gains a new required field `internal_gains_monthly_w`.
Synthetic-input tests (test_calculator, test_bre_worked_examples)
updated to pass a 450 W constant tuple.

All 283 §1-§7 tests pass. E2e SAP-score regression unaffected for
000490 (still within 1 point) and 000474 (still within 7) because the
legacy fixture build_epc()s don't carry §5-specific sap_windows /
bulbs / heating-details, so the orchestrator returns the L5b lighting
fallback + zero (65)m — matches the legacy scalar's behaviour.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-20 19:14:33 +00:00
parent 99e5c2cd44
commit bf6a7e04b3
5 changed files with 56 additions and 86 deletions

View file

@ -41,7 +41,6 @@ if TYPE_CHECKING:
from datatypes.epc.domain.epc_property_data import EpcPropertyData
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 (
ECF_LOG_THRESHOLD,
@ -101,6 +100,11 @@ class CalculatorInputs:
# and MV mode (§2 lines 24a-d). Constant-monthly inputs work too:
# pass `(ach,) * 12` to model a single rate across all months.
monthly_infiltration_ach: tuple[float, ...]
# SAP10.2 (73)m — total internal gains W per month (Jan..Dec).
# Per-month because lighting/appliances cosine-modulate and pumps/fans
# 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, ...]
region: int
windows: tuple[WindowInput, ...]
control_type: int
@ -213,10 +217,7 @@ def _solve_month(
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_int = inputs.internal_gains_monthly_w[month - 1]
g_sol = _solar_gains_w(windows=inputs.windows, region=inputs.region, month=month)
g_total = g_int + g_sol

View file

@ -59,6 +59,10 @@ from domain.sap.tables.table_12 import (
unit_price_p_per_kwh,
)
from domain.sap.worksheet.dimensions import dimensions_from_cert
from domain.sap.worksheet.internal_gains import (
OvershadingCategory,
internal_gains_from_cert,
)
from domain.sap.worksheet.heat_transmission import (
DwellingExposure,
heat_transmission_from_cert,
@ -137,6 +141,12 @@ _FRAME_FACTOR_DEFAULT: Final[float] = 0.70
_PENCE_TO_GBP: Final[float] = 0.01
_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K: Final[float] = 250.0
_DEFAULT_PUMPS_FANS_KWH_PER_YR: Final[float] = 130.0
# SAP10.2 Table 6d note 1: "average or unknown" overshading is the
# default for existing dwellings. RdSAP doesn't lodge a per-dwelling
# overshading code so §5 always uses AVERAGE → Z_L = 0.83.
_INTERNAL_GAINS_DEFAULT_OVERSHADING: Final[OvershadingCategory] = (
OvershadingCategory.AVERAGE
)
# Water-heating codes for instantaneous (no-cylinder) systems — SAP §4
@ -785,7 +795,7 @@ def _hot_water_fuel_kwh_per_yr(
water_efficiency_pct: float,
is_instantaneous: bool,
primary_age: Optional[str],
) -> float:
) -> tuple[float, tuple[float, ...]]:
"""Annual hot water FUEL kWh (the slot calculator.CalculatorInputs
expects). Wires the SAP10.2 §4 worksheet orchestrator into the cert
inputs adapter.
@ -799,9 +809,15 @@ def _hot_water_fuel_kwh_per_yr(
to convert delivered heat to fuel kWh, mirroring the worksheet's
(219) line. Falls back to legacy `predicted_hot_water_kwh` if the
TFA is missing (the orchestrator requires it for occupancy).
Returns a 2-tuple `(fuel_kwh_per_yr, heat_gains_monthly_kwh)`. The
heat-gains tuple is the §4 (65)m output, plumbed onward into the
§5 internal-gains orchestrator's `water_heating_gains_monthly_w`
bridge. Falls back to a 12-zero tuple when the legacy HW path is used.
"""
zero_monthly = (0.0,) * 12
if epc.total_floor_area_m2 is None:
return predicted_hot_water_kwh(
legacy_kwh = predicted_hot_water_kwh(
total_floor_area_m2=epc.total_floor_area_m2,
seasonal_efficiency_water=water_efficiency_pct,
cylinder_size=None if is_instantaneous else _int_or_none(epc.sap_heating.cylinder_size),
@ -816,6 +832,7 @@ def _hot_water_fuel_kwh_per_yr(
has_wwhrs=False,
has_solar_water_heating=epc.solar_water_heating,
)
return legacy_kwh, zero_monthly
result = water_heating_from_cert(
epc=epc,
mixer_shower_flow_rates_l_per_min=_mixer_shower_flow_rates_from_cert(epc),
@ -827,11 +844,11 @@ def _hot_water_fuel_kwh_per_yr(
low_water_use=False,
)
if water_efficiency_pct <= 0:
return 0.0
return 0.0, result.heat_gains_monthly_kwh
# `water_efficiency_pct` is misnamed in the calling code — the value
# is a decimal (0.01.0), not a percent. Divide the orchestrator's
# delivered-heat output by the decimal efficiency to land fuel kWh.
return result.output_kwh_per_yr / water_efficiency_pct
return result.output_kwh_per_yr / water_efficiency_pct, result.heat_gains_monthly_kwh
def cert_to_inputs(
@ -941,7 +958,7 @@ def cert_to_inputs(
# = q_generated, matching the per-kWh-generated unit price.
water_eff = 1.0 / _heat_network_dlf(primary_age)
is_instantaneous = epc.sap_heating.water_heating_code in _INSTANTANEOUS_WATER_CODES
hw_kwh = _hot_water_fuel_kwh_per_yr(
hw_kwh, hw_heat_gains_monthly_kwh = _hot_water_fuel_kwh_per_yr(
epc=epc,
water_efficiency_pct=water_eff,
is_instantaneous=is_instantaneous,
@ -953,6 +970,24 @@ def cert_to_inputs(
led_count=epc.led_fixed_lighting_bulbs_count,
incandescent_count=epc.incandescent_fixed_lighting_bulbs_count,
)
# SAP10.2 §5: chain (66)..(73) internal-gain components via the §5
# orchestrator. The orchestrator needs the §4 (65)m heat-gains tuple,
# which we just plumbed out of `water_heating_from_cert` above.
# Falls back to a 12-zero tuple when TFA is missing — matches the
# legacy `internal_gains_w` zero-floor behaviour. Overshading default
# is AVERAGE per Table 6d note 1 (existing dwellings).
if epc.total_floor_area_m2 is None:
internal_gains_monthly_w = (0.0,) * 12
else:
internal_gains_result = internal_gains_from_cert(
epc=epc,
dwelling_volume_m3=dim.volume_m3,
heat_gains_from_water_heating_monthly_kwh=hw_heat_gains_monthly_kwh,
overshading=_INTERNAL_GAINS_DEFAULT_OVERSHADING,
)
internal_gains_monthly_w = (
internal_gains_result.total_internal_gains_monthly_w
)
return CalculatorInputs(
dimensions=dim,
@ -960,6 +995,9 @@ def cert_to_inputs(
# SAP10.2 line (25)m — 12-month effective air-change rate from the
# full §2 worksheet (openings, shelter, wind adjustment, MV mode).
monthly_infiltration_ach=ventilation.effective_monthly_ach,
# SAP10.2 line (73)m — total internal gains W/month from §5
# orchestrator (composed above).
internal_gains_monthly_w=internal_gains_monthly_w,
region=_region_index(epc.region_code),
windows=_window_inputs(epc.sap_windows),
control_type=_control_type(main),

View file

@ -84,6 +84,7 @@ def _baseline_dwelling() -> CalculatorInputs:
dimensions=dim,
heat_transmission=ht,
monthly_infiltration_ach=(0.7,) * 12,
internal_gains_monthly_w=(450.0,) * 12,
region=0,
windows=windows,
control_type=2,

View file

@ -80,6 +80,10 @@ def _baseline_inputs() -> CalculatorInputs:
dimensions=dim,
heat_transmission=ht,
monthly_infiltration_ach=(0.7,) * 12,
# 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,
region=0,
windows=windows,
control_type=2,

View file

@ -15,13 +15,10 @@ Column A (typical gains) is used for the SAP rating + cooling calc per
Table 5 footnote 3; Column B (reduced gains) is only for new-build
DPER/TPER/DER/TER. We rate existing dwellings always Column A.
The `internal_gains_w` scalar API below is the legacy SAP-10.3 stub; it
remains so `calculator.py` keeps building until §5 wiring (slice 11)
lands. Tests + worksheet conformance target the new 12-tuple functions.
Reference: SAP 10.2 specification (14-03-2025), §5 (page 25), Table 5 +
Table 5a (page 177), Appendix L (lighting/appliances/cooking),
Appendix J Table 1b (occupancy from TFA).
Appendix J Table 1b (occupancy from TFA), Table 6d (Z_L light access
factor for L2a daylighting calc).
"""
from __future__ import annotations
@ -428,29 +425,6 @@ def water_heating_gains_monthly_w(
)
_METABOLIC_W_PER_OCCUPANT: Final[float] = 60.0
_DAYS_IN_MONTH: Final[tuple[int, ...]] = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
# Appendix L existing-dwelling lighting fallback constants.
_FIXED_LIGHTING_EFFICACY_LM_PER_W: Final[float] = 21.3
_FIXED_LIGHTING_LUMENS_PER_M2: Final[float] = 185.0
_REFERENCE_LIGHTING_LUMENS_PER_M2: Final[float] = 330.0
_DAYLIGHT_FACTOR_NO_BONUS: Final[float] = 1.433
_LIGHTING_INTERNAL_FRACTION: Final[float] = 0.85
@dataclass(frozen=True)
class InternalGainsBreakdown:
"""SAP 10.3 §5 internal-gain components in watts for a given month."""
metabolic_w: float
cooking_w: float
appliances_w: float
lighting_w: float
total_w: float
def _assumed_occupancy(total_floor_area_m2: float) -> float:
"""Appendix J Table 1b occupancy default from TFA.
@ -637,51 +611,3 @@ def internal_gains_from_cert(
)
def _default_occupancy_sap_j(total_floor_area_m2: float) -> float:
"""SAP 10.3 Appendix J Table 1b occupancy default from TFA."""
if total_floor_area_m2 <= 13.9:
return 1.0
tfa_offset = total_floor_area_m2 - 13.9
return 1.0 + 1.76 * (1 - exp(-0.000349 * tfa_offset * tfa_offset)) + 0.0013 * tfa_offset
def internal_gains_w(
*,
total_floor_area_m2: float,
month: int,
occupancy: Optional[float] = None,
) -> InternalGainsBreakdown:
"""SAP 10.3 §5 internal gains in watts for a given month."""
n = occupancy if occupancy is not None else _default_occupancy_sap_j(total_floor_area_m2)
if not 1 <= month <= 12:
raise ValueError(f"month must be 1..12, got {month}")
n_m = _DAYS_IN_MONTH[month - 1]
metabolic = _METABOLIC_W_PER_OCCUPANT * n
cooking = 35.0 + 7.0 * n
# Appendix L (L13) + (L14) + (L16a): appliances energy by month,
# converted to a watt heat-gain (100% of appliance energy stays internal).
e_a_annual = 207.8 * (total_floor_area_m2 * n) ** 0.4714
appliances_month_factor = 1.0 + 0.157 * cos(2.0 * pi * (month - 1.78) / 12.0)
e_a_m_kwh = e_a_annual * appliances_month_factor * n_m / 365.0
appliances = e_a_m_kwh * 1000.0 / (24.0 * n_m)
# Appendix L lighting — existing-dwelling fallback path (L5b, L8c, L9c-d, L10, L12).
lambda_b = 11.2 * 59.73 * (total_floor_area_m2 * n) ** 0.4714
c_daylight = _DAYLIGHT_FACTOR_NO_BONUS
lambda_req = (2.0 / 3.0) * lambda_b * c_daylight
c_l_fixed = _FIXED_LIGHTING_LUMENS_PER_M2 * total_floor_area_m2
c_l_ref = _REFERENCE_LIGHTING_LUMENS_PER_M2 * total_floor_area_m2
lambda_prov = lambda_req * c_l_fixed / c_l_ref if c_l_ref > 0 else 0.0
e_l_fixed = (lambda_prov if lambda_req >= lambda_prov else lambda_req) / _FIXED_LIGHTING_EFFICACY_LM_PER_W
e_l_topup = max(0.0, lambda_req / 3.0 - lambda_prov) / _FIXED_LIGHTING_EFFICACY_LM_PER_W
e_l_portable = (1.0 / 3.0) * lambda_b * c_daylight / _FIXED_LIGHTING_EFFICACY_LM_PER_W
e_l_annual = e_l_fixed + e_l_topup + e_l_portable
lighting_month_factor = 1.0 + 0.5 * cos(2.0 * pi * (month - 0.2) / 12.0)
e_l_m_kwh = e_l_annual * lighting_month_factor * n_m / 365.0
lighting = e_l_m_kwh * _LIGHTING_INTERNAL_FRACTION * 1000.0 / (24.0 * n_m)
return InternalGainsBreakdown(
metabolic_w=metabolic,
cooking_w=cooking,
appliances_w=appliances,
lighting_w=lighting,
total_w=metabolic + cooking + appliances + lighting,
)