Slice 27: §8 space heating cascade pin (36/36) + worksheet annual rule

Adds `space_heating_section_from_cert(epc)` to the cert→inputs cascade
mirroring `mean_internal_temperature_section_from_cert`. Composes §1
(dim) + §2 (ventilation) + §3 (HLC) + §5+§6 (gains) + §7 (MIT + η_whole)
+ climate and threads through `space_heating_monthly_kwh`.

Pins (95)/(97)/(98a)/(98c) monthly + (98c) annual + (99) per-m² against
the U985 PDF at abs=1e-4 for all 6 fixtures — 36/36 PASS.

Worksheet annual rule: the U985 PDF lodges (98a)_m / (98c)_m at 4 d.p.
half-up and reports the annual as the Σ of those displayed monthlies. The
full-precision Σ diverges from the lodged annual by up to ~1.4e-4
(accumulated 4-d.p. display rounding over 8 heating months) — e.g. 000490
= -0.000132. Empirically, `sum(round_half_up(monthly, 4))` reproduces the
lodged annual EXACTLY for all 6 fixtures (residual = 0 by construction).
The full-precision residuals are randomly distributed in ±1.4e-4 with no
bias — 5/6 cancel below 1e-4 by luck, 000490 lost the lottery.

SAP10.2 Table 9c step 10 (p.184) defines (98a)_m without an explicit
annual aggregation rounding rule; matching the worksheet display
convention is the only consistent interpretation that satisfies the
abs=1e-4 pin bar. The 1.2e-8 relative shift on downstream calcs is
negligible.

Cascade scoreboard: 312/312 → 348/348 (§7 60/60 + §8 36/36 now closed).
e2e SapResult: 56/66 unchanged (downstream §10a/§11a/§12 + 000487
defects await later slices).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-23 23:57:55 +00:00
parent cd94da4d2e
commit ac6dd250a2
3 changed files with 137 additions and 3 deletions

View file

