S0380.200: SAP 10.2 §9a two-main-heating split (203)/(205)/(207)/(213)

The cascade lumped a dwelling with two main heating systems into one:
`space_heating_fuel_monthly_kwh` hard-coded (203)=0 (a documented
scope-A placeholder) and the calculator's per-month fuel read only
main_1, so the full §8 space-heat demand billed against system 1's
efficiency. Simulated case 6 (one oil boiler feeding radiators 51% +
underfloor 49%) exposed it: main fuel ≈ demand/eff1 instead of the
worksheet's (211)+(213) per-system split.

Implements the SAP 10.2 §9a two-main model:
  (204) = (202) × (1 − (203))   → system 1 share of total heat
  (205) = (202) × (203)         → system 2 share of total heat
  (211)m = (98c)m × (204) × 100 / (206)
  (213)m = (98c)m × (205) × 100 / (207)
(203) = the second system's lodged `main_heating_fraction`; (207) = its
own seasonal efficiency via the new per-detail `_main_heating_detail_
efficiency` (the core of `_main_heating_efficiency`, now reused for
system 2). Calculator `_solve_month` aggregates main_1 + main_2 into
`main_heating_fuel_kwh`. Cost (§10a 241), CO2 (§12 262) and PE (§13 276)
main_2 paths were already wired and now activate.

Site-notes gap also fixed: §14.1 Main Heating2 omits the "Fuel Type"
cell when the second system shares Main 1's fuel (case 6: one oil boiler,
two emitters). `_map_elmhurst_main_heating_2` now inherits Main 1's
resolved fuel as a fallback.

Blast radius: only dual-main certs. 0240 (2× oil code 130, identical
Eq-D1 efficiency) is unchanged — its split collapses to the lumped total.
Suite: 2355 passed, 1 skipped. New code: 0 pyright errors.

NOTE: case 6 is not yet fully pinnable end-to-end — its two systems have
DIFFERENT efficiencies (radiators 55°C → 79%, underfloor 35°C → 84%), a
flow-temperature boiler-efficiency adjustment not yet modelled, and its
dual-system auxiliary pumps ((230c)+(230d)=356) differ from the cascade.
Both are separate follow-on features; this slice is the §9a fuel split.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-03 13:09:43 +00:00
parent 2b1f90a7de
commit 8ae978a646
5 changed files with 122 additions and 18 deletions

View file

@ -4861,6 +4861,8 @@ def _elmhurst_main_heating_category(
def _map_elmhurst_main_heating_2(
mh2: Optional[ElmhurstMainHeating2],
*,
fallback_fuel_type: Union[int, str, None] = None,
) -> Optional[MainHeatingDetail]:
"""Build a `MainHeatingDetail` from the Elmhurst §14.1 Main Heating2
block. Returns None when no Main 2 is lodged (extractor convention:
@ -4896,6 +4898,20 @@ def _map_elmhurst_main_heating_2(
and mh2.main_heating_sap_code in _ELECTRIC_SAP_MAIN_HEATING_CODES
):
main_fuel_int = _STANDARD_ELECTRICITY_FUEL_CODE
# §14.1 Main Heating2 often omits the "Fuel Type" cell when the
# second main system shares Main 1's fuel (simulated case 6: one oil
# boiler feeding radiators + underfloor, so the Summary lodges the
# fuel once on §14.0). Inherit Main 1's resolved fuel so the §9a
# two-main split (213)m can apply system 2's own efficiency.
resolved_fuel: Union[int, str] = (
main_fuel_int
if main_fuel_int is not None
else (
fallback_fuel_type
if (not mh2.fuel_type and fallback_fuel_type is not None)
else mh2.fuel_type
)
)
category: Optional[int] = None
if pcdb_index is not None and heat_pump_record(pcdb_index) is not None:
category = _ELMHURST_HEATING_CATEGORY_HEAT_PUMP
@ -4906,7 +4922,7 @@ def _map_elmhurst_main_heating_2(
# cert-level renewables block is the single source of truth and
# is already wired into Main 1.
has_fghrs=False,
main_fuel_type=main_fuel_int if main_fuel_int is not None else mh2.fuel_type,
main_fuel_type=resolved_fuel,
# §14.1 doesn't lodge a heat emitter (the emitter is Main 1's
# radiator/UFH); leave as empty-string sentinel for cascade
# consumers that key off Main 1's emitter.
@ -5110,7 +5126,9 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating:
# services DHW via `Water Heating SapCode 914` ("from second main
# system") while Main 1 handles space heat. None when the §14.1
# block is absent or lodges only placeholder zeros.
main_2_detail = _map_elmhurst_main_heating_2(mh.main_heating_2)
main_2_detail = _map_elmhurst_main_heating_2(
mh.main_heating_2, fallback_fuel_type=main_1_detail.main_fuel_type
)
main_heating_details = (
[main_1_detail, main_2_detail]
if main_2_detail is not None

View file

@ -425,9 +425,15 @@ def _solve_month(
# SAP 10.2 §8 — (98c)m precomputed upstream by `space_heating_monthly_kwh`
# (includes Table 9c summer clamp Jun..Sep). Calculator indexes directly.
q_heat = inputs.space_heating_monthly_kwh[month - 1]
# SAP 10.2 §9a — (211)m/(215)m precomputed upstream by
# SAP 10.2 §9a — (211)m/(213)m/(215)m precomputed upstream by
# `space_heating_fuel_monthly_kwh`. Calculator stops doing q/η inline.
fuel_main = inputs.energy_requirements.main_1_fuel_monthly_kwh[month - 1]
# `main_heating_fuel_kwh` aggregates both main systems (213)m is zero
# for single-main certs, so this is the per-system sum for dual-main
# dwellings (cert 0240 / simulated case 6) and main-1 alone otherwise.
fuel_main = (
inputs.energy_requirements.main_1_fuel_monthly_kwh[month - 1]
+ inputs.energy_requirements.main_2_fuel_monthly_kwh[month - 1]
)
fuel_secondary = inputs.energy_requirements.secondary_fuel_monthly_kwh[month - 1]
# SAP 10.2 §8c — (107)m precomputed upstream by `space_cooling_monthly_kwh`

View file

@ -1570,7 +1570,16 @@ def _main_heating_efficiency(epc: EpcPropertyData) -> float:
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)
return _main_heating_detail_efficiency(_first_main_heating(epc), epc)
def _main_heating_detail_efficiency(
main: Optional[MainHeatingDetail], epc: EpcPropertyData
) -> float:
"""SAP 10.2 (206)/(207) efficiency (0..1) for a SPECIFIC main heating
detail the per-detail core of `_main_heating_efficiency`. Used for
both main system 1 (206) and main system 2 (207) on dual-main certs
(cert 0240 / simulated case 6)."""
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)
@ -3956,11 +3965,25 @@ def energy_requirements_section_from_cert(
if secondary_fraction_value > 0.0 else 0.0
)
eff = _main_heating_efficiency(epc)
# SAP 10.2 §9a two-main split (203)/(207): when a second main heating
# system is lodged, (203) = its `main_heating_fraction` (% of main
# heating it supplies) and (207) = its own seasonal efficiency. Cert
# 0240 (2× oil code 130, 51/49) + simulated case 6 (oil code 127,
# rads 51% + underfloor 49%) exercise this.
details = epc.sap_heating.main_heating_details if epc.sap_heating else []
main_2 = details[1] if len(details) >= 2 else None
main_2_of_main_fraction = 0.0
main_2_efficiency_value = 0.0
if main_2 is not None and main_2.main_heating_fraction is not None:
main_2_of_main_fraction = main_2.main_heating_fraction / 100.0
main_2_efficiency_value = _main_heating_detail_efficiency(main_2, 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,
main_2_of_main_fraction=main_2_of_main_fraction,
main_2_efficiency_pct=main_2_efficiency_value * 100.0,
)
@ -6351,11 +6374,22 @@ def cert_to_inputs(
secondary_efficiency_value = _secondary_efficiency(
epc.sap_heating, main_code, main_fuel
)
# SAP 10.2 §9a two-main split (203)/(207) — see the section helper
# `energy_requirements_section_from_cert` for the rationale.
_main_details = epc.sap_heating.main_heating_details if epc.sap_heating else []
_main_2 = _main_details[1] if len(_main_details) >= 2 else None
main_2_of_main_fraction = 0.0
main_2_efficiency_value = 0.0
if _main_2 is not None and _main_2.main_heating_fraction is not None:
main_2_of_main_fraction = _main_2.main_heating_fraction / 100.0
main_2_efficiency_value = _main_heating_detail_efficiency(_main_2, epc)
energy_requirements_result = space_heating_fuel_monthly_kwh(
space_heating_monthly_kwh=space_heating_result.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,
main_2_of_main_fraction=main_2_of_main_fraction,
main_2_efficiency_pct=main_2_efficiency_value * 100.0,
)
# SAP 10.2 Appendix M1 §3-4 (p.93-94): split monthly PV generation

View file

@ -11,8 +11,10 @@ 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.
Two-main split ((203) > 0) is implemented: (211)m = (98c)m × (204) ×
100 / (206) for system 1 and (213)m = (98c)m × (205) × 100 / (207) for
system 2, where (204) = (202) × (1 (203)) and (205) = (202) × (203).
Cooling-fuel (209)/(221) remains a zero-branch placeholder.
Reference: SAP 10.2 specification (14-03-2025) §9a (lines 7909-7953).
"""
@ -26,10 +28,9 @@ from dataclasses import dataclass
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.
Populated lines: (201)-(208), (211)m/(211), (213)m/(213) (two-main
split), (215)m/(215). Cooling-fuel line refs ((209), (221)) are
zero-branch placeholders until the first fixed-AC cert lands.
"""
# Fractions (Table 11)
@ -60,26 +61,37 @@ def space_heating_fuel_monthly_kwh(
secondary_heating_fraction: float,
main_heating_efficiency_pct: float,
secondary_heating_efficiency_pct: float,
main_2_of_main_fraction: float = 0.0,
main_2_efficiency_pct: float = 0.0,
) -> 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.
Single-main certs leave `main_2_of_main_fraction` = 0, collapsing
(204) to (202) and zeroing (213)m. Dual-main certs (cert 0240 /
simulated case 6) pass (203) = fraction of main heating from main
system 2 and (207) = main system 2 efficiency; the §8 space-heat
demand then splits (204)=(202)×(1(203)) to system 1 and
(205)=(202)×(203) to system 2, each at its own efficiency. Cooling-
fuel (Table 10c SEER) remains a zero-branch placeholder.
"""
fraction_201 = secondary_heating_fraction
fraction_202 = 1.0 - fraction_201
fraction_203 = 0.0 # scope A: no main 2
fraction_203 = main_2_of_main_fraction
fraction_204 = fraction_202 * (1.0 - fraction_203)
fraction_205 = fraction_202 * fraction_203
main_1_eff = main_heating_efficiency_pct
main_2_eff = main_2_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
)
main_2_fuel_monthly = tuple(
q * fraction_205 * 100.0 / main_2_eff if main_2_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
@ -92,14 +104,14 @@ def space_heating_fuel_monthly_kwh(
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,
main_2_efficiency_pct=main_2_eff,
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,
main_2_fuel_monthly_kwh=main_2_fuel_monthly,
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,
main_2_fuel_kwh_per_yr=sum(main_2_fuel_monthly),
secondary_fuel_kwh_per_yr=sum(secondary_fuel_monthly),
cooling_fuel_kwh_per_yr=0.0,
)

View file

@ -60,6 +60,40 @@ def test_table_11_secondary_fraction_splits_q_heat_between_main_and_secondary()
assert result.secondary_fuel_kwh_per_yr == 550.0
def test_two_main_systems_split_q_heat_by_fraction_203_at_own_efficiencies() -> None:
"""Spec §9a (203)/(204)/(205) two-main split: when a second main
system supplies (203) of the main heating, (204)=(202)×(1(203)) goes
to system 1 at (206) and (205)=(202)×(203) to system 2 at (207). With
no secondary ((202)=1), (203)=0.49, eff1=79%, eff2=84%, Σ(98c)=4400:
Σ(211) = 4400 × 0.51 × 100/79 = 2840.5063 kWh
Σ(213) = 4400 × 0.49 × 100/84 = 2566.6667 kWh
Mirrors simulated case 6 (oil boiler, radiators 51% + underfloor 49%)
and cert 0240 (identical-efficiency systems collapse to the single-
main total)."""
# 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=79.0,
secondary_heating_efficiency_pct=0.0,
main_2_of_main_fraction=0.49,
main_2_efficiency_pct=84.0,
)
# Assert
assert abs(result.main_2_of_main_fraction - 0.49) <= 1e-12
assert abs(result.main_1_of_total_fraction - 0.51) <= 1e-12
assert abs(result.main_2_of_total_fraction - 0.49) <= 1e-12
assert abs(result.main_1_fuel_kwh_per_yr - 4400.0 * 0.51 * 100.0 / 79.0) <= 1e-9
assert abs(result.main_2_fuel_kwh_per_yr - 4400.0 * 0.49 * 100.0 / 84.0) <= 1e-9
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