Slice 26c: §7 mean internal temperature cascade pin (44/60 PASS)

Added `mean_internal_temperature_section_from_cert` composing §1 (dim)
+ §2 (effective_monthly_ach) + §3 (total HLC) + §5 (internal gains)
+ §6 (solar gains) + climate (external temp) and threading them through
the §7 orchestrator — exact mirror of the cert_to_inputs internal
cascade.

Added 60 strict pin cases for §7 worksheet lines (85)..(94): T_h1
scalar, living_area_fraction scalar, η_living + T_living + T_h2 +
η_elsewhere + T_elsewhere + T_92 + T_93 + η_whole monthly tuples.

§7 per-fixture monthly pin status:
  fixture | passing
  000474  | 6 of 8  (LINE_92/93 ~0.0001 K residual)
  000477  | 6 of 8  (LINE_92/93 ~0.0002 K residual)
  000480  | 6 of 8  (LINE_92/93 ~0.0001 K residual)
  000487  | 0 of 8  (cascade from §3 RR + §4 HW defects)
  000490  | 6 of 8  (LINE_92/93 ~0.0001 K residual)
  000516  | 8 of 8  ✓

LINE_92/93 marginal fails on 4 fixtures: weighted-sum of T_living +
T_elsewhere drifts by ~1e-4 K from PDF despite the per-zone temps
matching at 1e-4 individually. Likely a PDF intermediate-precision
artefact (analogous to U_eff at 5 dp in §3 windows); investigation
deferred — no widening per project policy.

Scoreboard:
  section_cascade_pins: 230 → 274 PASS (+44; 60 new tests, 16 fail)
  e2e SapResult:         32 →  32 PASS (unchanged — §7 cascade was
    already running internally, pin tests just surface the line refs)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-23 21:50:12 +00:00
parent 1e9654ce28
commit 6e6bba7e67
3 changed files with 138 additions and 7 deletions

View file

