§8 slice 2: 6 Elmhurst fixtures conform on (95)..(99)

Adds LINE_95_M_USEFUL_GAINS_W, LINE_97_M_HEAT_LOSS_RATE_W,
LINE_98A_M_SPACE_HEATING_KWH, LINE_98C_M_TOTAL_SPACE_HEATING_KWH,
LINE_98C_ANNUAL_KWH, LINE_99_PER_M2_KWH to each
_elmhurst_worksheet_*.py fixture, plus an ALL_FIXTURES-parametrised
end-to-end test.

Tolerances vary by line ref per §5's per-line precedent:
  - (95) η × G          → 5e-2 W per month
  - (97) H × ΔT         → 5e-2 W per month
  - (98a)/(98c)         → 1e-1 kWh per month
  - ∑(98c) annual       → 1e-1 kWh
  - (99) per-m²         → 5e-3 kWh

Looser than §6/§7's flat 5e-3 W budget because §8 inputs (LINE_93,
LINE_94, LINE_84) carry 4-d.p. display rounding from upstream worksheets,
and §8's 0.024·31·(L−ηG) amplifies that rounding into the per-month kWh
band. The orchestrator computes in full precision; tolerances reflect
the fixture-pin precision floor, not physics error.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-20 22:35:12 +00:00
parent 9113f30aa8
commit 1f078af7db
7 changed files with 198 additions and 0 deletions

View file

@ -346,3 +346,22 @@ LINE_94_M_UTILISATION_WHOLE: tuple[float, ...] = (
0.9720, 0.9633, 0.9455, 0.9098, 0.8457, 0.7330,
0.5633, 0.6168, 0.8187, 0.9256, 0.9627, 0.9745,
)
# ============================================================================
# §8 Space heating requirement — expected outputs
# ============================================================================
LINE_95_M_USEFUL_GAINS_W: tuple[float, ...] = (
506.0790, 567.8843, 634.7414, 693.9770, 699.4312, 595.3705,
436.3012, 437.1801, 518.5588, 513.1261, 484.1045, 483.5787,
)
LINE_97_M_HEAT_LOSS_RATE_W: tuple[float, ...] = (
2956.1218, 2856.8816, 2570.5049, 2119.4574, 1594.2286, 1008.8825,
577.3275, 616.0265, 1080.3274, 1738.5871, 2392.7150, 2948.0890,
)
LINE_98A_M_SPACE_HEATING_KWH: tuple[float, ...] = (
1822.8319, 1538.2062, 1440.2081, 1026.3458, 665.7293, 0.0,
0.0, 0.0, 0.0, 911.7430, 1374.1995, 1833.5957,
)
LINE_98C_M_TOTAL_SPACE_HEATING_KWH: tuple[float, ...] = LINE_98A_M_SPACE_HEATING_KWH
LINE_98C_ANNUAL_KWH: float = 10612.8595
LINE_99_PER_M2_KWH: float = 186.879

View file

@ -281,3 +281,22 @@ LINE_94_M_UTILISATION_WHOLE: tuple[float, ...] = (
0.9833, 0.9781, 0.9667, 0.9426, 0.8931, 0.7917,
0.6257, 0.6735, 0.8617, 0.9507, 0.9774, 0.9852,
)
# ============================================================================
# §8 Space heating requirement — expected outputs
# ============================================================================
LINE_95_M_USEFUL_GAINS_W: tuple[float, ...] = (
590.8300, 645.1621, 698.8688, 740.0958, 732.9898, 628.3945,
474.8614, 479.1612, 568.4900, 575.2972, 558.7756, 566.6199,
)
LINE_97_M_HEAT_LOSS_RATE_W: tuple[float, ...] = (
2959.4740, 2848.9539, 2553.0985, 2078.0015, 1561.3993, 993.9559,
595.9047, 632.0681, 1074.1362, 1720.1111, 2368.4759, 2924.2562,
)
LINE_98A_M_SPACE_HEATING_KWH: tuple[float, ...] = (
1762.2712, 1480.9481, 1379.5469, 963.2921, 616.3366, 0.0,
0.0, 0.0, 0.0, 851.7415, 1302.9842, 1754.0814,
)
LINE_98C_M_TOTAL_SPACE_HEATING_KWH: tuple[float, ...] = LINE_98A_M_SPACE_HEATING_KWH
LINE_98C_ANNUAL_KWH: float = 10111.2020
LINE_99_PER_M2_KWH: float = 130.3326

View file

@ -310,3 +310,22 @@ LINE_94_M_UTILISATION_WHOLE: tuple[float, ...] = (
0.9830, 0.9785, 0.9687, 0.9467, 0.9000, 0.8039,
0.6441, 0.6959, 0.8745, 0.9534, 0.9772, 0.9847,
)
# ============================================================================
# §8 Space heating requirement — expected outputs
# ============================================================================
LINE_95_M_USEFUL_GAINS_W: tuple[float, ...] = (
624.7214, 673.4519, 721.4221, 767.3929, 774.6171, 677.1408,
516.2266, 513.9498, 591.7998, 597.6119, 588.5443, 600.6212,
)
LINE_97_M_HEAT_LOSS_RATE_W: tuple[float, ...] = (
3470.4829, 3343.3744, 2995.0906, 2453.9817, 1839.7089, 1170.0720,
687.2651, 730.6558, 1258.5840, 2031.0834, 2804.7962, 3458.8981,
)
LINE_98A_M_SPACE_HEATING_KWH: tuple[float, ...] = (
2117.2466, 1794.1879, 1691.6094, 1214.3439, 792.4283, 0.0,
0.0, 0.0, 0.0, 1066.5028, 1595.7014, 2126.5580,
)
LINE_98C_M_TOTAL_SPACE_HEATING_KWH: tuple[float, ...] = LINE_98A_M_SPACE_HEATING_KWH
LINE_98C_ANNUAL_KWH: float = 12398.5783
LINE_99_PER_M2_KWH: float = 146.8852

View file

@ -327,3 +327,22 @@ LINE_94_M_UTILISATION_WHOLE: tuple[float, ...] = (
0.9813, 0.9748, 0.9651, 0.9481, 0.9139, 0.8354,
0.6865, 0.7123, 0.8636, 0.9441, 0.9737, 0.9835,
)
# ============================================================================
# §8 Space heating requirement — expected outputs
# ============================================================================
LINE_95_M_USEFUL_GAINS_W: tuple[float, ...] = (
658.8087, 728.6249, 757.9288, 747.8850, 703.1606, 604.2371,
481.2153, 495.1643, 607.8162, 650.0718, 633.5798, 629.6995,
)
LINE_97_M_HEAT_LOSS_RATE_W: tuple[float, ...] = (
3164.2298, 3053.3605, 2735.3722, 2227.2636, 1667.5142, 1068.2279,
651.0154, 692.3750, 1169.8152, 1866.6927, 2554.8108, 3138.0600,
)
LINE_98A_M_SPACE_HEATING_KWH: tuple[float, ...] = (
1864.0333, 1562.2224, 1471.2179, 1065.1526, 717.4791, 0.0,
0.0, 0.0, 0.0, 905.1659, 1383.2864, 1866.2202,
)
LINE_98C_M_TOTAL_SPACE_HEATING_KWH: tuple[float, ...] = LINE_98A_M_SPACE_HEATING_KWH
LINE_98C_ANNUAL_KWH: float = 10834.7778
LINE_99_PER_M2_KWH: float = 132.828

View file

