Slice 29: §9a energy requirements cascade pin (72/72)

Adds `energy_requirements_section_from_cert(epc)` to the cert→inputs
cascade. Composes §8 (98c)m + Table 11 secondary fraction + per-system
efficiencies into (201)..(221) line refs via the existing
`space_heating_fuel_monthly_kwh` orchestrator.

Extracts `_main_heating_efficiency(epc)` as a shared helper — same eff
derivation as the inline `cert_to_inputs` flow (PCDB winter override →
Table 4a/4b seasonal → heat-network 1/DLF override). Single source of
truth for §4 and §9a.

Worksheet display convention: when no secondary system is lodged the
PDF displays (208) = 0 (not the fallback 100% electric efficiency). The
per-system fuel formula already collapses to 0 via fraction_201 = 0, so
this is presentation-only; the helper zeros (208) when
`secondary_fraction == 0`. 000474 (no secondary) now matches exactly.

Adds §9a LINE_ constants to all 6 fixtures — (201), (202), (206), (207),
(208), (211)m, (211), (213)m, (213), (215)m, (215), (221). Extracted
from `sap worksheets/U985-0001-NNNNNN.txt` PDF blocks.

Cascade scoreboard: 396/396 → 468/468 (§7..§9a closed).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-24 00:16:12 +00:00
parent 13719e010a
commit 049694e1e6
8 changed files with 279 additions and 12 deletions

View file

@ -411,6 +411,35 @@ def _first_main_heating(epc: EpcPropertyData) -> Optional[MainHeatingDetail]:
return details[0] if details else None
def _main_heating_efficiency(epc: EpcPropertyData) -> float:
"""SAP 10.2 (206) main system 1 efficiency as a 0..1 fraction.
Resolves PCDB Table 105 winter efficiency override Table 4a/4b
seasonal efficiency heat-network 1/DLF override. Used by §4 (water
heating cascade) and §9a (per-system fuel kWh) both must see the
same value, so this single helper is the single source of truth."""
main = _first_main_heating(epc)
main_code = main.sap_main_heating_code if main is not None else None
main_category = main.main_heating_category if main is not None else None
main_fuel = _main_fuel_code(main)
pcdb_main = (
gas_oil_boiler_record(main.main_heating_index_number)
if main is not None and main.main_heating_index_number is not None
else None
)
if pcdb_main is not None and pcdb_main.winter_efficiency_pct is not None:
eff = pcdb_main.winter_efficiency_pct / 100.0
else:
eff = seasonal_efficiency(main_code, main_category, main_fuel)
if _is_heat_network_main(main):
primary_age = (
epc.sap_building_parts[0].construction_age_band
if epc.sap_building_parts else None
)
eff = 1.0 / _heat_network_dlf(primary_age)
return eff
def _control_type(main: Optional[MainHeatingDetail]) -> int:
"""SAP 10.2 §7.1 / Table 9 control type 1/2/3 from the
`main_heating_control` code on `MainHeatingDetail`. Defaults to 2
@ -1013,6 +1042,42 @@ def fabric_energy_efficiency_from_cert(epc: EpcPropertyData) -> Optional[float]:
)
def energy_requirements_section_from_cert(
epc: EpcPropertyData,
) -> Optional[EnergyRequirementsResult]:
"""SAP 10.2 §9a cert→inputs cascade for `space_heating_fuel_monthly_kwh`.
Composes §8 (98c)m + Table 11 secondary fraction + per-system
efficiencies into the (201)..(221) line refs. Single-main scope A
(no (203)/(207)/(213)/(209)/(221)). Returns None when TFA missing.
"""
if epc.total_floor_area_m2 is None:
return None
sh = space_heating_section_from_cert(epc)
assert sh is not None, "space_heating None despite TFA present"
main = _first_main_heating(epc)
main_code = main.sap_main_heating_code if main is not None else None
main_category = main.main_heating_category if main is not None else None
main_fuel = _main_fuel_code(main)
secondary_fraction_value = _secondary_fraction(
main, epc.sap_heating.secondary_heating_type if epc.sap_heating else None
)
# When no secondary system is lodged the worksheet displays (208) = 0;
# the per-system fuel formula already collapses to 0 via fraction_201 = 0
# so this is presentation-only.
secondary_efficiency_value = (
_secondary_efficiency(epc.sap_heating, main_code, main_fuel)
if secondary_fraction_value > 0.0 else 0.0
)
eff = _main_heating_efficiency(epc)
return space_heating_fuel_monthly_kwh(
space_heating_monthly_kwh=sh.total_space_heating_monthly_kwh,
secondary_heating_fraction=secondary_fraction_value,
main_heating_efficiency_pct=eff * 100.0,
secondary_heating_efficiency_pct=secondary_efficiency_value * 100.0,
)
def solar_gains_section_from_cert(epc: EpcPropertyData) -> SolarGainsResult:
"""SAP 10.2 §6 cert→inputs cascade for `solar_gains_from_cert`.
@ -1450,18 +1515,10 @@ def cert_to_inputs(
if main is not None and main.main_heating_index_number is not None
else None
)
if pcdb_main is not None and pcdb_main.winter_efficiency_pct is not None:
eff = pcdb_main.winter_efficiency_pct / 100.0
else:
eff = seasonal_efficiency(main_code, main_category, main_fuel)
if _is_heat_network_main(main):
# SAP 10.2 Table 12 note (k): heat-network unit prices are per
# kWh of heat GENERATED (before distribution losses), not per
# kWh of fuel consumed. Setting efficiency = 1/DLF makes the
# calculator's `main_fuel_kwh = q_useful / (1/DLF) = q_useful
# × DLF = q_generated`, so cost = q_generated × unit_price as
# the spec requires.
eff = 1.0 / _heat_network_dlf(primary_age)
# Heat-network override (Table 12 note (k)) sets efficiency = 1/DLF so
# `main_fuel_kwh = q_useful × DLF = q_generated`, matching the spec's
# "unit prices per kWh of heat generated" convention.
eff = _main_heating_efficiency(epc)
if pcdb_main is not None and pcdb_main.summer_efficiency_pct is not None:
water_eff = pcdb_main.summer_efficiency_pct / 100.0
else:

