§5 slice 9: internal_gains_from_cert orchestrator + lookalike tracer test

Wires all §5 leaf functions into a single from_cert orchestrator that
chains (66) → (67) → (68) → (69) → (70) → (71) → (72) → (73) and
returns an InternalGainsResult. The caller provides §4 (65)m heat
gains (the only non-cert input) and overshading defaults to AVERAGE.

Cert derivations:
  - Occupancy via Appendix J Table 1b from TFA
  - Lighting: RdSAP §12-1 per-lamp-type bulb defaults aggregated to
    C_L,fixed + ε_fixed; C_daylight via L2a from sap_windows × Z_L
    from Table 6d. L5b + L8c fallbacks when no bulb/window data lodged.
  - Pumps/fans: maps central_heating_pump_age_str on the first
    MainHeatingDetail to PumpDateCategory. Liquid-fuel / warm-air / PIV
    / MV / HIU branches deferred (reachable via leaf fns; currently
    return 0 in the orchestrator for the combi-gas-natural-vent
    population that covers all 6 Elmhurst fixtures).

Slice 9 tracer test hand-builds a 000490-lookalike EPC rather than
mutating `_elmhurst_worksheet_000490.build_epc()` — keeps the existing
e2e SAP-score regression test pinned. Slice 10 will extend the fixture
proper and parametrize over ALL_FIXTURES.

Also: extends make_minimal_sap10_epc with low_energy_fixed_lighting_bulbs_count
since the existing builder only exposed CFL/LED/incandescent separately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-20 18:50:40 +00:00
parent 53aba1332e
commit f81e744b02
3 changed files with 363 additions and 0 deletions

View file