@ -322,3 +322,22 @@ LINE_94_M_UTILISATION_WHOLE: tuple[float, ...] = (
0.9735, 0.9659, 0.9524, 0.9261, 0.8752, 0.7738,
0.6027, 0.6474, 0.8348, 0.9303, 0.9644, 0.9759,
)
# ============================================================================
# §8 Space heating requirement — expected outputs
# ============================================================================
LINE_95_M_USEFUL_GAINS_W: tuple[float, ...] = (
579.5188, 638.5351, 682.1664, 707.0047, 692.2499, 591.2711,
441.2650, 447.7583, 546.1761, 568.1093, 552.8782, 555.2620,
)
LINE_97_M_HEAT_LOSS_RATE_W: tuple[float, ...] = (
3153.9991, 3044.0808, 2727.4470, 2230.2770, 1662.0453, 1042.5972,
592.8375, 635.4462, 1131.3062, 1843.3753, 2548.3231, 3143.7625,
)
LINE_98A_M_SPACE_HEATING_KWH: tuple[float, ...] = (
1915.4133, 1616.5267, 1521.6888, 1096.7561, 721.5278, 0.0,
0.0, 0.0, 0.0, 948.7979, 1436.7203, 1925.8443,
)
LINE_98C_M_TOTAL_SPACE_HEATING_KWH: tuple[float, ...] = LINE_98A_M_SPACE_HEATING_KWH
LINE_98C_ANNUAL_KWH: float = 11183.2752
LINE_99_PER_M2_KWH: float = 169.2897

View file

@ -294,3 +294,22 @@ LINE_94_M_UTILISATION_WHOLE: tuple[float, ...] = (
0.9841, 0.9791, 0.9693, 0.9484, 0.9040, 0.8120,
0.6598, 0.7062, 0.8755, 0.9540, 0.9783, 0.9858,
)
# ============================================================================
# §8 Space heating requirement — expected outputs
# ============================================================================
LINE_95_M_USEFUL_GAINS_W: tuple[float, ...] = (
674.6949, 736.2594, 787.5631, 827.0143, 824.6046, 719.4386,
557.6630, 557.3181, 645.0463, 654.9073, 639.2232, 646.7565,
)
LINE_97_M_HEAT_LOSS_RATE_W: tuple[float, ...] = (
3539.0517, 3411.2290, 3059.3047, 2504.6246, 1886.7214, 1213.3943,
735.3648, 778.4417, 1307.1906, 2082.2459, 2857.3815, 3515.5362,
)
LINE_98A_M_SPACE_HEATING_KWH: tuple[float, ...] = (
2131.0814, 1797.5796, 1690.1757, 1207.8794, 790.2149, 0.0,
0.0, 0.0, 0.0, 1061.9399, 1597.0740, 2134.3721,
)
LINE_98C_M_TOTAL_SPACE_HEATING_KWH: tuple[float, ...] = LINE_98A_M_SPACE_HEATING_KWH
LINE_98C_ANNUAL_KWH: float = 12410.3170
LINE_99_PER_M2_KWH: float = 137.0700

View file

@ -7,13 +7,23 @@ negative or below 1 kWh/month per the Table 9c note.
Reference: SAP 10.3 specification (13-01-2026) Table 9c (page 185).
"""
from types import ModuleType
import pytest
from domain.sap.climate.appendix_u import external_temperature_c
from domain.sap.worksheet.space_heating import (
SpaceHeatingResult,
monthly_heat_requirement_kwh,
space_heating_monthly_kwh,
)
from domain.sap.worksheet.tests._elmhurst_fixtures import ALL_FIXTURES, fixture_id
_UK_AVG_EXT_TEMP_C: tuple[float, ...] = tuple(
external_temperature_c(0, m) for m in range(1, 13)
)
_VENTILATION_HLC_COEFF_W_PER_M3_K: float = 0.33 # SAP10.2 (38)
# Worksheet U985-0001-000490 (UK-avg weather, region 0) inputs for §8.
@ -169,3 +179,77 @@ def test_sub_one_kwh_requirement_clamps_to_zero_per_table_9c_note() -> None:
# Assert
assert result == 0.0
def _section_8_inputs(fixture: ModuleType) -> dict[str, object]:
"""Build space_heating_monthly_kwh kwargs from §1-§7 fixture pins.
Mirrors cert_to_inputs flow at slice 3: (84)m total gains = LINE_84,
(39)m HTC = LINE_37 + 0.33·V·LINE_25_M, T_internal from §7 LINE_93,
η_whole from §7 LINE_94, ext from Appendix U region 0."""
return {
"monthly_heat_transfer_coefficient_w_per_k": tuple(
fixture.LINE_37_TOTAL_FABRIC_HEAT_LOSS_W_PER_K
+ _VENTILATION_HLC_COEFF_W_PER_M3_K
* fixture.LINE_5_VOLUME_M3
* fixture.LINE_25_EFFECTIVE_ACH[m]
for m in range(12)
),
"monthly_internal_temperature_c": fixture.LINE_93_M_ADJUSTED_MIT_C,
"monthly_external_temperature_c": _UK_AVG_EXT_TEMP_C,
"monthly_utilisation_factor": fixture.LINE_94_M_UTILISATION_WHOLE,
"monthly_total_gains_w": fixture.LINE_84_M_TOTAL_GAINS_W,
"total_floor_area_m2": fixture.LINE_4_TFA_M2,
}
@pytest.mark.parametrize("fixture", ALL_FIXTURES, ids=[fixture_id(f) for f in ALL_FIXTURES])
def test_space_heating_monthly_kwh_matches_elmhurst_worksheet_all_fixtures(
fixture: ModuleType,
) -> None:
"""End-to-end §8 orchestrator against every Elmhurst conformance fixture.
Each fixture pins LINE_95_M (useful gains), LINE_97_M (heat loss rate),
LINE_98A_M / LINE_98C_M (space heating req incl. summer clamp),
LINE_98C_ANNUAL_KWH, LINE_99_PER_M2_KWH. Inputs flow from §1-§7 pins
via `_section_8_inputs(fixture)` same shape as cert_to_inputs at
slice 3.
Asserts each per-month line ref. Tolerances vary by line ref because
upstream fixture pins (LINE_93, LINE_94, LINE_84) are 4-d.p. display-
rounded and the products / sums in §8 accumulate that rounding:
- (95) η × G 5e-2 W per month
- (97) H × ΔT 5e-2 W per month
- (98a)/(98c) 1e-1 kWh per month (driven by (97)(95) propagation
through 0.024·31 days amplifier ~×0.7; mixed-glazing
fixtures 000474/000477/000487 compound worst)
- (98c) annual 1e-1 kWh (12-month sum amplifies)
- (99) per- 5e-3 kWh (annual÷TFA brings the digit back)
Same precedent as §5's (68) 5e-2 W tolerance — display-rounding floor,
not physics imprecision (the orchestrator computes in full precision).
"""
# Arrange
inputs = _section_8_inputs(fixture)
# Act
result = space_heating_monthly_kwh(**inputs) # type: ignore[arg-type]
# Assert
assert result.total_space_heating_kwh_per_yr == pytest.approx(
fixture.LINE_98C_ANNUAL_KWH, abs=1e-1
)
assert result.space_heating_per_m2_kwh == pytest.approx(
fixture.LINE_99_PER_M2_KWH, abs=5e-3
)
for m in range(12):
assert result.useful_gains_monthly_w[m] == pytest.approx(
fixture.LINE_95_M_USEFUL_GAINS_W[m], abs=5e-2
), f"(95) month {m+1}"
assert result.heat_loss_rate_monthly_w[m] == pytest.approx(
fixture.LINE_97_M_HEAT_LOSS_RATE_W[m], abs=5e-2
), f"(97) month {m+1}"
assert result.space_heating_requirement_monthly_kwh[m] == pytest.approx(
fixture.LINE_98A_M_SPACE_HEATING_KWH[m], abs=1e-1
), f"(98a) month {m+1}"
assert result.total_space_heating_monthly_kwh[m] == pytest.approx(
fixture.LINE_98C_M_TOTAL_SPACE_HEATING_KWH[m], abs=1e-1
), f"(98c) month {m+1}"