View file

@ -431,3 +431,22 @@ LINE_108_PER_M2_KWH: float = 0.0
# solar space heating = 0 → Σ(98a) = Σ(98c) → (98a)/TFA = LINE_99_PER_M2_KWH;
# (108) = LINE_108_PER_M2_KWH = 0 (no AC). So LINE_109 = LINE_99 exactly.
LINE_109_FEE_KWH_PER_M2: float = 186.879
# ============================================================================
# §9a Energy requirements — Individual heating systems
# ============================================================================
LINE_201_SECONDARY_FRACTION: float = 0.0000
LINE_202_MAIN_TOTAL_FRACTION: float = 1.0000
LINE_206_MAIN_1_EFFICIENCY_PCT: float = 88.7000
LINE_207_MAIN_2_EFFICIENCY_PCT: float = 0.0000
LINE_208_SECONDARY_EFFICIENCY_PCT: float = 0.0000
LINE_211_M_MAIN_1_FUEL_KWH: tuple[float, ...] = (
2055.0528, 1734.1671, 1623.6845, 1157.0979, 750.5403, 0.0000,
0.0000, 0.0000, 0.0000, 1027.8952, 1549.2667, 2067.1879,
)
LINE_211_ANNUAL_KWH: float = 11964.8924
LINE_213_M_MAIN_2_FUEL_KWH: tuple[float, ...] = (0.0,) * 12
LINE_213_ANNUAL_KWH: float = 0.0000
LINE_215_M_SECONDARY_FUEL_KWH: tuple[float, ...] = (0.0,) * 12
LINE_215_ANNUAL_KWH: float = 0.0000
LINE_221_COOLING_FUEL_KWH: float = 0.0000

View file

