mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
2b1f90a7de
commit
8ae978a646
5 changed files with 122 additions and 18 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue