§7 slice 3: 6 Elmhurst fixtures conform on (85)..(94) to ≤5e-3

Adds SECTION_7_LIVING_AREA_FRACTION, SECTION_7_CONTROL_TYPE,
SECTION_7_RESPONSIVENESS, SECTION_7_THERMAL_MASS_PARAMETER_KJ_PER_M2_K
plus LINE_85..LINE_94 expected outputs across all 6 _elmhurst_worksheet_*
fixtures, and an ALL_FIXTURES-parametrised end-to-end test.

The test sources its inputs from §1-§6 fixture pins:
  (84) monthly total gains = LINE_73 + LINE_83
  (39) monthly HTC         = LINE_37 + 0.33·V·LINE_25_M
  external temp = Appendix U Table U1 region 0 (UK-avg, SAP rating pass)

Asserts every per-zone line ref to abs=5e-3 °C / unitless:
  (85) T_h1                    × 6 = 6
  (86) η_living monthly        × 12 × 6 = 72
  (87) MIT living monthly      × 12 × 6 = 72
  (88) T_h2 monthly            × 12 × 6 = 72
  (89) η_elsewhere monthly     × 12 × 6 = 72
  (90) MIT elsewhere monthly   × 12 × 6 = 72
  (91) f_LA                    × 6 = 6
  (92) blended MIT monthly     × 12 × 6 = 72
  (93) adjusted MIT monthly    × 12 × 6 = 72
  (94) η_whole monthly         × 12 × 6 = 72
                                 total = 588 GREEN assertions

All 6 fixtures land at default scalars (control_type=2 gas combi w/
programmer+RT, R=1.0 Table 4d gas radiators, TMP=250 SAP mass-medium
default, Table 4e adj=0). Per-fixture f_LA reflects habitable_rooms_count.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-20 21:37:58 +00:00
parent 13c2c6514f
commit ff5d8c70c1
7 changed files with 344 additions and 0 deletions

View file

@ -303,3 +303,46 @@ LINE_84_M_TOTAL_GAINS_W: tuple[float, ...] = (
520.6560, 589.5427, 671.3605, 762.7985, 827.0272, 812.2096,
774.5274, 708.8195, 633.3985, 554.3432, 502.8518, 496.2075,
)
# ============================================================================
# §7 Mean internal temperature — inputs + expected outputs
# ============================================================================
SECTION_7_LIVING_AREA_FRACTION: float = 0.3001
SECTION_7_CONTROL_TYPE: int = 2 # Vaillant combi w/ programmer + room thermostat
SECTION_7_RESPONSIVENESS: float = 1.0 # Table 4d gas radiators
SECTION_7_THERMAL_MASS_PARAMETER_KJ_PER_M2_K: float = 250.0 # SAP10 mass-medium default
LINE_85_T_H1_C: float = 21.0
LINE_86_M_UTILISATION_LIVING: tuple[float, ...] = (
0.9874, 0.9832, 0.9744, 0.9561, 0.9225, 0.8662,
0.7917, 0.8245, 0.9182, 0.9671, 0.9835, 0.9886,
)
LINE_87_M_MIT_LIVING_C: tuple[float, ...] = (
16.3900, 16.6560, 17.2419, 18.0937, 18.9991, 19.8382,
20.3490, 20.2566, 19.5409, 18.4028, 17.2677, 16.3564,
)
LINE_88_M_T_H2_C: tuple[float, ...] = (
18.1067, 18.1088, 18.1110, 18.1212, 18.1232, 18.1326,
18.1326, 18.1344, 18.1289, 18.1232, 18.1192, 18.1151,
)
LINE_89_M_UTILISATION_ELSEWHERE: tuple[float, ...] = (
0.9822, 0.9760, 0.9622, 0.9310, 0.8641, 0.7148,
0.4479, 0.5197, 0.8204, 0.9429, 0.9750, 0.9840,
)
LINE_90_M_MIT_ELSEWHERE_C: tuple[float, ...] = (
14.3924, 14.6573, 15.2393, 16.0813, 16.9542, 17.7106,
18.0579, 18.0230, 17.4819, 16.3974, 15.2716, 14.3614,
)
LINE_91_LIVING_AREA_FRACTION: float = 0.3001
LINE_92_M_MIT_C: tuple[float, ...] = (
14.9918, 15.2570, 15.8402, 16.6851, 17.5678, 18.3490,
18.7453, 18.6932, 18.0997, 16.9991, 15.8705, 14.9600,
)
LINE_93_M_ADJUSTED_MIT_C: tuple[float, ...] = (
14.9918, 15.2570, 15.8402, 16.6851, 17.5678, 18.3490,
18.7453, 18.6932, 18.0997, 16.9991, 15.8705, 14.9600,
)
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,
)

View file

@ -238,3 +238,46 @@ LINE_84_M_TOTAL_GAINS_W: tuple[float, ...] = (
600.8530, 659.6195, 722.9096, 785.1894, 820.7212, 793.7017,
758.9505, 711.4514, 659.7060, 605.1436, 571.7221, 575.1085,
)
# ============================================================================
# §7 Mean internal temperature — inputs + expected outputs
# ============================================================================
SECTION_7_LIVING_AREA_FRACTION: float = 0.2501
SECTION_7_CONTROL_TYPE: int = 2
SECTION_7_RESPONSIVENESS: float = 1.0
SECTION_7_THERMAL_MASS_PARAMETER_KJ_PER_M2_K: float = 250.0
LINE_85_T_H1_C: float = 21.0
LINE_86_M_UTILISATION_LIVING: tuple[float, ...] = (
0.9924, 0.9898, 0.9842, 0.9719, 0.9465, 0.8952,
0.8168, 0.8456, 0.9366, 0.9778, 0.9898, 0.9933,
)
LINE_87_M_MIT_LIVING_C: tuple[float, ...] = (
17.3935, 17.6158, 18.0895, 18.7970, 19.5088, 20.1825,
20.5732, 20.5101, 19.9544, 19.0502, 18.1417, 17.4027,
)
LINE_88_M_T_H2_C: tuple[float, ...] = (
18.6336, 18.6420, 18.6504, 18.6907, 18.6984, 18.7346,
18.7346, 18.7414, 18.7205, 18.6984, 18.6829, 18.6669,
)
LINE_89_M_UTILISATION_ELSEWHERE: tuple[float, ...] = (
0.9892, 0.9855, 0.9768, 0.9561, 0.9071, 0.7849,
0.5587, 0.6187, 0.8665, 0.9620, 0.9846, 0.9905,
)
LINE_90_M_MIT_ELSEWHERE_C: tuple[float, ...] = (
15.6208, 15.8465, 16.3220, 17.0436, 17.7391, 18.3752,
18.6545, 18.6302, 18.1823, 17.3038, 16.3938, 15.6477,
)
LINE_91_LIVING_AREA_FRACTION: float = 0.2501
LINE_92_M_MIT_C: tuple[float, ...] = (
16.0641, 16.2890, 16.7640, 17.4821, 18.1816, 18.8271,
19.1343, 19.1003, 18.6254, 17.7405, 16.8309, 16.0866,
)
LINE_93_M_ADJUSTED_MIT_C: tuple[float, ...] = (
16.0641, 16.2890, 16.7640, 17.4821, 18.1816, 18.8271,
19.1343, 19.1003, 18.6254, 17.7405, 16.8309, 16.0866,
)
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,
)

View file

@ -267,3 +267,46 @@ LINE_84_M_TOTAL_GAINS_W: tuple[float, ...] = (
635.5030, 688.2231, 744.7156, 810.5810, 860.6814, 842.2758,
801.4906, 738.5032, 676.7345, 626.8170, 602.2639, 609.9585,
)
# ============================================================================
# §7 Mean internal temperature — inputs + expected outputs
# ============================================================================
SECTION_7_LIVING_AREA_FRACTION: float = 0.25
SECTION_7_CONTROL_TYPE: int = 2
SECTION_7_RESPONSIVENESS: float = 1.0
SECTION_7_THERMAL_MASS_PARAMETER_KJ_PER_M2_K: float = 250.0
LINE_85_T_H1_C: float = 21.0
LINE_86_M_UTILISATION_LIVING: tuple[float, ...] = (
0.9924, 0.9903, 0.9857, 0.9752, 0.9528, 0.9089,
0.8437, 0.8720, 0.9471, 0.9801, 0.9901, 0.9932,
)
LINE_87_M_MIT_LIVING_C: tuple[float, ...] = (
17.0943, 17.3065, 17.7859, 18.5015, 19.2718, 20.0032,
20.4482, 20.3690, 19.7555, 18.7992, 17.8482, 17.0752,
)
LINE_88_M_T_H2_C: tuple[float, ...] = (
18.4782, 18.4823, 18.4862, 18.5051, 18.5087, 18.5254,
18.5254, 18.5286, 18.5189, 18.5087, 18.5015, 18.4940,
)
LINE_89_M_UTILISATION_ELSEWHERE: tuple[float, ...] = (
0.9892, 0.9861, 0.9788, 0.9606, 0.9157, 0.8005,
0.5722, 0.6398, 0.8821, 0.9652, 0.9849, 0.9904,
)
LINE_90_M_MIT_ELSEWHERE_C: tuple[float, ...] = (
15.2441, 15.4575, 15.9367, 16.6555, 17.4098, 18.0964,
18.4289, 18.3922, 17.8877, 16.9572, 16.0081, 15.2328,
)
LINE_91_LIVING_AREA_FRACTION: float = 0.25
LINE_92_M_MIT_C: tuple[float, ...] = (
15.7066, 15.9197, 16.3990, 17.1169, 17.8753, 18.5730,
18.9336, 18.8863, 18.3546, 17.4176, 16.4681, 15.6933,
)
LINE_93_M_ADJUSTED_MIT_C: tuple[float, ...] = (
15.7066, 15.9197, 16.3990, 17.1169, 17.8753, 18.5730,
18.9336, 18.8863, 18.3546, 17.4176, 16.4681, 15.6933,
)
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,
)