@ -397,3 +397,25 @@ LINE_108_PER_M2_KWH: float = 0.0
# solar space heating = 0 → Σ(98a) = Σ(98c) → (98a)/TFA = LINE_99_PER_M2_KWH;
# (108) = LINE_108_PER_M2_KWH = 0 (no AC). So LINE_109 = LINE_99 exactly.
LINE_109_FEE_KWH_PER_M2: float = 130.3326
# ============================================================================
# §9a Energy requirements — Individual heating systems
# ============================================================================
LINE_201_SECONDARY_FRACTION: float = 0.1000
LINE_202_MAIN_TOTAL_FRACTION: float = 0.9000
LINE_206_MAIN_1_EFFICIENCY_PCT: float = 88.6000
LINE_207_MAIN_2_EFFICIENCY_PCT: float = 0.0000
LINE_208_SECONDARY_EFFICIENCY_PCT: float = 100.0000
LINE_211_M_MAIN_1_FUEL_KWH: tuple[float, ...] = (
1790.1174, 1504.3490, 1401.3456, 978.5134, 626.0756, 0.0000,
0.0000, 0.0000, 0.0000, 865.2002, 1323.5731, 1781.7982,
)
LINE_211_ANNUAL_KWH: float = 10270.9726
LINE_213_M_MAIN_2_FUEL_KWH: tuple[float, ...] = (0.0,) * 12
LINE_213_ANNUAL_KWH: float = 0.0000
LINE_215_M_SECONDARY_FUEL_KWH: tuple[float, ...] = (
176.2271, 148.0948, 137.9547, 96.3292, 61.6337, 0.0000,
0.0000, 0.0000, 0.0000, 85.1741, 130.2984, 175.4081,
)
LINE_215_ANNUAL_KWH: float = 1011.1202
LINE_221_COOLING_FUEL_KWH: float = 0.0000

View file

@ -439,3 +439,25 @@ LINE_108_PER_M2_KWH: float = 0.0
# solar space heating = 0 → Σ(98a) = Σ(98c) → (98a)/TFA = LINE_99_PER_M2_KWH;
# (108) = LINE_108_PER_M2_KWH = 0 (no AC). So LINE_109 = LINE_99 exactly.
LINE_109_FEE_KWH_PER_M2: float = 146.8852
# ============================================================================
# §9a Energy requirements — Individual heating systems
# ============================================================================
LINE_201_SECONDARY_FRACTION: float = 0.1000
LINE_202_MAIN_TOTAL_FRACTION: float = 0.9000
LINE_206_MAIN_1_EFFICIENCY_PCT: float = 88.7000
LINE_207_MAIN_2_EFFICIENCY_PCT: float = 0.0000
LINE_208_SECONDARY_EFFICIENCY_PCT: float = 100.0000
LINE_211_M_MAIN_1_FUEL_KWH: tuple[float, ...] = (
2148.2772, 1820.4838, 1716.4018, 1232.1415, 804.0422, 0.0000,
0.0000, 0.0000, 0.0000, 1082.1336, 1619.0882, 2157.7252,
)
LINE_211_ANNUAL_KWH: float = 12580.2936
LINE_213_M_MAIN_2_FUEL_KWH: tuple[float, ...] = (0.0,) * 12
LINE_213_ANNUAL_KWH: float = 0.0000
LINE_215_M_SECONDARY_FUEL_KWH: tuple[float, ...] = (
211.7247, 179.4188, 169.1609, 121.4344, 79.2428, 0.0000,
0.0000, 0.0000, 0.0000, 106.6503, 159.5701, 212.6558,
)
LINE_215_ANNUAL_KWH: float = 1239.8578
LINE_221_COOLING_FUEL_KWH: float = 0.0000

View file