@ -133,12 +133,14 @@ Two test files contain the strict pins:
Total: **169 PASS / 83 FAIL** across the strict pins. 4 of 6 fixtures fully
close §1+§2+§4. 000487 is the worst (RR fixture defect propagates everywhere).
(Post-slice-26b: section_cascade_pins 230 PASS / 22 FAIL, e2e SapResult
32 PASS / 40 FAIL. §3 + §5 + §6 fully close for 5 of 6 fixtures at
abs=1e-4. Remaining cascade failures: §4 monthly (000477/487 HW defects,
slice 25), §5 LINE_72/73 + §6 LINE_84 on 000477/487 (cascaded from §4),
§3 (000487 RR defect, slice 25), and downstream SapResult pins still
drifting because of §7§9a precision not yet pinned.)
(Post-slice-26c: section_cascade_pins 274 PASS / 38 FAIL, e2e SapResult
32 PASS / 40 FAIL. §3 + §5 + §6 + §7 (mostly) pinned. §7 LINE_85..91
+ LINE_87/88/89/90 close at abs=1e-4 for all 5 non-487 fixtures.
LINE_92/93 marginal residuals (~0.0001 K, just over threshold) on
000474/477/480/490 — investigation needed (possible PDF intermediate
rounding precision artefact). 000487 fully cascades from §3/§4 defects
(slice 25). e2e SapResult unchanged because cert_to_inputs was already
running the §7 calc internally — pin tests just surface it now.)
### B.2 SapResult pin matrix (post-slice-22/23)
@ -199,6 +201,7 @@ fixture | section §4 pin status
### B.5 Recent slices (in reverse order — newest first)
```
Slice 26c: §7 mean internal temp cascade pin (60 cases, 44 PASS) — LINE_85..94
Slice 26b: §6 solar gains cascade pin (12 cases, 10 PASS) + SapRoofWindow solar attrs + plumb to §6 cascade
Slice 26: §5 internal gains cascade pin (54 cases, 50 PASS / 4 FAIL) + rooflight plumb to daylight factor
Slice 27b: §3 element-area + door-area rounding to 2 d.p. per RdSAP10 §15 (p.66)
@ -268,7 +271,7 @@ The cascade pin work continues in worksheet order. For each section:
Sections still to pin:
- ~~**§5 internal gains** (lines 66-73 + 232 lighting kWh)~~ DONE (slice 26)
- ~~**§6 solar gains** (lines 83-84)~~ DONE (slice 26b — 5/6 fixtures close, 000477/487 cascade from §4)
- **§7 mean internal temperature** (lines 85-94). 10 line refs, mostly monthly.
- ~~**§7 mean internal temperature** (lines 85-94)~~ MOSTLY DONE (slice 26c — 44/60 PASS; LINE_92/93 marginal ~0.0001 K residual on 000474/477/480/490 needs investigation; 000487 cascades from §3/§4 defects).
- **§8 space heating** (lines 95-99). 4 monthly + 2 annual.
- **§9a energy requirements** (lines 201, 206-208, 211-215, 219). 5 scalar + 2
monthly. Currently only the annual aggregates show on `SapResult` — may need

View file

@ -88,6 +88,7 @@ from domain.sap.worksheet.heat_transmission import (
)
from domain.sap.climate.appendix_u import external_temperature_c
from domain.sap.worksheet.mean_internal_temperature import (
MeanInternalTemperatureResult,
mean_internal_temperature_monthly,
)
from domain.sap.worksheet.energy_requirements import (
@ -827,6 +828,54 @@ def _roof_windows_for_solar_gains(
)
def mean_internal_temperature_section_from_cert(
epc: EpcPropertyData,
) -> Optional[MeanInternalTemperatureResult]:
"""SAP 10.2 §7 cert→inputs cascade for `mean_internal_temperature_monthly`.
Composes §1 (dim) + §2 (effective_monthly_ach) + §3 (total HLC) + §5
(internal gains) + §6 (solar gains) + climate (external temp) and
threads them through the §7 orchestrator exactly as cert_to_inputs
computes internally. Returns the full
`MeanInternalTemperatureResult` (every (85)..(94) line ref) so
cascade pin tests can assert each §7 line ref against the U985 PDF.
Returns `None` when TFA is missing (matches other section helpers).
"""
if epc.total_floor_area_m2 is None:
return None
dim = dimensions_from_cert(epc)
ventilation = ventilation_from_cert(epc)
ht = heat_transmission_section_from_cert(epc)
ig = internal_gains_section_from_cert(epc)
sg = solar_gains_section_from_cert(epc)
assert ig is not None, "internal_gains None despite TFA present"
internal_gains_monthly_w = ig.total_internal_gains_monthly_w
solar_gains_monthly_w = sg.total_solar_gains_monthly_w
monthly_total_gains_w = tuple(
internal_gains_monthly_w[m] + solar_gains_monthly_w[m] for m in range(12)
)
monthly_htc_w_per_k = tuple(
ht.total_w_per_k + 0.33 * dim.volume_m3 * ventilation.effective_monthly_ach[m]
for m in range(12)
)
main = _first_main_heating(epc)
region = _region_index(epc.region_code)
return mean_internal_temperature_monthly(
monthly_external_temp_c=tuple(
external_temperature_c(region, m) for m in range(1, 13)
),
monthly_total_gains_w=monthly_total_gains_w,
monthly_heat_transfer_coefficient_w_per_k=monthly_htc_w_per_k,
thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K,
total_floor_area_m2=dim.total_floor_area_m2,
control_type=_control_type(main),
responsiveness=_responsiveness(main),
living_area_fraction=_living_area_fraction(epc.habitable_rooms_count),
control_temperature_adjustment_c=0.0,
)
def solar_gains_section_from_cert(epc: EpcPropertyData) -> SolarGainsResult:
"""SAP 10.2 §6 cert→inputs cascade for `solar_gains_from_cert`.

View file

@ -20,6 +20,7 @@ from domain.sap.rdsap.cert_to_inputs import (
cert_to_inputs,
heat_transmission_section_from_cert,
internal_gains_section_from_cert,
mean_internal_temperature_section_from_cert,
solar_gains_section_from_cert,
ventilation_from_cert,
water_heating_section_from_cert,
@ -439,3 +440,81 @@ def test_section_6_line_84_total_gains_match_pdf(
# Assert
for m in range(12):
_pin(actual[m], expected[m], f"§6 (84)[{m+1}] {fixture_name}")
# ============================================================================
# §7 Mean internal temperature — LINE_85..LINE_94 scalar + monthly tuples
# ============================================================================
_SECTION_7_SCALAR_PINS: Final[tuple[tuple[str, str], ...]] = (
("LINE_85_T_H1_C", "living_area_heating_temp_c"),
("LINE_91_LIVING_AREA_FRACTION", "living_area_fraction"),
)
_SECTION_7_MONTHLY_PINS: Final[tuple[tuple[str, str], ...]] = (
("LINE_86_M_UTILISATION_LIVING", "utilisation_factor_living_monthly"),
("LINE_87_M_MIT_LIVING_C", "mean_internal_temp_living_monthly"),
("LINE_88_M_T_H2_C", "elsewhere_heating_temp_monthly"),
("LINE_89_M_UTILISATION_ELSEWHERE", "utilisation_factor_elsewhere_monthly"),
("LINE_90_M_MIT_ELSEWHERE_C", "mean_internal_temp_elsewhere_monthly"),
("LINE_92_M_MIT_C", "mean_internal_temp_monthly"),
("LINE_93_M_ADJUSTED_MIT_C", "adjusted_mean_internal_temp_monthly"),
("LINE_94_M_UTILISATION_WHOLE", "utilisation_factor_whole_monthly"),
)
@pytest.mark.parametrize(
"fixture_name,fixture_attr,result_attr",
[
(fix, line, attr)
for fix in _FIXTURES
for line, attr in _SECTION_7_SCALAR_PINS
],
ids=lambda x: x if isinstance(x, str) else None,
)
def test_section_7_scalar_line_refs_match_pdf(
fixture_name: str, fixture_attr: str, result_attr: str
) -> None:
"""§7 scalar pins — (85) T_h1 living-area heating temp + (91) living
area fraction."""
# Arrange
mod = _FIXTURES[fixture_name]
epc = mod.build_epc() # type: ignore[attr-defined]
expected = getattr(mod, fixture_attr)
# Act
mit = mean_internal_temperature_section_from_cert(epc)
assert mit is not None, f"{fixture_name}: mit_from_cert returned None"
actual = getattr(mit, result_attr)
# Assert
_pin(actual, expected, f"§7 {fixture_attr} {fixture_name}")
@pytest.mark.parametrize(
"fixture_name,fixture_attr,result_attr",
[
(fix, line, attr)
for fix in _FIXTURES
for line, attr in _SECTION_7_MONTHLY_PINS
],
ids=lambda x: x if isinstance(x, str) else None,
)
def test_section_7_monthly_line_refs_match_pdf(
fixture_name: str, fixture_attr: str, result_attr: str
) -> None:
"""§7 monthly pins — every Jan..Dec value of (86)..(94) MIT + η lines
matches the U985 PDF to abs=1e-4."""
# Arrange
mod = _FIXTURES[fixture_name]
epc = mod.build_epc() # type: ignore[attr-defined]
expected = getattr(mod, fixture_attr)
# Act
mit = mean_internal_temperature_section_from_cert(epc)
assert mit is not None, f"{fixture_name}: mit_from_cert returned None"
actual = getattr(mit, result_attr)
# Assert
for m in range(12):
_pin(actual[m], expected[m], f"§7 {fixture_attr}[{m+1}] {fixture_name}")