Slice S0380.44: SAP 10.2 Appendix M1 §3-4 PV β-factor calculator (no wiring)

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-28 18:11:56 +00:00
parent 29c4b029e3
commit 5344bc8920
2 changed files with 384 additions and 0 deletions

View file

@ -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),
)

View file

@ -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)