@ -462,3 +462,25 @@ LINE_108_PER_M2_KWH: float = 0.0
# solar space heating = 0 → Σ(98a) = Σ(98c) → (98a)/TFA = LINE_99_PER_M2_KWH;
# (108) = LINE_108_PER_M2_KWH = 0 (no AC). So LINE_109 = LINE_99 exactly.
LINE_109_FEE_KWH_PER_M2: float = 132.828
# ============================================================================
# §9a Energy requirements — Individual heating systems
# ============================================================================
LINE_201_SECONDARY_FRACTION: float = 0.1000
LINE_202_MAIN_TOTAL_FRACTION: float = 0.9000
LINE_206_MAIN_1_EFFICIENCY_PCT: float = 88.5000
LINE_207_MAIN_2_EFFICIENCY_PCT: float = 0.0000
LINE_208_SECONDARY_EFFICIENCY_PCT: float = 100.0000
LINE_211_M_MAIN_1_FUEL_KWH: tuple[float, ...] = (
1895.6271, 1588.7007, 1496.1538, 1083.2060, 729.6397, 0.0000,
0.0000, 0.0000, 0.0000, 920.5077, 1406.7319, 1897.8511,
)
LINE_211_ANNUAL_KWH: float = 11018.4181
LINE_213_M_MAIN_2_FUEL_KWH: tuple[float, ...] = (0.0,) * 12
LINE_213_ANNUAL_KWH: float = 0.0000
LINE_215_M_SECONDARY_FUEL_KWH: tuple[float, ...] = (
186.4033, 156.2222, 147.1218, 106.5153, 71.7479, 0.0000,
0.0000, 0.0000, 0.0000, 90.5166, 138.3286, 186.6220,
)
LINE_215_ANNUAL_KWH: float = 1083.4778
LINE_221_COOLING_FUEL_KWH: float = 0.0000

View file

@ -413,3 +413,25 @@ LINE_108_PER_M2_KWH: float = 0.0
# solar space heating = 0 → Σ(98a) = Σ(98c) → (98a)/TFA = LINE_99_PER_M2_KWH;
# (108) = LINE_108_PER_M2_KWH = 0 (no AC). So LINE_109 = LINE_99 exactly.
LINE_109_FEE_KWH_PER_M2: float = 169.2897
# ============================================================================
# §9a Energy requirements — Individual heating systems
# ============================================================================
LINE_201_SECONDARY_FRACTION: float = 0.1000
LINE_202_MAIN_TOTAL_FRACTION: float = 0.9000
LINE_206_MAIN_1_EFFICIENCY_PCT: float = 88.2000
LINE_207_MAIN_2_EFFICIENCY_PCT: float = 0.0000
LINE_208_SECONDARY_EFFICIENCY_PCT: float = 100.0000
LINE_211_M_MAIN_1_FUEL_KWH: tuple[float, ...] = (
1954.5034, 1649.5170, 1552.7436, 1119.1388, 736.2528, 0.0000,
0.0000, 0.0000, 0.0000, 968.1611, 1466.0411, 1965.1473,
)
LINE_211_ANNUAL_KWH: float = 11411.5052
LINE_213_M_MAIN_2_FUEL_KWH: tuple[float, ...] = (0.0,) * 12
LINE_213_ANNUAL_KWH: float = 0.0000
LINE_215_M_SECONDARY_FUEL_KWH: tuple[float, ...] = (
191.5413, 161.6527, 152.1689, 109.6756, 72.1528, 0.0000,
0.0000, 0.0000, 0.0000, 94.8798, 143.6720, 192.5844,
)
LINE_215_ANNUAL_KWH: float = 1118.3275
LINE_221_COOLING_FUEL_KWH: float = 0.0000

View file

@ -439,3 +439,25 @@ LINE_108_PER_M2_KWH: float = 0.0
# solar space heating = 0 → Σ(98a) = Σ(98c) → (98a)/TFA = LINE_99_PER_M2_KWH;
# (108) = LINE_108_PER_M2_KWH = 0 (no AC). So LINE_109 = LINE_99 exactly.
LINE_109_FEE_KWH_PER_M2: float = 137.0700
# ============================================================================
# §9a Energy requirements — Individual heating systems
# ============================================================================
LINE_201_SECONDARY_FRACTION: float = 0.1000
LINE_202_MAIN_TOTAL_FRACTION: float = 0.9000
LINE_206_MAIN_1_EFFICIENCY_PCT: float = 88.6000
LINE_207_MAIN_2_EFFICIENCY_PCT: float = 0.0000
LINE_208_SECONDARY_EFFICIENCY_PCT: float = 100.0000
LINE_211_M_MAIN_1_FUEL_KWH: tuple[float, ...] = (
2164.7554, 1825.9838, 1716.8828, 1226.9655, 802.7014, 0.0000,
0.0000, 0.0000, 0.0000, 1078.7200, 1622.3099, 2168.0981,
)
LINE_211_ANNUAL_KWH: float = 12606.4169
LINE_213_M_MAIN_2_FUEL_KWH: tuple[float, ...] = (0.0,) * 12
LINE_213_ANNUAL_KWH: float = 0.0000
LINE_215_M_SECONDARY_FUEL_KWH: tuple[float, ...] = (
213.1081, 179.7580, 169.0176, 120.7879, 79.0215, 0.0000,
0.0000, 0.0000, 0.0000, 106.1940, 159.7074, 213.4372,
)
LINE_215_ANNUAL_KWH: float = 1241.0317
LINE_221_COOLING_FUEL_KWH: float = 0.0000

