§9a slice 1: space_heating_fuel_monthly_kwh orchestrator + EnergyRequirementsResult + 4 synthetic tests

Spec lines 7909-7953 (worksheet block §9a). Composes per-system fuel kWh from (98c)m, Table 11 secondary fraction (201), and per-system efficiencies (206)/(207)/(208). Formula: (211)m = (98c)m × (204) × 100 / (206) where (204) collapses to (202) = 1 − (201) in scope A's single-main case.

EnergyRequirementsResult dataclass mirrors the full §9a worksheet shape with 16 fields including (203)/(205)/(207)/(209)/(213)/(221) zero-branch placeholders — worksheet-shape-fidelity precedent (§8c Q4/Q7/Q9, §8f Q3 grilling). First multi-main / fixed-AC / PCDB cert triggers the slices that populate them.

Synthetic tests: (a) single-main no-secondary 80% efficiency Σ(211)=Σ(98c)/0.8, (b) Table 11 secondary fraction split (201)=0.1 produces (211)+(215) at correct ratios, (c) summer-clamp zeros from §8 (98c)m propagate through linearly to (211)m/(215)m, (d) scope-A two-main + cooling-fuel fields remain zero regardless of inputs.

Calculator + cert_to_inputs wiring lands in slice 3. PDF-derived ALL_FIXTURES pins for slice 2 deferred until PCDB integration grounds LINE_206 (Manufacturer-declared boiler efficiency); flagged in the SPEC_COVERAGE PCDB gap-list entry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-21 08:33:22 +00:00
parent 05d9dc73f8
commit 2b5fc6a575
2 changed files with 219 additions and 0 deletions

View file

