Slice 28: §8c + §8f cascade pins (48/48)

Adds `space_cooling_section_from_cert(epc)` and
`fabric_energy_efficiency_from_cert(epc)` to the cert→inputs cascade.

§8c (lines 100..108) — all 6 Elmhurst fixtures have
`has_fixed_air_conditioning=False` so f_C=0 collapses (107)/(108) to
zero, (101) η_loss=1 for every month (γ=0 branch), (103) gains=0, and
(106) intermittency follows the spec Jun-Aug mask 0.25. (100), (102),
(104) depend on H × (24 − T_e) per fixture and are not asserted in the
cascade (covered by `test_space_cooling.py` synthetic-positive case).
42/42 §8c pins PASS.

§8f (line 109) — Fabric Energy Efficiency = (98a)/(4) + (108). For all
6 fixtures (98b) solar space heating = 0 and (108) = 0, so (109) = (99)
exactly. 6/6 §8f pins PASS.

Cascade scoreboard: 348/348 → 396/396 (§7..§8f closed).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-24 00:01:30 +00:00
parent ac6dd250a2
commit 13719e010a
2 changed files with 167 additions and 1 deletions

View file

@ -100,7 +100,10 @@ from domain.sap.worksheet.energy_requirements import (
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_cooling import (
SpaceCoolingResult,
space_cooling_monthly_kwh,
)
from domain.sap.worksheet.space_heating import (
SpaceHeatingResult,
space_heating_monthly_kwh,
@ -953,6 +956,63 @@ def space_heating_section_from_cert(
)
def space_cooling_section_from_cert(
epc: EpcPropertyData,
) -> Optional[SpaceCoolingResult]:
"""SAP 10.2 §8c cert→inputs cascade for `space_cooling_monthly_kwh`.
Composes §1 (dim) + §2 (ventilation) + §3 (HLC) + climate; cooling
gains and cooled-area fraction default to 0 (RdSAP convention the
cert never lodges cooled-area data, and for `has_fixed_air_conditioning
=False` certs the f_C=0 zeros (107) regardless of gains). Returns the
full `SpaceCoolingResult` (every (100)..(108) line ref) so cascade pin
tests can assert each §8c 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)
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_cooling_monthly_kwh(
monthly_heat_transfer_coefficient_w_per_k=monthly_htc_w_per_k,
monthly_external_temperature_c=monthly_external_temp_c,
monthly_total_gains_w=(0.0,) * 12,
total_floor_area_m2=dim.total_floor_area_m2,
thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K,
cooled_area_fraction=0.0,
intermittency_factor=0.25,
)
def fabric_energy_efficiency_from_cert(epc: EpcPropertyData) -> Optional[float]:
"""SAP 10.2 §8f cert→inputs cascade for `fabric_energy_efficiency_kwh_
per_m2_yr` line (109) = (98a)/(4) + (108). Composes §8 (space heating)
+ §8c (space cooling) + §1 (TFA). Returns None when TFA missing.
"""
if epc.total_floor_area_m2 is None:
return None
dim = dimensions_from_cert(epc)
sh = space_heating_section_from_cert(epc)
sc = space_cooling_section_from_cert(epc)
assert sh is not None, "space_heating None despite TFA present"
assert sc is not None, "space_cooling None despite TFA present"
return fabric_energy_efficiency_kwh_per_m2_yr(
space_heating_kwh_per_yr=sh.space_heating_requirement_kwh_per_yr,
total_floor_area_m2=dim.total_floor_area_m2,
space_cooling_per_m2_kwh=sc.space_cooling_per_m2_kwh,
)
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,12 @@ import pytest
from domain.sap.rdsap.cert_to_inputs import (
cert_to_inputs,
fabric_energy_efficiency_from_cert,
heat_transmission_section_from_cert,
internal_gains_section_from_cert,
mean_internal_temperature_section_from_cert,
solar_gains_section_from_cert,
space_cooling_section_from_cert,
space_heating_section_from_cert,
ventilation_from_cert,
water_heating_section_from_cert,
@ -593,3 +595,107 @@ def test_section_8_scalar_line_refs_match_pdf(
# Assert
_pin(actual, expected, f"§8 {fixture_attr} {fixture_name}")
# ============================================================================
# §8c Space cooling — LINE_100..LINE_108
# ============================================================================
# All 6 Elmhurst fixtures have `has_fixed_air_conditioning=False` so f_C=0
# and (107)/(108) collapse to zero. (100), (102), (104) depend on H × (24
# T_e) per fixture, so we don't assert those here (covered by
# `test_space_cooling.py` synthetic-positive case); cascade pins target the
# spec-collapsed lines: (101) η_loss=1, (103) gains=0, (106) intermittency
# mask, (107) cooling kWh=0, (107) annual=0, (108) per-m²=0.
_SECTION_8C_MONTHLY_PINS: Final[tuple[tuple[str, str], ...]] = (
("LINE_101_M_UTILISATION_FACTOR_LOSS", "utilisation_factor_loss_monthly"),
("LINE_103_M_COOLING_GAINS_W", "cooling_gains_monthly_w"),
("LINE_106_M_INTERMITTENCY_FACTOR", "intermittency_factor_monthly"),
("LINE_107_M_SPACE_COOLING_KWH", "space_cooling_monthly_kwh"),
)
_SECTION_8C_SCALAR_PINS: Final[tuple[tuple[str, str], ...]] = (
("SECTION_8C_COOLED_AREA_FRACTION", "cooled_area_fraction"),
("LINE_107_ANNUAL_KWH", "space_cooling_kwh_per_yr"),
("LINE_108_PER_M2_KWH", "space_cooling_per_m2_kwh"),
)
@pytest.mark.parametrize(
"fixture_name,fixture_attr,result_attr",
[
(fix, line, attr)
for fix in _FIXTURES
for line, attr in _SECTION_8C_MONTHLY_PINS
],
ids=lambda x: x if isinstance(x, str) else None,
)
def test_section_8c_monthly_line_refs_match_pdf(
fixture_name: str, fixture_attr: str, result_attr: str
) -> None:
"""§8c monthly pins — every Jan..Dec value of (101)/(103)/(106)/(107)
space-cooling 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
sc = space_cooling_section_from_cert(epc)
assert sc is not None, f"{fixture_name}: space_cooling_from_cert returned None"
actual = getattr(sc, result_attr)
# Assert
for m in range(12):
_pin(actual[m], expected[m], f"§8c {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_8C_SCALAR_PINS
],
ids=lambda x: x if isinstance(x, str) else None,
)
def test_section_8c_scalar_line_refs_match_pdf(
fixture_name: str, fixture_attr: str, result_attr: str
) -> None:
"""§8c scalar pins — (105) cooled-area fraction + (107) annual Σ +
(108) per- 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
sc = space_cooling_section_from_cert(epc)
assert sc is not None, f"{fixture_name}: space_cooling_from_cert returned None"
actual = getattr(sc, result_attr)
# Assert
_pin(actual, expected, f"§8c {fixture_attr} {fixture_name}")
# ============================================================================
# §8f Fabric Energy Efficiency — LINE_109
# ============================================================================
@pytest.mark.parametrize("fixture_name", list(_FIXTURES), ids=lambda x: x)
def test_section_8f_line_109_fee_matches_pdf(fixture_name: str) -> None:
"""§8f scalar pin — (109) FEE = (98a)/(4) + (108) matches the U985 PDF
to abs=1e-4. For all 6 fixtures (98b) solar space heating = 0 and (108)
= 0, so (109) = (99)."""
# Arrange
mod = _FIXTURES[fixture_name]
epc = mod.build_epc() # type: ignore[attr-defined]
expected = mod.LINE_109_FEE_KWH_PER_M2 # type: ignore[attr-defined]
# Act
actual = fabric_energy_efficiency_from_cert(epc)
# Assert
assert actual is not None, f"{fixture_name}: fee_from_cert returned None"
_pin(actual, expected, f"§8f LINE_109_FEE_KWH_PER_M2 {fixture_name}")