View file

@ -18,6 +18,7 @@ import pytest
from domain.sap.rdsap.cert_to_inputs import (
cert_to_inputs,
energy_requirements_section_from_cert,
fabric_energy_efficiency_from_cert,
heat_transmission_section_from_cert,
internal_gains_section_from_cert,
@ -699,3 +700,83 @@ def test_section_8f_line_109_fee_matches_pdf(fixture_name: str) -> None:
# 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}")
# ============================================================================
# §9a Energy requirements — LINE_201..LINE_221
# ============================================================================
_SECTION_9A_MONTHLY_PINS: Final[tuple[tuple[str, str], ...]] = (
("LINE_211_M_MAIN_1_FUEL_KWH", "main_1_fuel_monthly_kwh"),
("LINE_213_M_MAIN_2_FUEL_KWH", "main_2_fuel_monthly_kwh"),
("LINE_215_M_SECONDARY_FUEL_KWH", "secondary_fuel_monthly_kwh"),
)
_SECTION_9A_SCALAR_PINS: Final[tuple[tuple[str, str], ...]] = (
("LINE_201_SECONDARY_FRACTION", "secondary_heating_fraction"),
("LINE_202_MAIN_TOTAL_FRACTION", "main_heating_total_fraction"),
("LINE_206_MAIN_1_EFFICIENCY_PCT", "main_1_efficiency_pct"),
("LINE_207_MAIN_2_EFFICIENCY_PCT", "main_2_efficiency_pct"),
("LINE_208_SECONDARY_EFFICIENCY_PCT", "secondary_efficiency_pct"),
("LINE_211_ANNUAL_KWH", "main_1_fuel_kwh_per_yr"),
("LINE_213_ANNUAL_KWH", "main_2_fuel_kwh_per_yr"),
("LINE_215_ANNUAL_KWH", "secondary_fuel_kwh_per_yr"),
("LINE_221_COOLING_FUEL_KWH", "cooling_fuel_kwh_per_yr"),
)
@pytest.mark.parametrize(
"fixture_name,fixture_attr,result_attr",
[
(fix, line, attr)
for fix in _FIXTURES
for line, attr in _SECTION_9A_MONTHLY_PINS
],
ids=lambda x: x if isinstance(x, str) else None,
)
def test_section_9a_monthly_line_refs_match_pdf(
fixture_name: str, fixture_attr: str, result_attr: str
) -> None:
"""§9a monthly pins — (211)m main 1 fuel, (213)m main 2 fuel, (215)m
secondary fuel match the U985 PDF to abs=1e-4 for every Jan..Dec."""
# Arrange
mod = _FIXTURES[fixture_name]
epc = mod.build_epc() # type: ignore[attr-defined]
expected = getattr(mod, fixture_attr)
# Act
er = energy_requirements_section_from_cert(epc)
assert er is not None, f"{fixture_name}: energy_req_from_cert returned None"
actual = getattr(er, result_attr)
# Assert
for m in range(12):
_pin(actual[m], expected[m], f"§9a {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_9A_SCALAR_PINS
],
ids=lambda x: x if isinstance(x, str) else None,
)
def test_section_9a_scalar_line_refs_match_pdf(
fixture_name: str, fixture_attr: str, result_attr: str
) -> None:
"""§9a scalar pins — Table 11 fractions + per-system efficiencies +
annual fuel-kWh totals + cooling fuel 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
er = energy_requirements_section_from_cert(epc)
assert er is not None, f"{fixture_name}: energy_req_from_cert returned None"
actual = getattr(er, result_attr)
# Assert
_pin(actual, expected, f"§9a {fixture_attr} {fixture_name}")