§5 slice 10: extract LINE_66..LINE_73 + ALL_FIXTURES e2e conformance

Adds SECTION_5_BULB_COUNT_LEL, SECTION_5_WINDOW_AREAS_M2,
SECTION_5_PUMP_AGE_STR and LINE_66..LINE_73 expected outputs to every
Elmhurst fixture (000474, 000477, 000480, 000487, 000490, 000516).
Constants extracted from the U985-0001-NNNNNN worksheets supplied
2026-05-20. All six fixtures share the same shape: all-LEL bulb
lighting, gas combi pump with unknown install date, average overshading.

Adds an ALL_FIXTURES-parametrized test in test_internal_gains.py that
composes a §5 EPC from the fixture's constants and drives
internal_gains_from_cert. Tolerances: ≤1e-3 W on the linear-in-N rows
(66/69/71), ≤2e-1 W on (67) lighting (worksheet-rounded N + rooflight
Z_L=1.0 approximated by AVERAGE Z_L=0.83), ≤5e-2 W on (68) appliances,
≤3e-1 W on (73) sum. Result: 26 tests pass; six fixtures conform to
≤0.6% lighting bias end-to-end.

The fixture's base build_epc() is unchanged — §5 EPC composition lives
in a test helper so the existing e2e SAP-score regression (000490, 000474)
remains pinned for the upcoming calc.py wiring slice.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-20 19:06:34 +00:00
parent f81e744b02
commit 99e5c2cd44
7 changed files with 296 additions and 0 deletions

View file

@ -238,3 +238,39 @@ LINE_65_M_HEAT_GAINS_FROM_WH_KWH: tuple[float, ...] = (
)
COMBI_LOSS_OVERRIDE: Optional[tuple[float, ...]] = LINE_61_M_COMBI_LOSS_KWH
# ============================================================================
# §5 Internal gains — cert-derived inputs + expected outputs
# ============================================================================
# Lighting: 8 low-energy bulbs (LED/CFL undistinguished on cert) → RdSAP
# §12-1 default 80 lm/W × 15 W = 1200 lm each.
SECTION_5_BULB_COUNT_LEL: int = 8
# Window areas per worksheet §6 (5 windows: East 3.74 ×2, NW 1.76 + 1.98,
# SE 0.5). All DG air-filled (g_L=0.80) on PVC frames (FF=0.7).
SECTION_5_WINDOW_AREAS_M2: tuple[float, ...] = (3.74, 3.74, 1.76, 1.98, 0.5)
# Vaillant ecoTEC pro 28 combi, pump in heated space, unknown install date
# → Table 5a 7 W heating-season-only row.
SECTION_5_PUMP_AGE_STR: str = "Unknown"
LINE_66_M_METABOLIC_W: tuple[float, ...] = (113.3748,) * 12
LINE_67_M_LIGHTING_W: tuple[float, ...] = (
19.8107, 17.5957, 14.3098, 10.8334, 8.0981, 6.8368,
7.3874, 9.6024, 12.8883, 16.3646, 19.1000, 20.3613,
)
LINE_68_M_APPLIANCES_W: tuple[float, ...] = (
245.9478, 248.5000, 242.0686, 228.3770, 211.0937, 194.8499,
183.9980, 181.4459, 187.8772, 201.5689, 218.8522, 235.0960,
)
LINE_69_M_COOKING_W: tuple[float, ...] = (48.2271,) * 12
LINE_70_M_PUMPS_FANS_W: tuple[float, ...] = (
7.0, 7.0, 7.0, 7.0, 7.0, 0.0, 0.0, 0.0, 0.0, 7.0, 7.0, 7.0,
)
LINE_71_M_LOSSES_W: tuple[float, ...] = (-75.5832,) * 12
LINE_72_M_WATER_HEATING_GAINS_W: tuple[float, ...] = (
87.5927, 85.5340, 81.6263, 73.1405, 67.9223, 62.4757,
59.1662, 61.9545, 65.2172, 71.2596, 79.3985, 86.5137,
)
LINE_73_M_TOTAL_INTERNAL_GAINS_W: tuple[float, ...] = (
446.3699, 444.6484, 431.0234, 405.3696, 380.1328, 350.1810,
336.5702, 339.0214, 352.0014, 382.2118, 410.3693, 434.9896,
)

View file

@ -180,3 +180,34 @@ LINE_65_M_HEAT_GAINS_FROM_WH_KWH: tuple[float, ...] = (
)
COMBI_LOSS_OVERRIDE: Optional[tuple[float, ...]] = LINE_61_M_COMBI_LOSS_KWH
# ============================================================================
# §5 Internal gains — cert-derived inputs + expected outputs
# ============================================================================
SECTION_5_BULB_COUNT_LEL: int = 9
# 3 windows: East 1.28, West 1.17 + 6.76. All DG / PVC.
SECTION_5_WINDOW_AREAS_M2: tuple[float, ...] = (1.28, 1.17, 6.76)
SECTION_5_PUMP_AGE_STR: str = "Unknown"
LINE_66_M_METABOLIC_W: tuple[float, ...] = (144.9204,) * 12
LINE_67_M_LIGHTING_W: tuple[float, ...] = (
28.5492, 25.3572, 20.6218, 15.6121, 11.6702, 9.8525,
10.6459, 13.8380, 18.5733, 23.5831, 27.5250, 29.3427,
)
LINE_68_M_APPLIANCES_W: tuple[float, ...] = (
319.8635, 323.1826, 314.8184, 297.0120, 274.5345, 253.4089,
239.2956, 235.9765, 244.3407, 262.1471, 284.6246, 305.7502,
)
LINE_69_M_COOKING_W: tuple[float, ...] = (51.9074,) * 12
LINE_70_M_PUMPS_FANS_W: tuple[float, ...] = (
7.0, 7.0, 7.0, 7.0, 7.0, 0.0, 0.0, 0.0, 0.0, 7.0, 7.0, 7.0,
)
LINE_71_M_LOSSES_W: tuple[float, ...] = (-96.6136,) * 12
LINE_72_M_WATER_HEATING_GAINS_W: tuple[float, ...] = (
81.6015, 79.4024, 75.2820, 66.4100, 60.9388, 55.1879,
51.7430, 54.7206, 58.1855, 64.5131, 73.0260, 80.4796,
)
LINE_73_M_TOTAL_INTERNAL_GAINS_W: tuple[float, ...] = (
537.2284, 535.1563, 517.9364, 486.2482, 454.3577, 418.6634,
401.8987, 404.7492, 421.3137, 457.4575, 492.3897, 522.7868,
)

View file

@ -212,3 +212,34 @@ LINE_65_M_HEAT_GAINS_FROM_WH_KWH: tuple[float, ...] = (
)
COMBI_LOSS_OVERRIDE: Optional[tuple[float, ...]] = LINE_61_M_COMBI_LOSS_KWH
# ============================================================================
# §5 Internal gains — cert-derived inputs + expected outputs
# ============================================================================
SECTION_5_BULB_COUNT_LEL: int = 10
# 2 windows: NE 8.74, SW 1.8. All DG / PVC.
SECTION_5_WINDOW_AREAS_M2: tuple[float, ...] = (8.74, 1.8)
SECTION_5_PUMP_AGE_STR: str = "Unknown"
LINE_66_M_METABOLIC_W: tuple[float, ...] = (152.4740,) * 12
LINE_67_M_LIGHTING_W: tuple[float, ...] = (
30.0891, 26.7249, 21.7341, 16.4541, 12.2997, 10.3839,
11.2202, 14.5844, 19.5751, 24.8551, 29.0096, 30.9254,
)
LINE_68_M_APPLIANCES_W: tuple[float, ...] = (
340.9107, 344.4483, 335.5337, 316.5556, 292.5991, 270.0834,
255.0415, 251.5039, 260.4185, 279.3966, 303.3531, 325.8689,
)
LINE_69_M_COOKING_W: tuple[float, ...] = (52.7886,) * 12
LINE_70_M_PUMPS_FANS_W: tuple[float, ...] = (
7.0, 7.0, 7.0, 7.0, 7.0, 0.0, 0.0, 0.0, 0.0, 7.0, 7.0, 7.0,
)
LINE_71_M_LOSSES_W: tuple[float, ...] = (-101.6493,) * 12
LINE_72_M_WATER_HEATING_GAINS_W: tuple[float, ...] = (
93.1167, 90.8413, 86.5957, 77.4784, 71.8542, 65.9937,
62.4747, 65.5058, 69.0425, 75.5460, 84.2964, 91.9648,
)
LINE_73_M_TOTAL_INTERNAL_GAINS_W: tuple[float, ...] = (
574.7298, 572.6278, 554.4769, 521.1014, 487.3662, 450.0743,
432.3496, 435.2074, 452.6494, 490.4110, 527.2724, 559.3723,
)