View file

@ -284,3 +284,46 @@ LINE_84_M_TOTAL_GAINS_W: tuple[float, ...] = (
671.3723, 747.4641, 785.3754, 788.7928, 769.4264, 723.2821,
700.9215, 695.1295, 703.8008, 688.5887, 650.7110, 640.2960,
)
# ============================================================================
# §7 Mean internal temperature — inputs + expected outputs
# ============================================================================
SECTION_7_LIVING_AREA_FRACTION: float = 0.30
SECTION_7_CONTROL_TYPE: int = 2
SECTION_7_RESPONSIVENESS: float = 1.0
SECTION_7_THERMAL_MASS_PARAMETER_KJ_PER_M2_K: float = 250.0
LINE_85_T_H1_C: float = 21.0
LINE_86_M_UTILISATION_LIVING: tuple[float, ...] = (
0.9912, 0.9879, 0.9830, 0.9746, 0.9580, 0.9212,
0.8544, 0.8664, 0.9356, 0.9737, 0.9877, 0.9923,
)
LINE_87_M_MIT_LIVING_C: tuple[float, ...] = (
17.4074, 17.6396, 18.0853, 18.7273, 19.3998, 20.0728,
20.4989, 20.4561, 19.9338, 19.0627, 18.1498, 17.3984,
)
LINE_88_M_T_H2_C: tuple[float, ...] = (
18.6209, 18.6273, 18.6336, 18.6635, 18.6692, 18.6959,
18.6959, 18.7009, 18.6855, 18.6692, 18.6577, 18.6458,
)
LINE_89_M_UTILISATION_ELSEWHERE: tuple[float, ...] = (
0.9876, 0.9828, 0.9750, 0.9600, 0.9254, 0.8281,
0.6098, 0.6479, 0.8639, 0.9552, 0.9815, 0.9891,
)
LINE_90_M_MIT_ELSEWHERE_C: tuple[float, ...] = (
15.6277, 15.8618, 16.3085, 16.9611, 17.6235, 18.2696,
18.5948, 18.5734, 18.1415, 17.2982, 16.3877, 15.6320,
)
LINE_91_LIVING_AREA_FRACTION: float = 0.30
LINE_92_M_MIT_C: tuple[float, ...] = (
16.1616, 16.3951, 16.8415, 17.4909, 18.1563, 18.8105,
19.1660, 19.1382, 18.6791, 17.8275, 16.9163, 16.1619,
)
LINE_93_M_ADJUSTED_MIT_C: tuple[float, ...] = (
16.1616, 16.3951, 16.8415, 17.4909, 18.1563, 18.8105,
19.1660, 19.1382, 18.6791, 17.8275, 16.9163, 16.1619,
)
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,
)

View file

@ -279,3 +279,46 @@ LINE_84_M_TOTAL_GAINS_W: tuple[float, ...] = (
595.2863, 661.0571, 716.2901, 763.4028, 790.9684, 764.1081,
732.0878, 691.6074, 654.2542, 610.6983, 573.2833, 568.9492,
)
# ============================================================================
# §7 Mean internal temperature — inputs + expected outputs
# ============================================================================
SECTION_7_LIVING_AREA_FRACTION: float = 0.2501
SECTION_7_CONTROL_TYPE: int = 2
SECTION_7_RESPONSIVENESS: float = 1.0
SECTION_7_THERMAL_MASS_PARAMETER_KJ_PER_M2_K: float = 250.0
LINE_85_T_H1_C: float = 21.0
LINE_86_M_UTILISATION_LIVING: tuple[float, ...] = (
0.9882, 0.9845, 0.9780, 0.9655, 0.9413, 0.8964,
0.8297, 0.8525, 0.9291, 0.9697, 0.9844, 0.9893,
)
LINE_87_M_MIT_LIVING_C: tuple[float, ...] = (
16.6616, 16.9101, 17.4399, 18.2116, 19.0443, 19.8460,
20.3482, 20.2734, 19.6150, 18.5651, 17.5005, 16.6343,
)
LINE_88_M_T_H2_C: tuple[float, ...] = (
18.2175, 18.2207, 18.2237, 18.2384, 18.2412, 18.2545,
18.2545, 18.2570, 18.2493, 18.2412, 18.2356, 18.2297,
)
LINE_89_M_UTILISATION_ELSEWHERE: tuple[float, ...] = (
0.9832, 0.9779, 0.9675, 0.9452, 0.8949, 0.7694,
0.5159, 0.5769, 0.8426, 0.9473, 0.9763, 0.9849,
)
LINE_90_M_MIT_ELSEWHERE_C: tuple[float, ...] = (
14.6991, 14.9471, 15.4749, 16.2438, 17.0556, 17.7996,
18.1642, 18.1307, 17.6086, 16.6001, 15.5427, 14.6766,
)
LINE_91_LIVING_AREA_FRACTION: float = 0.2501
LINE_92_M_MIT_C: tuple[float, ...] = (
15.1899, 15.4380, 15.9663, 16.7359, 17.5529, 18.3114,
18.7103, 18.6666, 18.1104, 17.0915, 16.0323, 15.1662,
)
LINE_93_M_ADJUSTED_MIT_C: tuple[float, ...] = (
15.1899, 15.4380, 15.9663, 16.7359, 17.5529, 18.3114,
18.7103, 18.6666, 18.1104, 17.0915, 16.0323, 15.1662,
)
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,
)

View file

@ -251,3 +251,46 @@ LINE_84_M_TOTAL_GAINS_W: tuple[float, ...] = (
685.6226, 751.9476, 812.4827, 872.0460, 912.1729, 886.0156,
845.2404, 789.1829, 736.7389, 686.5174, 653.4111, 656.0623,
)
# ============================================================================
# §7 Mean internal temperature — inputs + expected outputs
# ============================================================================
SECTION_7_LIVING_AREA_FRACTION: float = 0.30
SECTION_7_CONTROL_TYPE: int = 2
SECTION_7_RESPONSIVENESS: float = 1.0
SECTION_7_THERMAL_MASS_PARAMETER_KJ_PER_M2_K: float = 250.0
LINE_85_T_H1_C: float = 21.0
LINE_86_M_UTILISATION_LIVING: tuple[float, ...] = (
0.9926, 0.9902, 0.9854, 0.9749, 0.9523, 0.9066,
0.8363, 0.8636, 0.9433, 0.9792, 0.9901, 0.9935,
)
LINE_87_M_MIT_LIVING_C: tuple[float, ...] = (
17.3148, 17.5323, 17.9964, 18.6853, 19.4112, 20.1024,
20.5166, 20.4476, 19.8767, 18.9663, 18.0505, 17.3048,
)
LINE_88_M_T_H2_C: tuple[float, ...] = (
18.5969, 18.6026, 18.6082, 18.6349, 18.6399, 18.6637,
18.6637, 18.6682, 18.6545, 18.6399, 18.6297, 18.6192,
)
LINE_89_M_UTILISATION_ELSEWHERE: tuple[float, ...] = (
0.9896, 0.9860, 0.9784, 0.9604, 0.9159, 0.8013,
0.5780, 0.6398, 0.8774, 0.9641, 0.9851, 0.9908,
)
LINE_90_M_MIT_ELSEWHERE_C: tuple[float, ...] = (
15.5233, 15.7428, 16.2076, 16.9043, 17.6151, 18.2667,
18.5728, 18.5420, 18.0749, 17.1898, 16.2749, 15.5250,
)
LINE_91_LIVING_AREA_FRACTION: float = 0.30
LINE_92_M_MIT_C: tuple[float, ...] = (
16.0608, 16.2796, 16.7442, 17.4386, 18.1539, 18.8173,
19.1559, 19.1137, 18.6154, 17.7227, 16.8075, 16.0589,
)
LINE_93_M_ADJUSTED_MIT_C: tuple[float, ...] = (
16.0608, 16.2796, 16.7442, 17.4386, 18.1539, 18.8173,
19.1559, 19.1137, 18.6154, 17.7227, 16.8075, 16.0589,
)
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,
)

View file

@ -10,8 +10,11 @@ Reference: SAP 10.3 (13-01-2026) Table 9 (page 183),
Table 9b (page 184), Table 9c (page 185).
"""
from types import ModuleType
import pytest
from domain.sap.climate.appendix_u import external_temperature_c
from domain.sap.worksheet.mean_internal_temperature import (
MeanInternalTemperatureResult,
elsewhere_heating_temperature_c,
@ -19,6 +22,89 @@ from domain.sap.worksheet.mean_internal_temperature import (
mean_internal_temperature_monthly,
off_period_temperature_reduction_c,
)
from domain.sap.worksheet.tests._elmhurst_fixtures import ALL_FIXTURES, fixture_id
# UK-average climate (region 0) external temperatures, Appendix U Table U1.
_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) coefficient
def _section_7_inputs(fixture: ModuleType) -> dict[str, object]:
"""Build mean_internal_temperature_monthly kwargs from §1§6 fixture
pins. Mirrors the cert_to_inputs flow at slice 5: (84)m total gains
= LINE_73 + LINE_83, (39)m HTC = LINE_37 + 0.33·V·LINE_25_M, ext
temps from Appendix U region 0 (SAP rating pass)."""
return {
"monthly_external_temp_c": _UK_AVG_EXT_TEMP_C,
"monthly_total_gains_w": tuple(
fixture.LINE_73_M_TOTAL_INTERNAL_GAINS_W[m]
+ fixture.LINE_83_M_TOTAL_SOLAR_W[m]
for m in range(12)
),
"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)
),
"thermal_mass_parameter_kj_per_m2_k": fixture.SECTION_7_THERMAL_MASS_PARAMETER_KJ_PER_M2_K,
"total_floor_area_m2": fixture.LINE_4_TFA_M2,
"control_type": fixture.SECTION_7_CONTROL_TYPE,
"responsiveness": fixture.SECTION_7_RESPONSIVENESS,
"living_area_fraction": fixture.SECTION_7_LIVING_AREA_FRACTION,
}
@pytest.mark.parametrize("fixture", ALL_FIXTURES, ids=[fixture_id(f) for f in ALL_FIXTURES])
def test_mean_internal_temperature_monthly_matches_elmhurst_worksheet_all_fixtures(
fixture: ModuleType,
) -> None:
"""End-to-end §7 orchestrator against every Elmhurst conformance fixture.
Each fixture pins SECTION_7_* scalars (f_LA, control_type, R, TMP) and
the LINE_85..LINE_94 expected outputs. Inputs flow from §1-§6 pins via
`_section_7_inputs(fixture)` same shape as cert_to_inputs at slice 5.
Asserts every per-zone line ref to 5e-3 °C / unitless per month.
"""
# Arrange
inputs = _section_7_inputs(fixture)
# Act
result = mean_internal_temperature_monthly(**inputs) # type: ignore[arg-type]
# Assert
assert result.living_area_heating_temp_c == pytest.approx(fixture.LINE_85_T_H1_C, abs=1e-9)
assert result.living_area_fraction == pytest.approx(fixture.LINE_91_LIVING_AREA_FRACTION, abs=1e-9)
for m in range(12):
assert result.utilisation_factor_living_monthly[m] == pytest.approx(
fixture.LINE_86_M_UTILISATION_LIVING[m], abs=5e-3
), f"(86) month {m+1}"
assert result.mean_internal_temp_living_monthly[m] == pytest.approx(
fixture.LINE_87_M_MIT_LIVING_C[m], abs=5e-3
), f"(87) month {m+1}"
assert result.elsewhere_heating_temp_monthly[m] == pytest.approx(
fixture.LINE_88_M_T_H2_C[m], abs=5e-3
), f"(88) month {m+1}"
assert result.utilisation_factor_elsewhere_monthly[m] == pytest.approx(
fixture.LINE_89_M_UTILISATION_ELSEWHERE[m], abs=5e-3
), f"(89) month {m+1}"
assert result.mean_internal_temp_elsewhere_monthly[m] == pytest.approx(
fixture.LINE_90_M_MIT_ELSEWHERE_C[m], abs=5e-3
), f"(90) month {m+1}"
assert result.mean_internal_temp_monthly[m] == pytest.approx(
fixture.LINE_92_M_MIT_C[m], abs=5e-3
), f"(92) month {m+1}"
assert result.adjusted_mean_internal_temp_monthly[m] == pytest.approx(
fixture.LINE_93_M_ADJUSTED_MIT_C[m], abs=5e-3
), f"(93) month {m+1}"
assert result.utilisation_factor_whole_monthly[m] == pytest.approx(
fixture.LINE_94_M_UTILISATION_WHOLE[m], abs=5e-3
), f"(94) month {m+1}"
# Worksheet U985-0001-000490 (UK-avg weather, region 0) inputs for §7.