From 8ae978a646187036f4a4d8dac249d3c1542bd896 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 3 Jun 2026 13:09:43 +0000 Subject: [PATCH] =?UTF-8?q?S0380.200:=20SAP=2010.2=20=C2=A79a=20two-main-h?= =?UTF-8?q?eating=20split=20(203)/(205)/(207)/(213)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- datatypes/epc/domain/mapper.py | 22 ++++++++++- domain/sap10_calculator/calculator.py | 10 ++++- .../sap10_calculator/rdsap/cert_to_inputs.py | 36 +++++++++++++++++- .../worksheet/energy_requirements.py | 38 ++++++++++++------- .../worksheet/test_energy_requirements.py | 34 +++++++++++++++++ 5 files changed, 122 insertions(+), 18 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 92373fbe..3d85d19a 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -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 diff --git a/domain/sap10_calculator/calculator.py b/domain/sap10_calculator/calculator.py index 6b33c3f7..3252b924 100644 --- a/domain/sap10_calculator/calculator.py +++ b/domain/sap10_calculator/calculator.py @@ -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` diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 2f4eb570..db29fa0a 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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 diff --git a/domain/sap10_calculator/worksheet/energy_requirements.py b/domain/sap10_calculator/worksheet/energy_requirements.py index 44a15ed6..ccd8d738 100644 --- a/domain/sap10_calculator/worksheet/energy_requirements.py +++ b/domain/sap10_calculator/worksheet/energy_requirements.py @@ -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, ) diff --git a/tests/domain/sap10_calculator/worksheet/test_energy_requirements.py b/tests/domain/sap10_calculator/worksheet/test_energy_requirements.py index 0f63ec26..7bcd9400 100644 --- a/tests/domain/sap10_calculator/worksheet/test_energy_requirements.py +++ b/tests/domain/sap10_calculator/worksheet/test_energy_requirements.py @@ -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