From ac6dd250a29e00e77de8b803529156c2f743da49 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 23 May 2026 23:57:55 +0000 Subject: [PATCH] =?UTF-8?q?Slice=2027:=20=C2=A78=20space=20heating=20casca?= =?UTF-8?q?de=20pin=20(36/36)=20+=20worksheet=20annual=20rule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/domain/sap/rdsap/cert_to_inputs.py | 50 ++++++++++++- .../src/domain/sap/worksheet/space_heating.py | 15 +++- .../tests/test_section_cascade_pins.py | 75 +++++++++++++++++++ 3 files changed, 137 insertions(+), 3 deletions(-) diff --git a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py index 7b19e8b4..75b4c97d 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -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`. diff --git a/packages/domain/src/domain/sap/worksheet/space_heating.py b/packages/domain/src/domain/sap/worksheet/space_heating.py index 34d9db53..38a31156 100644 --- a/packages/domain/src/domain/sap/worksheet/space_heating.py +++ b/packages/domain/src/domain/sap/worksheet/space_heating.py @@ -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( diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py b/packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py index b950a100..135a8714 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py +++ b/packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py @@ -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}")