mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
cd94da4d2e
commit
ac6dd250a2
3 changed files with 137 additions and 3 deletions
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue