diff --git a/packages/domain/src/domain/sap/worksheet/energy_requirements.py b/packages/domain/src/domain/sap/worksheet/energy_requirements.py new file mode 100644 index 00000000..44a15ed6 --- /dev/null +++ b/packages/domain/src/domain/sap/worksheet/energy_requirements.py @@ -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, + ) diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_energy_requirements.py b/packages/domain/src/domain/sap/worksheet/tests/test_energy_requirements.py new file mode 100644 index 00000000..b765adcf --- /dev/null +++ b/packages/domain/src/domain/sap/worksheet/tests/test_energy_requirements.py @@ -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