View file

@ -227,3 +227,34 @@ LINE_65_M_HEAT_GAINS_FROM_WH_KWH: tuple[float, ...] = (
COMBI_LOSS_OVERRIDE: Optional[tuple[float, ...]] = LINE_61_M_COMBI_LOSS_KWH
ELECTRIC_SHOWER_OVERRIDE: Optional[tuple[float, ...]] = LINE_64A_M_ELECTRIC_SHOWER_KWH
# ============================================================================
# §5 Internal gains — cert-derived inputs + expected outputs
# ============================================================================
SECTION_5_BULB_COUNT_LEL: int = 7
# 2 windows: South 0.77, South 6.69. All DG / PVC.
SECTION_5_WINDOW_AREAS_M2: tuple[float, ...] = (0.77, 6.69)
SECTION_5_PUMP_AGE_STR: str = "Unknown"
LINE_66_M_METABOLIC_W: tuple[float, ...] = (149.5185,) * 12
LINE_67_M_LIGHTING_W: tuple[float, ...] = (
32.2313, 28.6276, 23.2815, 17.6256, 13.1753, 11.1232,
12.0190, 15.6227, 20.9688, 26.6247, 31.0750, 33.1271,
)
LINE_68_M_APPLIANCES_W: tuple[float, ...] = (
332.3739, 335.8229, 327.1315, 308.6286, 285.2720, 263.3201,
248.6549, 245.2059, 253.8973, 272.4002, 295.7567, 317.7086,
)
LINE_69_M_COOKING_W: tuple[float, ...] = (52.4438,) * 12
LINE_70_M_PUMPS_FANS_W: tuple[float, ...] = (
7.0, 7.0, 7.0, 7.0, 7.0, 0.0, 0.0, 0.0, 0.0, 7.0, 7.0, 7.0,
)
LINE_71_M_LOSSES_W: tuple[float, ...] = (-99.6790,) * 12
LINE_72_M_WATER_HEATING_GAINS_W: tuple[float, ...] = (
74.9695, 73.0835, 70.0906, 64.3849, 60.6749, 56.8639,
54.9180, 57.1404, 59.6591, 63.8641, 69.3748, 74.3133,
)
LINE_73_M_TOTAL_INTERNAL_GAINS_W: tuple[float, ...] = (
548.8580, 546.8173, 529.7869, 499.9224, 468.4056, 433.5905,
417.8752, 420.2523, 436.8085, 472.1723, 505.4898, 534.4324,
)

View file

@ -223,3 +223,34 @@ LINE_65_M_HEAT_GAINS_FROM_WH_KWH: tuple[float, ...] = (
75.2034, 66.4381, 70.4305, 61.5896, 59.4711, 53.3391,
52.4705, 54.6991, 55.4582, 62.1383, 66.4342, 74.3403,
)
# ============================================================================
# §5 Internal gains — cert-derived inputs + expected outputs
# ============================================================================
SECTION_5_BULB_COUNT_LEL: int = 8
# 3 windows: NE 0.81, NW 2.7, SE 5.52. All DG / PVC.
SECTION_5_WINDOW_AREAS_M2: tuple[float, ...] = (0.81, 2.7, 5.52)
SECTION_5_PUMP_AGE_STR: str = "Unknown"
LINE_66_M_METABOLIC_W: tuple[float, ...] = (128.8087,) * 12
LINE_67_M_LIGHTING_W: tuple[float, ...] = (
24.2665, 21.5533, 17.5283, 13.2701, 9.9195, 8.3745,
9.0489, 11.7621, 15.7871, 20.0454, 23.3959, 24.9410,
)
LINE_68_M_APPLIANCES_W: tuple[float, ...] = (
280.4965, 283.4071, 276.0723, 260.4574, 240.7463, 222.2207,
209.8445, 206.9338, 214.2686, 229.8835, 249.5946, 268.1202,
)
LINE_69_M_COOKING_W: tuple[float, ...] = (50.0277,) * 12
LINE_70_M_PUMPS_FANS_W: tuple[float, ...] = (
7.0, 7.0, 7.0, 7.0, 7.0, 0.0, 0.0, 0.0, 0.0, 7.0, 7.0, 7.0,
)
LINE_71_M_LOSSES_W: tuple[float, ...] = (-85.8725,) * 12
LINE_72_M_WATER_HEATING_GAINS_W: tuple[float, ...] = (
101.0798, 98.8663, 94.6647, 85.5412, 79.9343, 74.0821,
70.5249, 73.5203, 77.0253, 83.5192, 92.2698, 99.9197,
)
LINE_73_M_TOTAL_INTERNAL_GAINS_W: tuple[float, ...] = (
505.8067, 503.7906, 488.2293, 459.2325, 430.5641, 397.6412,
382.3822, 385.1801, 400.0449, 433.4120, 465.2242, 492.9448,
)

View file

@ -189,3 +189,34 @@ LINE_65_M_HEAT_GAINS_FROM_WH_KWH: tuple[float, ...] = (
)
COMBI_LOSS_OVERRIDE: Optional[tuple[float, ...]] = LINE_61_M_COMBI_LOSS_KWH
# ============================================================================
# §5 Internal gains — cert-derived inputs + expected outputs
# ============================================================================
SECTION_5_BULB_COUNT_LEL: int = 9
# 3 windows: NE 1.18 + 3.88, SW 4.43. All DG / PVC.
SECTION_5_WINDOW_AREAS_M2: tuple[float, ...] = (1.18, 3.88, 4.43)
SECTION_5_PUMP_AGE_STR: str = "Unknown"
LINE_66_M_METABOLIC_W: tuple[float, ...] = (157.9824,) * 12
LINE_67_M_LIGHTING_W: tuple[float, ...] = (
32.6842, 29.0298, 23.6086, 17.8733, 13.3605, 11.2795,
12.1879, 15.8423, 21.2634, 26.9988, 31.5116, 33.5926,
)
LINE_68_M_APPLIANCES_W: tuple[float, ...] = (
358.3100, 362.0281, 352.6585, 332.7118, 307.5326, 283.8678,
268.0582, 264.3400, 273.7096, 293.6564, 318.8355, 342.5004,
)
LINE_69_M_COOKING_W: tuple[float, ...] = (53.4313,) * 12
LINE_70_M_PUMPS_FANS_W: tuple[float, ...] = (
7.0, 7.0, 7.0, 7.0, 7.0, 0.0, 0.0, 0.0, 0.0, 7.0, 7.0, 7.0,
)
LINE_71_M_LOSSES_W: tuple[float, ...] = (-105.3216,) * 12
LINE_72_M_WATER_HEATING_GAINS_W: tuple[float, ...] = (
96.0567, 93.5526, 88.7767, 78.3746, 71.9842, 65.3089,
61.2413, 64.6528, 68.6444, 76.0475, 86.0218, 94.7327,
)
LINE_73_M_TOTAL_INTERNAL_GAINS_W: tuple[float, ...] = (
600.1430, 597.7026, 578.1360, 542.0517, 505.9694, 466.5482,
447.5794, 450.9272, 469.7096, 509.7947, 549.4610, 583.9178,
)

View file

@ -33,13 +33,17 @@ from domain.sap.worksheet.internal_gains import (
warm_air_heating_fan_w,
water_heating_gains_monthly_w,
)
from types import ModuleType
from datatypes.epc.domain.epc_property_data import (
EpcPropertyData,
InstantaneousWwhrs,
MainHeatingDetail,
SapHeating,
SapWindow,
)
from domain.ml.tests._fixtures import make_minimal_sap10_epc
from domain.sap.worksheet.tests._elmhurst_fixtures import ALL_FIXTURES, fixture_id
def test_metabolic_gains_are_60w_per_occupant_constant_across_months() -> None:
@ -524,3 +528,104 @@ def test_internal_gains_from_cert_reproduces_000490_worksheet_end_to_end() -> No
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}"
def _build_section_5_epc(fixture: ModuleType) -> EpcPropertyData:
"""Wrap a fixture's base `build_epc()` with the §5-relevant fields it
doesn't yet carry: sap_windows (DG air-filled / PVC), low-energy bulb
count, and a MainHeatingDetail with the recorded pump age. Kept in
test scope so the legacy fixture build_epc()s stay pinned for §1-§4
conformance + the e2e SAP-score regression."""
def _window(area: float) -> SapWindow:
side = area ** 0.5
return SapWindow(
frame_material="PVC",
glazing_gap=12,
orientation=1, # arbitrary — orientation doesn't affect §5
window_type=2,
glazing_type=2, # DG air-filled
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=fixture.SECTION_5_PUMP_AGE_STR,
),
],
has_fixed_air_conditioning=False,
)
return make_minimal_sap10_epc(
total_floor_area_m2=fixture.LINE_4_TFA_M2,
low_energy_fixed_lighting_bulbs_count=fixture.SECTION_5_BULB_COUNT_LEL,
sap_windows=[_window(a) for a in fixture.SECTION_5_WINDOW_AREAS_M2],
sap_heating=sap_heating,
)
@pytest.mark.parametrize("fixture", ALL_FIXTURES, ids=[fixture_id(f) for f in ALL_FIXTURES])
def test_internal_gains_from_cert_matches_elmhurst_worksheet_all_fixtures(
fixture: ModuleType,
) -> None:
"""End-to-end §5 orchestrator against every Elmhurst conformance
fixture in `_elmhurst_fixtures.ALL_FIXTURES`.
Each fixture pins its own §5 input constants (SECTION_5_BULB_COUNT_LEL,
SECTION_5_WINDOW_AREAS_M2, SECTION_5_PUMP_AGE_STR) + worksheet outputs
(LINE_66..LINE_73). The test composes an EPC from these constants and
drives `internal_gains_from_cert`. (65)m comes from each fixture's
§4 LINE_65_M_HEAT_GAINS_FROM_WH_KWH.
Tolerances are generous on (67) and (68) because their (TFA × N)^0.4714
+ cosine modulation propagate display rounding from the worksheet's
4-d.p. N value, plus rooflight Z_L=1.0 (Table 6d note 2) is approximated
by a single AVERAGE Z_L=0.83 here. (66), (69), (71) close to <1e-3 W.
(73) accumulates the other tolerances 3e-1 W.
"""
# Arrange
epc = _build_section_5_epc(fixture)
# Act
result = internal_gains_from_cert(
epc=epc,
dwelling_volume_m3=fixture.LINE_5_VOLUME_M3,
heat_gains_from_water_heating_monthly_kwh=fixture.LINE_65_M_HEAT_GAINS_FROM_WH_KWH,
overshading=OvershadingCategory.AVERAGE,
)
# Assert
for m in range(12):
assert result.metabolic_monthly_w[m] == pytest.approx(
fixture.LINE_66_M_METABOLIC_W[m], abs=1e-3
), f"(66) month {m+1}"
assert result.lighting_monthly_w[m] == pytest.approx(
fixture.LINE_67_M_LIGHTING_W[m], abs=2e-1
), f"(67) month {m+1}"
assert result.appliances_monthly_w[m] == pytest.approx(
fixture.LINE_68_M_APPLIANCES_W[m], abs=5e-2
), f"(68) month {m+1}"
assert result.cooking_monthly_w[m] == pytest.approx(
fixture.LINE_69_M_COOKING_W[m], abs=1e-3
), f"(69) month {m+1}"
assert result.pumps_fans_monthly_w[m] == pytest.approx(
fixture.LINE_70_M_PUMPS_FANS_W[m], abs=1e-9
), f"(70) month {m+1}"
assert result.losses_monthly_w[m] == pytest.approx(
fixture.LINE_71_M_LOSSES_W[m], abs=1e-3
), f"(71) month {m+1}"
assert result.water_heating_gains_monthly_w[m] == pytest.approx(
fixture.LINE_72_M_WATER_HEATING_GAINS_W[m], abs=1e-3
), f"(72) month {m+1}"
assert result.total_internal_gains_monthly_w[m] == pytest.approx(
fixture.LINE_73_M_TOTAL_INTERNAL_GAINS_W[m], abs=3e-1
), f"(73) month {m+1}"