mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
29c4b029e3
commit
5344bc8920
2 changed files with 384 additions and 0 deletions
145
domain/sap10_calculator/worksheet/photovoltaic.py
Normal file
145
domain/sap10_calculator/worksheet/photovoltaic.py
Normal 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),
|
||||
)
|
||||
239
domain/sap10_calculator/worksheet/tests/test_photovoltaic.py
Normal file
239
domain/sap10_calculator/worksheet/tests/test_photovoltaic.py
Normal 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)
|
||||
Loading…
Add table
Reference in a new issue