@ -0,0 +1,105 @@
"""SAP 10.2 §9a Energy requirements — individual heating systems.
Spec lines 7909-7953 (worksheet block §9a). Composes the per-system fuel
kWh from the §8 space-heating tuple (98c)m, the Table 11 secondary
fraction (201), and the per-system efficiencies (206)/(207)/(208). The
formula for the main system 1 line ref (211)m is:
(211)m = (98c)m × (204) × 100 / (206)
where (204) = (202) × (1 (203)) and (202) = 1 (201). Single-main
case ((203) = 0) collapses (204) to (202), so (211)m = (98c)m × (202) ×
100 / (206). Same shape for secondary (215)m and main 2 (213)m.
Two-main split ((203) > 0) and cooling-fuel (209)/(221) are zero-branch
placeholders in scope A populated once first cert exercises them.
Reference: SAP 10.2 specification (14-03-2025) §9a (lines 7909-7953).
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class EnergyRequirementsResult:
"""SAP 10.2 §9a worksheet line refs (201)..(221).
Scope-A populated lines: (201), (202), (204), (206), (208), (211)m,
(211), (215)m, (215). Two-main and cooling-fuel line refs ((203),
(205), (207), (209), (213)m, (213), (221)) are zero-branch
placeholders until the first multi-main / fixed-AC cert lands.
"""
# Fractions (Table 11)
secondary_heating_fraction: float # (201)
main_heating_total_fraction: float # (202) = 1 - (201)
main_2_of_main_fraction: float # (203)
main_1_of_total_fraction: float # (204) = (202) × (1 - (203))
main_2_of_total_fraction: float # (205) = (202) × (203)
# Efficiencies (%)
main_1_efficiency_pct: float # (206)
main_2_efficiency_pct: float # (207)
secondary_efficiency_pct: float # (208)
cooling_seer: float # (209)
# Per-month fuel (kWh)
main_1_fuel_monthly_kwh: tuple[float, ...] # (211)m
main_2_fuel_monthly_kwh: tuple[float, ...] # (213)m
secondary_fuel_monthly_kwh: tuple[float, ...] # (215)m
# Annual totals (kWh)
main_1_fuel_kwh_per_yr: float # (211)
main_2_fuel_kwh_per_yr: float # (213)
secondary_fuel_kwh_per_yr: float # (215)
cooling_fuel_kwh_per_yr: float # (221)
def space_heating_fuel_monthly_kwh(
*,
space_heating_monthly_kwh: tuple[float, ...],
secondary_heating_fraction: float,
main_heating_efficiency_pct: float,
secondary_heating_efficiency_pct: float,
) -> EnergyRequirementsResult:
"""SAP 10.2 §9a orchestrator — produce (201)..(221) line refs.
Scope A: single-main + secondary only. Two-main ((203) > 0) and
cooling-fuel (Table 10c SEER) populate the zero-branch placeholder
fields with computed values when their respective slices land.
"""
fraction_201 = secondary_heating_fraction
fraction_202 = 1.0 - fraction_201
fraction_203 = 0.0 # scope A: no main 2
fraction_204 = fraction_202 * (1.0 - fraction_203)
fraction_205 = fraction_202 * fraction_203
main_1_eff = main_heating_efficiency_pct
secondary_eff = secondary_heating_efficiency_pct
main_1_fuel_monthly = tuple(
q * fraction_204 * 100.0 / main_1_eff if main_1_eff > 0 else 0.0
for q in space_heating_monthly_kwh
)
secondary_fuel_monthly = tuple(
q * fraction_201 * 100.0 / secondary_eff if secondary_eff > 0 else 0.0
for q in space_heating_monthly_kwh
)
return EnergyRequirementsResult(
secondary_heating_fraction=fraction_201,
main_heating_total_fraction=fraction_202,
main_2_of_main_fraction=fraction_203,
main_1_of_total_fraction=fraction_204,
main_2_of_total_fraction=fraction_205,
main_1_efficiency_pct=main_1_eff,
main_2_efficiency_pct=0.0,
secondary_efficiency_pct=secondary_eff,
cooling_seer=0.0,
main_1_fuel_monthly_kwh=main_1_fuel_monthly,
main_2_fuel_monthly_kwh=(0.0,) * 12,
secondary_fuel_monthly_kwh=secondary_fuel_monthly,
main_1_fuel_kwh_per_yr=sum(main_1_fuel_monthly),
main_2_fuel_kwh_per_yr=0.0,
secondary_fuel_kwh_per_yr=sum(secondary_fuel_monthly),
cooling_fuel_kwh_per_yr=0.0,
)

View file

@ -0,0 +1,114 @@
"""Tests for SAP 10.2 §9a Energy requirements (individual heating systems).
Reference: SAP 10.2 specification (14-03-2025) worksheet block §9a
(lines 7909-7953).
"""
from __future__ import annotations
from domain.sap.worksheet.energy_requirements import (
space_heating_fuel_monthly_kwh,
)
def test_single_main_no_secondary_converts_q_heat_by_efficiency() -> None:
"""Spec lines 7940-7941: (211)m = (98c)m × (204) × 100 / (206). With no
secondary system (201)=0, (204) collapses to 1.0 so (211)m = (98c)m /
(206)/100. With Σ(98c) = 4400 kWh at 80% efficiency, Σ(211) = 4400 ×
100/80 = 5500 kWh."""
# Arrange
monthly_space_heating = (
1000.0, 800.0, 600.0, 400.0, 200.0, 0.0,
0.0, 0.0, 0.0, 200.0, 400.0, 800.0,
)
# Act
result = space_heating_fuel_monthly_kwh(
space_heating_monthly_kwh=monthly_space_heating,
secondary_heating_fraction=0.0,
main_heating_efficiency_pct=80.0,
secondary_heating_efficiency_pct=100.0,
)
# Assert
assert result.main_1_fuel_kwh_per_yr == 5500.0
def test_table_11_secondary_fraction_splits_q_heat_between_main_and_secondary() -> None:
"""Spec lines 7912-7914 + 7940-7951: (201) Table 11 secondary fraction
routes (98c)m × (201) to the secondary system, (98c)m × (202) to the
main. With (201)=0.1, both efficiencies 80%, Σ(98c)=4400 Σ(211) =
4400 × 0.9 × 100/80 = 4950 kWh; Σ(215) = 4400 × 0.1 × 100/80 = 550 kWh."""
# Arrange
monthly_space_heating = (
1000.0, 800.0, 600.0, 400.0, 200.0, 0.0,
0.0, 0.0, 0.0, 200.0, 400.0, 800.0,
)
# Act
result = space_heating_fuel_monthly_kwh(
space_heating_monthly_kwh=monthly_space_heating,
secondary_heating_fraction=0.1,
main_heating_efficiency_pct=80.0,
secondary_heating_efficiency_pct=80.0,
)
# Assert
assert result.secondary_heating_fraction == 0.1
assert result.main_heating_total_fraction == 0.9
assert result.main_1_fuel_kwh_per_yr == 4950.0
assert result.secondary_fuel_kwh_per_yr == 550.0
def test_per_month_fuel_preserves_summer_clamp_zeros_from_98c() -> None:
"""The §8 Table 9c summer clamp zeros (98c)m for Jun..Sep. §9a's per-
month (211)m / (215)m tuples are linear in (98c)m so they inherit the
summer zeros important downstream because §10a fuel-cost cascade
indexes the per-month tuples directly."""
# Arrange
monthly_space_heating = (
1000.0, 800.0, 600.0, 400.0, 200.0, 0.0,
0.0, 0.0, 0.0, 200.0, 400.0, 800.0,
)
# Act
result = space_heating_fuel_monthly_kwh(
space_heating_monthly_kwh=monthly_space_heating,
secondary_heating_fraction=0.1,
main_heating_efficiency_pct=80.0,
secondary_heating_efficiency_pct=80.0,
)
# Assert
assert result.main_1_fuel_monthly_kwh[5] == 0.0 # Jun
assert result.main_1_fuel_monthly_kwh[8] == 0.0 # Sep
assert result.secondary_fuel_monthly_kwh[5] == 0.0
assert result.secondary_fuel_monthly_kwh[8] == 0.0
assert result.main_1_fuel_monthly_kwh[0] == 1125.0 # Jan: 1000 × 0.9 × 100/80
assert result.secondary_fuel_monthly_kwh[0] == 125.0 # Jan: 1000 × 0.1 × 100/80
def test_scope_a_two_main_and_cooling_fuel_remain_zero_placeholders() -> None:
"""Scope A defers two-main system ((203)/(205)/(207)/(213)) and cooling
SEER ((209)/(221)). EnergyRequirementsResult mirrors the worksheet's
full §9a shape but those fields stay zero regardless of inputs first
multi-main / fixed-AC cert triggers the slice that populates them."""
# Arrange
monthly_space_heating = (1000.0,) * 12
# Act
result = space_heating_fuel_monthly_kwh(
space_heating_monthly_kwh=monthly_space_heating,
secondary_heating_fraction=0.1,
main_heating_efficiency_pct=80.0,
secondary_heating_efficiency_pct=80.0,
)
# Assert
assert result.main_2_of_main_fraction == 0.0
assert result.main_2_of_total_fraction == 0.0
assert result.main_2_efficiency_pct == 0.0
assert result.main_2_fuel_monthly_kwh == (0.0,) * 12
assert result.main_2_fuel_kwh_per_yr == 0.0
assert result.cooling_seer == 0.0
assert result.cooling_fuel_kwh_per_yr == 0.0