From 5344bc89200ed7e791ece60cd1b4fdebd3baa6b9 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 28 May 2026 18:11:56 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.44:=20SAP=2010.2=20Appendix=20M1?= =?UTF-8?q?=20=C2=A73-4=20PV=20=CE=B2-factor=20calculator=20(no=20wiring)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure-function module + 13 unit tests for the photovoltaic onsite/export split. No cascade wiring yet — Slices S0380.45..47 will wire β into the PE / CO2 / cost cascades respectively (which currently all over-credit the exported PV portion at the IMPORT factor). Module: `domain/sap10_calculator/worksheet/photovoltaic.py` - `PhotovoltaicSplit` frozen dataclass — monthly β + (E_PV,dw,m, E_PV,ex,m) with annual-sum properties matching worksheet line refs (233a) and (233b). - `pv_beta_coefficients(Cbat)` — three coefficients keyed on battery capacity (kWh), capped at 15 per §3c: CPV1 = 1.610 - 0.0973 × Cbat CPV2 = 0.415 - 0.00776 × Cbat CPV3 = 0.511 + 0.0866 × Cbat - `pv_split_monthly(epv, dpv, battery_kwh)` — per §3d-4: R_PV,m = E_PV,m / D_PV,m β_m = min(exp(-CPV1 × (R_PV,m × CPV2)^CPV3), D_PV,m / E_PV,m) E_PV,dw,m = E_PV,m × β_m; E_PV,ex,m = E_PV,m × (1 - β_m) Edge cases (not in spec but implied by physics): - E_PV,m = 0 → β = 0; both onsite and exported = 0 - D_PV,m = 0 → cap forces β = 0; all PV exports Unit-test coverage (13 tests, AAA convention, `abs(diff) <= tol`): - β coefficient constants at Cbat=0, 5 (ASHP cohort), 15 (cap) - Cbat>15 clamps to 15; Cbat<0 clamps to 0 (defensive) - Hand-computed β worked example (no battery): β≈0.4864 at E_PV=100, D_PV=200 — pinned at 1e-7 against precomputed value AND at 1e-9 against the live formula recomputation (load-bearing math pin) - Edge cases: E_PV=0 → no split; D_PV=0 → full export - Battery monotonicity: β increases with Cbat for fixed (E_PV, D_PV) - Energy conservation: E_PV,dw + E_PV,ex = E_PV per month + annually - Tuple length validation (raises on != 12 months) - Return shape pinned to `PhotovoltaicSplit` dataclass contract Test suite: 750 → 763 pass + 0 fail. Pyright net-zero on new files. Spec citation: SAP 10.2 specification Appendix M1 §3-4 (p.93-94). Co-Authored-By: Claude Opus 4.7 --- .../worksheet/photovoltaic.py | 145 +++++++++++ .../worksheet/tests/test_photovoltaic.py | 239 ++++++++++++++++++ 2 files changed, 384 insertions(+) create mode 100644 domain/sap10_calculator/worksheet/photovoltaic.py create mode 100644 domain/sap10_calculator/worksheet/tests/test_photovoltaic.py diff --git a/domain/sap10_calculator/worksheet/photovoltaic.py b/domain/sap10_calculator/worksheet/photovoltaic.py new file mode 100644 index 00000000..8e91384e --- /dev/null +++ b/domain/sap10_calculator/worksheet/photovoltaic.py @@ -0,0 +1,145 @@ +"""SAP 10.2 Appendix M1 §3-4 — PV onsite/export split via the β factor. + +Photovoltaic generation E_PV,m (kWh/month) is split between energy used +within the dwelling (E_PV,dw,m) and energy exported to the grid +(E_PV,ex,m). The split depends on: + + - monthly PV supply E_PV,m (from Appendix M1 §2 — EPV = 0.8 × kWp × S × ZPV + apportioned to months by solar radiation) + - monthly PV-eligible demand D_PV,m (§3a — lighting + appliances + cooking + + electric showers + pumps & fans + electric space/water heating where + applicable; assembled in the cascade) + - usable battery capacity (Cbat, kWh) — capped at 15 per §3c + +This module is a pure function over those three inputs and exposes the +monthly β factor + the resulting (E_PV,dw,m, E_PV,ex,m) split. +Appendix M1 §6 (cost), §7 (CO2), §8 (PE) all read the same split — the +onsite fraction takes the IMPORT factor (the dwelling would otherwise +have paid for / emitted at that rate), the exported fraction takes the +EXPORT factor (Table 12a "electricity sold to grid, PV"). + +Reference: SAP 10.2 specification Appendix M1 §3-4 (p.93-94). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from math import exp +from typing import Final + + +# SAP 10.2 Appendix M1 §3c — usable battery capacity is capped at 15 kWh. +_BATTERY_CAPACITY_CAP_KWH: Final[float] = 15.0 + + +@dataclass(frozen=True) +class PhotovoltaicSplit: + """SAP 10.2 Appendix M1 §3-4 result — monthly β + the resulting + onsite-consumed and exported PV kWh. + + All tuples are 12-element (Jan..Dec). Annual sums available via the + `*_kwh_per_yr` properties — same accumulation as the worksheet's + monthly-sum line refs (233a) and (233b).""" + + beta_monthly: tuple[float, ...] + epv_dwelling_monthly_kwh: tuple[float, ...] + epv_exported_monthly_kwh: tuple[float, ...] + + @property + def epv_dwelling_kwh_per_yr(self) -> float: + """Annual sum of E_PV,dw,m — worksheet line ref (233a).""" + return sum(self.epv_dwelling_monthly_kwh) + + @property + def epv_exported_kwh_per_yr(self) -> float: + """Annual sum of E_PV,ex,m — worksheet line ref (233b).""" + return sum(self.epv_exported_monthly_kwh) + + +def pv_beta_coefficients(battery_capacity_kwh: float) -> tuple[float, float, float]: + """SAP 10.2 Appendix M1 §3c — three β coefficients keyed on usable + battery capacity Cbat (kWh). Cbat is capped at 15 per spec; values + below 0 are clamped to 0. + + CPV1 = 1.610 - 0.0973 × Cbat + CPV2 = 0.415 - 0.00776 × Cbat + CPV3 = 0.511 + 0.0866 × Cbat + """ + cbat = min(max(0.0, battery_capacity_kwh), _BATTERY_CAPACITY_CAP_KWH) + cpv1 = 1.610 - 0.0973 * cbat + cpv2 = 0.415 - 0.00776 * cbat + cpv3 = 0.511 + 0.0866 * cbat + return (cpv1, cpv2, cpv3) + + +def _beta_for_month( + epv_m_kwh: float, + dpv_m_kwh: float, + cpv1: float, + cpv2: float, + cpv3: float, +) -> float: + """SAP 10.2 Appendix M1 §3d — β for one month. + + β_m = min(exp(-CPV1 × (R_PV,m × CPV2)^CPV3), D_PV,m / E_PV,m), + where R_PV,m = E_PV,m / D_PV,m. + + Edge cases (not in spec but implied by the physics): + - E_PV,m = 0: no PV to split; β is irrelevant (return 0). + - D_PV,m = 0: no eligible consumption to absorb PV — the D/E cap + forces β = 0, so all PV exports. + """ + if epv_m_kwh <= 0.0: + return 0.0 + if dpv_m_kwh <= 0.0: + return 0.0 + r_pv_m = epv_m_kwh / dpv_m_kwh + beta_formula = exp(-cpv1 * (r_pv_m * cpv2) ** cpv3) + beta_cap = dpv_m_kwh / epv_m_kwh + return min(beta_formula, beta_cap) + + +def pv_split_monthly( + epv_monthly_kwh: tuple[float, ...], + dpv_monthly_kwh: tuple[float, ...], + battery_capacity_kwh: float, +) -> PhotovoltaicSplit: + """SAP 10.2 Appendix M1 §3-4 (p.93-94) — split monthly PV generation + into onsite-consumed (E_PV,dw,m) and exported (E_PV,ex,m) portions. + + Inputs: + - epv_monthly_kwh: 12-tuple of monthly PV generation E_PV,m (kWh). + Apportioned from annual E_PV per §2 by monthly solar radiation. + - dpv_monthly_kwh: 12-tuple of monthly PV-eligible demand D_PV,m + (kWh) per §3a. Includes lighting + appliances + cooking + + electric showers + pumps & fans + electric space/water heating + where the fuel meets the §3a inclusion criteria. + - battery_capacity_kwh: usable battery capacity Cbat (kWh). 0 if + no battery; capped at 15 per §3c. + + Returns a PhotovoltaicSplit with monthly β + (E_PV,dw,m, E_PV,ex,m). + """ + if len(epv_monthly_kwh) != 12: + raise ValueError( + f"epv_monthly_kwh must be a 12-tuple, got length {len(epv_monthly_kwh)}" + ) + if len(dpv_monthly_kwh) != 12: + raise ValueError( + f"dpv_monthly_kwh must be a 12-tuple, got length {len(dpv_monthly_kwh)}" + ) + cpv1, cpv2, cpv3 = pv_beta_coefficients(battery_capacity_kwh) + + betas: list[float] = [] + dwellings: list[float] = [] + exports: list[float] = [] + for epv_m, dpv_m in zip(epv_monthly_kwh, dpv_monthly_kwh): + beta_m = _beta_for_month(epv_m, dpv_m, cpv1, cpv2, cpv3) + betas.append(beta_m) + dwellings.append(epv_m * beta_m) + exports.append(epv_m * (1.0 - beta_m)) + + return PhotovoltaicSplit( + beta_monthly=tuple(betas), + epv_dwelling_monthly_kwh=tuple(dwellings), + epv_exported_monthly_kwh=tuple(exports), + ) diff --git a/domain/sap10_calculator/worksheet/tests/test_photovoltaic.py b/domain/sap10_calculator/worksheet/tests/test_photovoltaic.py new file mode 100644 index 00000000..999959e7 --- /dev/null +++ b/domain/sap10_calculator/worksheet/tests/test_photovoltaic.py @@ -0,0 +1,239 @@ +"""Unit tests for SAP 10.2 Appendix M1 §3-4 — PV onsite/export β-split. + +Reference: SAP 10.2 specification Appendix M1 §3c-3d / §4 (p.93-94). +Worked example for the no-battery case is hand-computed from the +spec formulas; cohort worksheet (233a)/(233b) pinning is deferred to +the integration slice that wires β into the cascade. +""" + +from __future__ import annotations + +from math import exp + +import pytest + +from domain.sap10_calculator.worksheet.photovoltaic import ( + PhotovoltaicSplit, + pv_beta_coefficients, + pv_split_monthly, +) + + +def test_beta_coefficients_no_battery_matches_spec_constants() -> None: + # Arrange — SAP 10.2 Appendix M1 §3c (p.94): Cbat=0 case. + # CPV1 = 1.610 - 0.0973 × 0 = 1.610 + # CPV2 = 0.415 - 0.00776 × 0 = 0.415 + # CPV3 = 0.511 + 0.0866 × 0 = 0.511 + + # Act + cpv1, cpv2, cpv3 = pv_beta_coefficients(0.0) + + # Assert + assert abs(cpv1 - 1.610) <= 1e-9 + assert abs(cpv2 - 0.415) <= 1e-9 + assert abs(cpv3 - 0.511) <= 1e-9 + + +def test_beta_coefficients_at_5kwh_battery_matches_spec_formula() -> None: + # Arrange — SAP 10.2 Appendix M1 §3c (p.94): Cbat=5 kWh — the + # typical ASHP cohort battery size. + # CPV1 = 1.610 - 0.0973 × 5 = 1.1235 + # CPV2 = 0.415 - 0.00776 × 5 = 0.3762 + # CPV3 = 0.511 + 0.0866 × 5 = 0.9440 + + # Act + cpv1, cpv2, cpv3 = pv_beta_coefficients(5.0) + + # Assert + assert abs(cpv1 - 1.1235) <= 1e-9 + assert abs(cpv2 - 0.3762) <= 1e-9 + assert abs(cpv3 - 0.9440) <= 1e-9 + + +def test_beta_coefficients_at_15kwh_battery_matches_spec_cap_values() -> None: + # Arrange — SAP 10.2 Appendix M1 §3c (p.94): Cbat capped at 15. + # CPV1 = 1.610 - 0.0973 × 15 = 0.1505 + # CPV2 = 0.415 - 0.00776 × 15 = 0.2986 + # CPV3 = 0.511 + 0.0866 × 15 = 1.8100 + + # Act + cpv1, cpv2, cpv3 = pv_beta_coefficients(15.0) + + # Assert — pin to 4 d.p. for clean spec-arithmetic check. + assert abs(cpv1 - 0.1505) <= 1e-9 + assert abs(cpv2 - 0.2986) <= 1e-9 + assert abs(cpv3 - 1.8100) <= 1e-9 + + +def test_beta_coefficients_above_cap_clamps_to_15kwh() -> None: + # Arrange — SAP 10.2 Appendix M1 §3c (p.94): "Cbat is the usable + # capacity of the battery in kWh, limited to a maximum value of + # 15kWh." Large lodgement → same coefficients as Cbat=15. + + # Act + cpv1_30, cpv2_30, cpv3_30 = pv_beta_coefficients(30.0) + cpv1_15, cpv2_15, cpv3_15 = pv_beta_coefficients(15.0) + + # Assert + assert abs(cpv1_30 - cpv1_15) <= 1e-9 + assert abs(cpv2_30 - cpv2_15) <= 1e-9 + assert abs(cpv3_30 - cpv3_15) <= 1e-9 + + +def test_beta_coefficients_negative_battery_clamps_to_zero() -> None: + # Arrange — defensive: spec doesn't define negative battery but + # implementation clamps to 0 so the coefficients land on the + # no-battery baseline rather than producing inverted-sign nonsense. + + # Act + cpv1, cpv2, cpv3 = pv_beta_coefficients(-1.0) + + # Assert — matches Cbat=0 spec constants. + assert abs(cpv1 - 1.610) <= 1e-9 + assert abs(cpv2 - 0.415) <= 1e-9 + assert abs(cpv3 - 0.511) <= 1e-9 + + +def test_pv_split_monthly_no_battery_hand_computed_worked_example() -> None: + # Arrange — SAP 10.2 Appendix M1 §3d (p.94) hand-computed worked + # example, since the spec doesn't include one for §3. + # Setup: Cbat=0 → CPV1=1.610, CPV2=0.415, CPV3=0.511. + # Single month with E_PV,m=100 kWh, D_PV,m=200 kWh (all other + # months zero — focus on the single month's β calculation). + # + # R_PV,m = E_PV,m / D_PV,m = 100/200 = 0.5 + # R_PV,m × CPV2 = 0.5 × 0.415 = 0.2075 + # β_formula = exp(-1.610 × (0.2075)^0.511) = 0.4863571 + # D_PV,m / E_PV,m = 2.0 (cap) + # β = min(0.4863571, 2.0) = 0.4863571 + # + # E_PV,dw,m = 100 × 0.4863571 = 48.63571 + # E_PV,ex,m = 100 × (1 - 0.4863571) = 51.36429 + epv = (100.0,) + (0.0,) * 11 + dpv = (200.0,) + (0.0,) * 11 + + # Act + result = pv_split_monthly(epv, dpv, battery_capacity_kwh=0.0) + + # Assert — pin β at 1e-7 against the precomputed worked value, and + # again at 1e-9 against the live formula recomputation (the latter + # is the load-bearing math pin; the former documents the constant). + assert abs(result.beta_monthly[0] - 0.4863571) <= 1e-7 + expected_beta = exp(-1.610 * (0.5 * 0.415) ** 0.511) + assert abs(result.beta_monthly[0] - expected_beta) <= 1e-9 + assert abs(result.epv_dwelling_monthly_kwh[0] - 100.0 * expected_beta) <= 1e-9 + assert abs(result.epv_exported_monthly_kwh[0] - 100.0 * (1.0 - expected_beta)) <= 1e-9 + + +def test_pv_split_monthly_zero_pv_generates_zero_split() -> None: + # Arrange — when E_PV,m=0 for every month, no PV → β indeterminate + # by spec but onsite/export both 0 (the implementation returns + # β=0 as a stable, documented edge-case value). + epv = (0.0,) * 12 + dpv = (200.0,) * 12 + + # Act + result = pv_split_monthly(epv, dpv, battery_capacity_kwh=5.0) + + # Assert + assert result.epv_dwelling_kwh_per_yr == 0.0 + assert result.epv_exported_kwh_per_yr == 0.0 + assert all(b == 0.0 for b in result.beta_monthly) + + +def test_pv_split_monthly_zero_demand_forces_full_export() -> None: + # Arrange — when D_PV,m=0 the cap D/E forces β=0; all PV exports. + # (Spec rule: "or β = D_PV,m / E_PV,m, whichever is lower".) + epv = (100.0,) * 12 + dpv = (0.0,) * 12 + + # Act + result = pv_split_monthly(epv, dpv, battery_capacity_kwh=0.0) + + # Assert + assert result.epv_dwelling_kwh_per_yr == 0.0 + assert abs(result.epv_exported_kwh_per_yr - 1200.0) <= 1e-9 + assert all(b == 0.0 for b in result.beta_monthly) + + +def test_pv_split_monthly_battery_increases_onsite_fraction() -> None: + # Arrange — for identical (E_PV, D_PV), a larger battery shifts more + # PV to onsite consumption (β closer to 1). Per SAP 10.2 Appendix + # M1 §3c the CPV1 coefficient drops with Cbat (1.610 → 0.151 at + # Cbat=15), making exp(-CPV1 × ...) larger → β larger. + epv = (100.0,) * 12 + dpv = (150.0,) * 12 + + # Act + result_no_battery = pv_split_monthly(epv, dpv, battery_capacity_kwh=0.0) + result_5kwh = pv_split_monthly(epv, dpv, battery_capacity_kwh=5.0) + result_15kwh = pv_split_monthly(epv, dpv, battery_capacity_kwh=15.0) + + # Assert + assert ( + result_no_battery.epv_dwelling_kwh_per_yr + < result_5kwh.epv_dwelling_kwh_per_yr + < result_15kwh.epv_dwelling_kwh_per_yr + ) + + +def test_pv_split_monthly_conserves_total_energy() -> None: + # Arrange — E_PV,dw,m + E_PV,ex,m must equal E_PV,m exactly for + # every month (energy conservation; no double-counting or loss). + epv = (50.0, 75.0, 120.0, 180.0, 240.0, 290.0, 300.0, 270.0, 200.0, 130.0, 70.0, 40.0) + dpv = (300.0, 280.0, 260.0, 230.0, 200.0, 180.0, 170.0, 175.0, 195.0, 220.0, 260.0, 290.0) + + # Act + result = pv_split_monthly(epv, dpv, battery_capacity_kwh=5.0) + + # Assert — per-month conservation pinned at 1e-12 (float ε). + for m in range(12): + total_m = ( + result.epv_dwelling_monthly_kwh[m] + + result.epv_exported_monthly_kwh[m] + ) + assert abs(total_m - epv[m]) <= 1e-12 + # Annual sum conservation pinned at 1e-9. + total_yr = ( + result.epv_dwelling_kwh_per_yr + result.epv_exported_kwh_per_yr + ) + assert abs(total_yr - sum(epv)) <= 1e-9 + + +def test_pv_split_monthly_rejects_wrong_length_epv_tuple() -> None: + # Arrange — implementation enforces the 12-month tuple shape so + # callers can't silently lose or duplicate months. + epv = (100.0,) * 11 # one month short + dpv = (200.0,) * 12 + + # Act / Assert + with pytest.raises(ValueError, match="epv_monthly_kwh.*12"): + pv_split_monthly(epv, dpv, battery_capacity_kwh=0.0) + + +def test_pv_split_monthly_rejects_wrong_length_dpv_tuple() -> None: + # Arrange + epv = (100.0,) * 12 + dpv = (200.0,) * 13 # one month extra + + # Act / Assert + with pytest.raises(ValueError, match="dpv_monthly_kwh.*12"): + pv_split_monthly(epv, dpv, battery_capacity_kwh=0.0) + + +def test_pv_split_monthly_returns_photovoltaic_split_dataclass() -> None: + # Arrange — result shape is a frozen dataclass with three 12-tuples + # and two annual-sum properties (worksheet line refs (233a)/(233b)). + epv = (100.0,) * 12 + dpv = (200.0,) * 12 + + # Act + result = pv_split_monthly(epv, dpv, battery_capacity_kwh=5.0) + + # Assert + assert isinstance(result, PhotovoltaicSplit) + assert len(result.beta_monthly) == 12 + assert len(result.epv_dwelling_monthly_kwh) == 12 + assert len(result.epv_exported_monthly_kwh) == 12 + assert result.epv_dwelling_kwh_per_yr == sum(result.epv_dwelling_monthly_kwh) + assert result.epv_exported_kwh_per_yr == sum(result.epv_exported_monthly_kwh)