@ -101,7 +101,10 @@ from domain.sap.worksheet.fabric_energy_efficiency import (
fabric_energy_efficiency_kwh_per_m2_yr,
)
from domain.sap.worksheet.space_cooling import space_cooling_monthly_kwh
from domain.sap.worksheet.space_heating import space_heating_monthly_kwh
from domain.sap.worksheet.space_heating import (
SpaceHeatingResult,
space_heating_monthly_kwh,
)
from domain.sap.worksheet.ventilation import (
MechanicalVentilationKind,
VentilationResult,
@ -905,6 +908,51 @@ def mean_internal_temperature_section_from_cert(
)
def space_heating_section_from_cert(
epc: EpcPropertyData,
) -> Optional[SpaceHeatingResult]:
"""SAP 10.2 §8 cert→inputs cascade for `space_heating_monthly_kwh`.
Composes §1 (dim) + §2 (ventilation) + §3 (HLC) + §5+§6 (gains) +
§7 (MIT + η_whole) + climate (external temp) and threads them through
the §8 orchestrator. Returns the full `SpaceHeatingResult` (every
(95)..(99) line ref) so cascade pin tests can assert each §8 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)
mit = mean_internal_temperature_section_from_cert(epc)
assert ig is not None, "internal_gains None despite TFA present"
assert mit is not None, "mit None despite TFA present"
monthly_total_gains_w = tuple(
ig.total_internal_gains_monthly_w[m] + sg.total_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)
)
region = _region_index(epc.region_code)
monthly_external_temp_c = tuple(
external_temperature_c(region, m) for m in range(1, 13)
)
return space_heating_monthly_kwh(
monthly_heat_transfer_coefficient_w_per_k=monthly_htc_w_per_k,
monthly_internal_temperature_c=mit.adjusted_mean_internal_temp_monthly,
monthly_external_temperature_c=monthly_external_temp_c,
monthly_utilisation_factor=mit.utilisation_factor_whole_monthly,
monthly_total_gains_w=monthly_total_gains_w,
total_floor_area_m2=dim.total_floor_area_m2,
)
def solar_gains_section_from_cert(epc: EpcPropertyData) -> SolarGainsResult:
"""SAP 10.2 §6 cert→inputs cascade for `solar_gains_from_cert`.

View file

@ -18,10 +18,13 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Final
from domain.sap.worksheet.heat_transmission import _round_half_up
_MIN_KWH_PER_MONTH: Final[float] = 1.0
_WH_TO_KWH_PER_DAY: Final[float] = 0.024 # 24 h / 1000
_DAYS_IN_MONTH: Final[tuple[int, ...]] = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
_WORKSHEET_DISPLAY_DP: Final[int] = 4
# SAP10.2 Table 9c step 10: "Include the heating requirement for each month
# from October to May (disregarding June to September)." Set Q_heat to zero
# in Jun..Sep regardless of computed value. Indices 5..8 inclusive (zero-based).
@ -124,8 +127,16 @@ def space_heating_monthly_kwh(
q_solar_98b.append(0.0)
q_total_98c.append(q98a)
annual_98a = sum(q_heat_98a)
annual_98c = sum(q_total_98c)
# U985 worksheet lodges (98a)_m / (98c)_m at 4 d.p. half-up and reports
# the annual as the Σ of those displayed monthlies. The full-precision Σ
# diverges from the lodged annual by up to ~1.4e-4 (accumulated 4-d.p.
# rounding over 8 heating months) — e.g. 000490 = 0.000132. Rounding
# each monthly to 4 d.p. before summing reproduces the lodged annual
# exactly for all 6 fixtures. SAP10.2 Table 9c step 10 (p.184) defines
# (98a)_m without an explicit annual rounding rule; this matches the
# worksheet display convention.
annual_98a = sum(_round_half_up(q, _WORKSHEET_DISPLAY_DP) for q in q_heat_98a)
annual_98c = sum(_round_half_up(q, _WORKSHEET_DISPLAY_DP) for q in q_total_98c)
per_m2_99 = annual_98c / total_floor_area_m2 if total_floor_area_m2 > 0 else 0.0
return SpaceHeatingResult(

View file

@ -22,6 +22,7 @@ from domain.sap.rdsap.cert_to_inputs import (
internal_gains_section_from_cert,
mean_internal_temperature_section_from_cert,
solar_gains_section_from_cert,
space_heating_section_from_cert,
ventilation_from_cert,
water_heating_section_from_cert,
)
@ -518,3 +519,77 @@ def test_section_7_monthly_line_refs_match_pdf(
# Assert
for m in range(12):
_pin(actual[m], expected[m], f"§7 {fixture_attr}[{m+1}] {fixture_name}")
# ============================================================================
# §8 Space heating requirement — LINE_95..LINE_99
# ============================================================================
_SECTION_8_MONTHLY_PINS: Final[tuple[tuple[str, str], ...]] = (
("LINE_95_M_USEFUL_GAINS_W", "useful_gains_monthly_w"),
("LINE_97_M_HEAT_LOSS_RATE_W", "heat_loss_rate_monthly_w"),
("LINE_98A_M_SPACE_HEATING_KWH", "space_heating_requirement_monthly_kwh"),
("LINE_98C_M_TOTAL_SPACE_HEATING_KWH", "total_space_heating_monthly_kwh"),
)
_SECTION_8_SCALAR_PINS: Final[tuple[tuple[str, str], ...]] = (
("LINE_98C_ANNUAL_KWH", "total_space_heating_kwh_per_yr"),
("LINE_99_PER_M2_KWH", "space_heating_per_m2_kwh"),
)
@pytest.mark.parametrize(
"fixture_name,fixture_attr,result_attr",
[
(fix, line, attr)
for fix in _FIXTURES
for line, attr in _SECTION_8_MONTHLY_PINS
],
ids=lambda x: x if isinstance(x, str) else None,
)
def test_section_8_monthly_line_refs_match_pdf(
fixture_name: str, fixture_attr: str, result_attr: str
) -> None:
"""§8 monthly pins — every Jan..Dec value of (95)/(97)/(98a)/(98c)
space-heating 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
sh = space_heating_section_from_cert(epc)
assert sh is not None, f"{fixture_name}: space_heating_from_cert returned None"
actual = getattr(sh, result_attr)
# Assert
for m in range(12):
_pin(actual[m], expected[m], f"§8 {fixture_attr}[{m+1}] {fixture_name}")
@pytest.mark.parametrize(
"fixture_name,fixture_attr,result_attr",
[
(fix, line, attr)
for fix in _FIXTURES
for line, attr in _SECTION_8_SCALAR_PINS
],
ids=lambda x: x if isinstance(x, str) else None,
)
def test_section_8_scalar_line_refs_match_pdf(
fixture_name: str, fixture_attr: str, result_attr: str
) -> None:
"""§8 scalar pins — (98c) annual Σ + (99) per-m² total match 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
sh = space_heating_section_from_cert(epc)
assert sh is not None, f"{fixture_name}: space_heating_from_cert returned None"
actual = getattr(sh, result_attr)
# Assert
_pin(actual, expected, f"§8 {fixture_attr} {fixture_name}")