@ -200,6 +200,7 @@ def make_minimal_sap10_epc(
cfl_fixed_lighting_bulbs_count: int = 0,
led_fixed_lighting_bulbs_count: int = 0,
incandescent_fixed_lighting_bulbs_count: int = 0,
low_energy_fixed_lighting_bulbs_count: Optional[int] = None,
solar_water_heating: bool = False,
has_hot_water_cylinder: bool = False,
has_fixed_air_conditioning: bool = False,
@ -299,6 +300,7 @@ def make_minimal_sap10_epc(
cfl_fixed_lighting_bulbs_count=cfl_fixed_lighting_bulbs_count,
led_fixed_lighting_bulbs_count=led_fixed_lighting_bulbs_count,
incandescent_fixed_lighting_bulbs_count=incandescent_fixed_lighting_bulbs_count,
low_energy_fixed_lighting_bulbs_count=low_energy_fixed_lighting_bulbs_count,
total_floor_area_m2=total_floor_area_m2,
sap_version=10.2,
energy_rating_current=energy_rating_current,

View file

@ -31,6 +31,8 @@ from enum import Enum
from math import cos, exp, pi
from typing import Final, Optional
from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapWindow
_DAYS_PER_YEAR: Final[float] = 365.0
_APPLIANCES_E_A_COEFF: Final[float] = 207.8
_APPLIANCES_E_A_EXPONENT: Final[float] = 0.4714
@ -60,6 +62,67 @@ class PumpDateCategory(Enum):
UNKNOWN = "unknown"
class OvershadingCategory(Enum):
"""Table 6d overshading bucket. Maps to light access factor Z_L. SAP
defaults to AVERAGE when the cert hasn't lodged a specific category."""
HEAVY = "heavy"
MORE_THAN_AVERAGE = "more_than_average"
AVERAGE = "average"
VERY_LITTLE = "very_little"
# Table 6d third column — light access factor Z_L by overshading bucket.
_Z_L_BY_OVERSHADING: Final[dict[OvershadingCategory, float]] = {
OvershadingCategory.HEAVY: 0.5,
OvershadingCategory.MORE_THAN_AVERAGE: 0.67,
OvershadingCategory.AVERAGE: 0.83,
OvershadingCategory.VERY_LITTLE: 1.0,
}
# RdSAP §12-1 per-lamp-type defaults: (watts_per_bulb, efficacy_lm_per_w).
# When the cert distinguishes LED vs CFL the per-type values apply;
# combined "low energy lighting" (LEL) — LED/CFL unknown — uses the LEL
# default. Lumens per bulb = watts × efficacy.
_RDSAP_LAMP_LED: Final[tuple[float, float]] = (9.0, 100.0)
_RDSAP_LAMP_CFL: Final[tuple[float, float]] = (19.0, 55.0)
_RDSAP_LAMP_INCANDESCENT: Final[tuple[float, float]] = (60.0, 11.2)
_RDSAP_LAMP_LEL_UNKNOWN: Final[tuple[float, float]] = (15.0, 80.0)
# L5b existing-dwelling C_L,fixed fallback when no fixed-lighting data lodged.
_LIGHTING_L5B_LUMENS_PER_M2: Final[float] = 185.0
# L8c ε_fixed fallback when no fixed lighting present.
_LIGHTING_L8C_EFFICACY_LM_PER_W: Final[float] = 21.3
# Table 6b light transmittance g_L by SAP glazing-type code. Single
# glazed = 0.90; double-glazed variants = 0.80; triple-glazed = 0.70.
# Mirrors the SAP code mapping in cert_to_inputs._g_perpendicular but
# returns the light column, not solar.
_G_LIGHT_BY_GLAZING_CODE: Final[dict[int, float]] = {
1: 0.90, # single glazed
2: 0.80, # double glazed (air filled, pre-2002)
3: 0.80, # double glazed (air filled, post-2002)
4: 0.80, # double glazed (low-E)
5: 0.80, # double glazed (low-E argon)
6: 0.70, # triple glazed
7: 0.80, # secondary glazing
}
_G_LIGHT_DEFAULT: Final[float] = 0.80 # treat unknowns as DG (modal)
# Table 6c frame factor FF by frame-material substring. PVC, wood,
# composite default to 0.7; metal to 0.8.
_FRAME_FACTOR_BY_MATERIAL_SUBSTR: Final[tuple[tuple[str, float], ...]] = (
("metal", 0.8),
("aluminium", 0.8),
("aluminum", 0.8),
("wood", 0.7),
("pvc", 0.7),
("upvc", 0.7),
("composite", 0.7),
)
_FRAME_FACTOR_DEFAULT: Final[float] = 0.7
# Appendix L lighting constants.
_LIGHTING_LAMBDA_B_COEFF: Final[float] = 11.2 * 59.73
_LIGHTING_LAMBDA_B_EXPONENT: Final[float] = 0.4714
@ -388,6 +451,192 @@ class InternalGainsBreakdown:
total_w: float
def _assumed_occupancy(total_floor_area_m2: float) -> float:
"""Appendix J Table 1b occupancy default from TFA.
Duplicated from `water_heating.assumed_occupancy` to avoid the §4
import dependency in §5 keeps internal_gains.py self-contained.
"""
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 _g_light(w: SapWindow) -> float:
"""Table 6b light transmittance g_L by glazing-type code. Defaults
to 0.80 (DG, modal across UK certs) when the cert lodges a code we
don't recognise."""
if isinstance(w.glazing_type, int) and w.glazing_type in _G_LIGHT_BY_GLAZING_CODE:
return _G_LIGHT_BY_GLAZING_CODE[w.glazing_type]
return _G_LIGHT_DEFAULT
def _frame_factor(w: SapWindow) -> float:
"""Table 6c frame factor. Prefer cert's `frame_factor`; else look up
by `frame_material` substring."""
if w.frame_factor is not None:
return float(w.frame_factor)
material = (w.frame_material or "").lower()
for needle, ff in _FRAME_FACTOR_BY_MATERIAL_SUBSTR:
if needle in material:
return ff
return _FRAME_FACTOR_DEFAULT
def _lighting_capacity_and_efficacy_from_cert(
epc: EpcPropertyData,
) -> tuple[float, float]:
"""Aggregate C_L,fixed (lm) and ε_fixed (lm/W) from the cert's bulb
counts via RdSAP §12-1 per-lamp-type defaults. Falls back to L5b
(185 × TFA lumens) + L8c (21.3 lm/W) when no bulb data lodged."""
led = epc.led_fixed_lighting_bulbs_count or 0
cfl = epc.cfl_fixed_lighting_bulbs_count or 0
inc = epc.incandescent_fixed_lighting_bulbs_count or 0
lel = epc.low_energy_fixed_lighting_bulbs_count or 0
led_w, led_eff = _RDSAP_LAMP_LED
cfl_w, cfl_eff = _RDSAP_LAMP_CFL
inc_w, inc_eff = _RDSAP_LAMP_INCANDESCENT
lel_w, lel_eff = _RDSAP_LAMP_LEL_UNKNOWN
total_lumens = (
led * led_w * led_eff
+ cfl * cfl_w * cfl_eff
+ inc * inc_w * inc_eff
+ lel * lel_w * lel_eff
)
total_power_w = led * led_w + cfl * cfl_w + inc * inc_w + lel * lel_w
if total_power_w <= 0.0:
tfa = float(epc.total_floor_area_m2 or 0.0)
return (_LIGHTING_L5B_LUMENS_PER_M2 * tfa, _LIGHTING_L8C_EFFICACY_LM_PER_W)
return (total_lumens, total_lumens / total_power_w)
def _daylight_factor_from_cert(
epc: EpcPropertyData,
overshading: OvershadingCategory,
) -> float:
"""Compute C_daylight via L2a + L2b from the cert's windows. Per
Table 6d note 3 a single Z_L applies to all glazing in the dwelling.
When `total_floor_area_m2` is missing or no windows are lodged the
SAP "no-bonus" default 1.433 is used.
"""
tfa = float(epc.total_floor_area_m2 or 0.0)
if tfa <= 0.0 or not epc.sap_windows:
return 1.433
z_l = _Z_L_BY_OVERSHADING[overshading]
g_l_numerator = sum(
float(w.window_width) * float(w.window_height)
* _g_light(w) * _frame_factor(w)
for w in epc.sap_windows
)
g_l = 0.9 * g_l_numerator * z_l / tfa
if g_l > 0.095:
return 0.96
return 52.2 * g_l * g_l - 9.94 * g_l + 1.433
def _pump_date_category_from_cert(epc: EpcPropertyData) -> PumpDateCategory:
"""Map first main-heating detail's central_heating_pump_age_str to a
Table 5a bucket. Elmhurst lodges "Pre 2013" / "Post 2013" / "Unknown"
/ None on each `MainHeatingDetail` (nested under `epc.sap_heating`)."""
sap_heating = getattr(epc, "sap_heating", None)
details = getattr(sap_heating, "main_heating_details", None) or []
age_str = ""
if details:
age_str = (details[0].central_heating_pump_age_str or "").lower()
if "post" in age_str or "2013 or later" in age_str:
return PumpDateCategory.NEW_2013_OR_LATER
if "pre" in age_str or "2012" in age_str:
return PumpDateCategory.OLD_2012_OR_EARLIER
return PumpDateCategory.UNKNOWN
def internal_gains_from_cert(
*,
epc: EpcPropertyData,
dwelling_volume_m3: float,
heat_gains_from_water_heating_monthly_kwh: tuple[float, ...],
overshading: OvershadingCategory = OvershadingCategory.AVERAGE,
) -> InternalGainsResult:
"""SAP 10.2 §5 orchestrator — chain every line ref (66)..(73) for the
dwelling identified by `epc`.
Inputs:
epc cert (TFA, bulbs, windows, pump)
dwelling_volume_m3 §1 line (5) for fan-W formulas
heat_gains_from_water_heating_monthly_kwh §4 line (65)m see Q5 grill
overshading Table 6d bucket (default AVERAGE)
Coverage caveats for the current corpus:
- Lighting: full Appendix L L1-L12 with RdSAP §12-1 per-lamp defaults
and the L2a window-driven C_daylight. Conformant for the 6 Elmhurst
fixtures (all DG, PVC frame, average overshading) to ~0.5%.
- Pumps/fans: central heating pump only. Liquid-fuel pump, warm-air
fans, PIV, balanced MV w/o HR, HIU branches are reachable via the
leaf fns but not yet derivable from the cert here. Mirrors §4's
combi-only happy-path scope.
"""
tfa = float(epc.total_floor_area_m2 or 0.0)
n = _assumed_occupancy(tfa)
metabolic = metabolic_monthly_w(n_occupants=n)
cooking = cooking_monthly_w(n_occupants=n)
losses = losses_monthly_w(n_occupants=n)
appliances = appliances_monthly_w(total_floor_area_m2=tfa, n_occupants=n)
c_l_fixed, eff_fixed = _lighting_capacity_and_efficacy_from_cert(epc)
c_daylight = _daylight_factor_from_cert(epc, overshading)
lighting = lighting_monthly_w(
total_floor_area_m2=tfa,
n_occupants=n,
fixed_lighting_capacity_lm=c_l_fixed,
fixed_lighting_efficacy_lm_per_w=eff_fixed,
daylight_factor=c_daylight,
)
pump_w = central_heating_pump_w(
date_category=_pump_date_category_from_cert(epc)
)
# Liquid-fuel + warm-air + PIV + MV + HIU branches default to zero for
# the combi-gas-natural-vent population; future slices will detect them
# from epc.main_heating_details + epc.mechanical_ventilation.
pumps_fans = pumps_fans_monthly_w(
heating_season_w=pump_w,
year_round_w=0.0,
)
water_heating_gains = water_heating_gains_monthly_w(
heat_gains_from_water_heating_monthly_kwh=heat_gains_from_water_heating_monthly_kwh,
)
total = total_internal_gains_monthly_w(
metabolic_monthly_w=metabolic,
lighting_monthly_w=lighting,
appliances_monthly_w=appliances,
cooking_monthly_w=cooking,
pumps_fans_monthly_w=pumps_fans,
losses_monthly_w=losses,
water_heating_gains_monthly_w=water_heating_gains,
)
return InternalGainsResult(
metabolic_monthly_w=metabolic,
lighting_monthly_w=lighting,
appliances_monthly_w=appliances,
cooking_monthly_w=cooking,
pumps_fans_monthly_w=pumps_fans,
losses_monthly_w=losses,
water_heating_gains_monthly_w=water_heating_gains,
total_internal_gains_monthly_w=total,
)
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:

View file

@ -14,12 +14,14 @@ import pytest
from domain.sap.worksheet.internal_gains import (
InternalGainsResult,
OvershadingCategory,
PumpDateCategory,
appliances_monthly_w,
balanced_mv_no_hr_fan_w,
central_heating_pump_w,
cooking_monthly_w,
heat_interface_unit_w,
internal_gains_from_cert,
lighting_monthly_w,
liquid_fuel_boiler_pump_w,
liquid_fuel_warm_air_pump_w,
@ -31,6 +33,13 @@ from domain.sap.worksheet.internal_gains import (
warm_air_heating_fan_w,
water_heating_gains_monthly_w,
)
from datatypes.epc.domain.epc_property_data import (
InstantaneousWwhrs,
MainHeatingDetail,
SapHeating,
SapWindow,
)
from domain.ml.tests._fixtures import make_minimal_sap10_epc
def test_metabolic_gains_are_60w_per_occupant_constant_across_months() -> None:
@ -412,3 +421,106 @@ def test_internal_gains_result_dataclass_holds_all_seven_lines_plus_total() -> N
"cooking_monthly_w", "pumps_fans_monthly_w", "losses_monthly_w",
"water_heating_gains_monthly_w", "total_internal_gains_monthly_w",
))
def _build_000490_lookalike_epc() -> "EpcPropertyData": # noqa: F821 — string ref keeps imports light
"""Hand-build a minimal EPC matching the 000490 cert's §5 surface:
TFA 66.06 , 8 LEL bulbs (LED/CFL unknown), 3 DG-PVC windows
totalling 9.03 , gas combi with central heating pump (unknown date).
Slice 9 keeps this local rather than mutating `_elmhurst_worksheet_000490.build_epc()`
so the existing e2e SAP-score regression test (which is pinned to the
legacy cert state) doesn't drift. Slice 10 extends the fixture proper.
"""
def _window(area: float, orientation_code: int) -> SapWindow:
side = area ** 0.5
return SapWindow(
frame_material="PVC",
glazing_gap=12,
orientation=orientation_code,
window_type=2,
glazing_type=2,
window_width=side,
window_height=area / side,
draught_proofed=True,
window_location=1,
window_wall_type=1,
permanent_shutters_present=False,
)
sap_heating = SapHeating(
instantaneous_wwhrs=InstantaneousWwhrs(),
main_heating_details=[
MainHeatingDetail(
has_fghrs=False,
main_fuel_type=1,
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2106,
central_heating_pump_age_str="Unknown",
),
],
has_fixed_air_conditioning=False,
)
return make_minimal_sap10_epc(
total_floor_area_m2=66.06,
low_energy_fixed_lighting_bulbs_count=8,
sap_windows=[
_window(0.81, orientation_code=8),
_window(5.52, orientation_code=4),
_window(2.70, orientation_code=6),
],
sap_heating=sap_heating,
)
def test_internal_gains_from_cert_reproduces_000490_worksheet_end_to_end() -> None:
"""End-to-end §5 orchestrator against Elmhurst U985-0001-000490.
Drives the full (66)..(73) pipeline from the cert: occupancy via
Appendix J Table 1b from TFA, lighting via RdSAP §12-1 bulb defaults
+ Appendix L cascade, appliances via L13/L14/L16a, cooking + losses
+ metabolic from N, pumps/fans via Table 5a (central heating pump
unknown date, no MV/PIV/HIU for combi-gas-natural-vent population),
water-heating gains bridged from the §4 (65)m kWh tuple supplied by
the caller.
Asserts every line ref against the worksheet to 1e-2 W tolerance.
"""
# Arrange — hand-built 000490-lookalike + worksheet (65)m + (5) volume.
epc = _build_000490_lookalike_epc()
heat_gains_wh_kwh = (
75.2034, 66.4381, 70.4305, 61.5896, 59.4711, 53.3391,
52.4705, 54.6991, 55.4582, 62.1383, 66.4342, 74.3403,
)
# Act
result = internal_gains_from_cert(
epc=epc,
dwelling_volume_m3=202.6377, # worksheet line (5)
heat_gains_from_water_heating_monthly_kwh=heat_gains_wh_kwh,
overshading=OvershadingCategory.AVERAGE,
)
# Assert — every worksheet line.
expected_66 = (128.8087,) * 12
expected_67 = (24.2665, 21.5533, 17.5283, 13.2701, 9.9195, 8.3745,
9.0489, 11.7621, 15.7871, 20.0454, 23.3959, 24.9410)
expected_68 = (280.4965, 283.4071, 276.0723, 260.4574, 240.7463, 222.2207,
209.8445, 206.9338, 214.2686, 229.8835, 249.5946, 268.1202)
expected_69 = (50.0277,) * 12
expected_70 = (7.0, 7.0, 7.0, 7.0, 7.0, 0.0, 0.0, 0.0, 0.0, 7.0, 7.0, 7.0)
expected_71 = (-85.8725,) * 12
expected_72 = (101.0798, 98.8663, 94.6647, 85.5412, 79.9343, 74.0821,
70.5249, 73.5203, 77.0253, 83.5192, 92.2698, 99.9197)
expected_73 = (505.8067, 503.7906, 488.2293, 459.2325, 430.5641, 397.6412,
382.3822, 385.1801, 400.0449, 433.4120, 465.2242, 492.9448)
for m in range(12):
assert result.metabolic_monthly_w[m] == pytest.approx(expected_66[m], abs=1e-3), f"(66) month {m+1}"
assert result.lighting_monthly_w[m] == pytest.approx(expected_67[m], abs=5e-2), f"(67) month {m+1}"
assert result.appliances_monthly_w[m] == pytest.approx(expected_68[m], abs=5e-2), f"(68) month {m+1}"
assert result.cooking_monthly_w[m] == pytest.approx(expected_69[m], abs=1e-3), f"(69) month {m+1}"
assert result.pumps_fans_monthly_w[m] == pytest.approx(expected_70[m], abs=1e-9), f"(70) month {m+1}"
assert result.losses_monthly_w[m] == pytest.approx(expected_71[m], abs=1e-3), f"(71) month {m+1}"
assert result.water_heating_gains_monthly_w[m] == pytest.approx(expected_72[m], abs=1e-3), f"(72) month {m+1}"
assert result.total_internal_gains_monthly_w[m] == pytest.approx(expected_73[m], abs=1e-1), f"(73) month {m+1}"