Appendix L slice 1: annual_lighting_kwh extraction

Surfaces the SAP10.2 Appendix L L1-L12 annual lighting kWh as a public
free fn alongside lighting_monthly_w. Refactors lighting_monthly_w to
compose it. One source of truth shared by the §5 gains side and the
forthcoming cost side (inputs.lighting_kwh_per_yr) — slice 2 wires
internal_gains_from_cert + cert_to_inputs.

Synthetic L1-L12 test pins a hand-computed dwelling
(TFA=100, N=2.0, C_L=10000, ε=100, D=1.0) at 189.152079 kWh, abs=1e-3.

6-fixture LINE_67 conformance tests (Elmhurst 000474..000516) act as a
regression check on the monthly cosine + 0.85 internal-fraction
composition — all green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-22 07:44:24 +00:00
parent 95086f957e
commit f4352587f7
2 changed files with 85 additions and 19 deletions

View file

@ -205,6 +205,47 @@ def appliances_monthly_w(
return tuple(monthly)
def annual_lighting_kwh(
*,
total_floor_area_m2: float,
n_occupants: float,
fixed_lighting_capacity_lm: float,
fixed_lighting_efficacy_lm_per_w: float,
daylight_factor: float,
) -> float:
"""SAP 10.2 Appendix L L1-L12 — annual lighting energy in kWh/yr.
The scalar leaf shared by the §5 gains side (composed into
`lighting_monthly_w` via the seasonal cosine modulation) and the
cost side (`inputs.lighting_kwh_per_yr`). Surfacing it via this
public free fn lets the certinputs precompute reuse the same
derivation that drives (67)m one source of truth.
See `lighting_monthly_w` for the per-kwarg semantics + RdSAP §12-1
lamp-type / L5b / L8c / L2a/L2b fallback rules.
"""
lambda_b = (
_LIGHTING_LAMBDA_B_COEFF
* (total_floor_area_m2 * n_occupants) ** _LIGHTING_LAMBDA_B_EXPONENT
)
lambda_req = (2.0 / 3.0) * lambda_b * daylight_factor
c_l_ref = _LIGHTING_C_L_REF_PER_M2 * total_floor_area_m2
lambda_prov = (
lambda_req * fixed_lighting_capacity_lm / c_l_ref if c_l_ref > 0 else 0.0
)
lambda_topup = max(0.0, lambda_req / 3.0 - lambda_prov)
e_l_fixed = (
max(lambda_req, lambda_prov) / fixed_lighting_efficacy_lm_per_w
if fixed_lighting_efficacy_lm_per_w > 0
else 0.0
)
e_l_topup = lambda_topup / _LIGHTING_TOPUP_EFFICACY_LM_PER_W
e_l_portable = (
(1.0 / 3.0) * lambda_b * daylight_factor / _LIGHTING_PORTABLE_EFFICACY_LM_PER_W
)
return e_l_fixed + e_l_topup + e_l_portable
def lighting_monthly_w(
*,
total_floor_area_m2: float,
@ -231,26 +272,13 @@ def lighting_monthly_w(
L12 reduced-gain branch (L12a, used for new-build DPER/TPER) is deferred.
"""
lambda_b = (
_LIGHTING_LAMBDA_B_COEFF
* (total_floor_area_m2 * n_occupants) ** _LIGHTING_LAMBDA_B_EXPONENT
e_l_annual_kwh = annual_lighting_kwh(
total_floor_area_m2=total_floor_area_m2,
n_occupants=n_occupants,
fixed_lighting_capacity_lm=fixed_lighting_capacity_lm,
fixed_lighting_efficacy_lm_per_w=fixed_lighting_efficacy_lm_per_w,
daylight_factor=daylight_factor,
)
lambda_req = (2.0 / 3.0) * lambda_b * daylight_factor
c_l_ref = _LIGHTING_C_L_REF_PER_M2 * total_floor_area_m2
lambda_prov = (
lambda_req * fixed_lighting_capacity_lm / c_l_ref if c_l_ref > 0 else 0.0
)
lambda_topup = max(0.0, lambda_req / 3.0 - lambda_prov)
e_l_fixed = (
max(lambda_req, lambda_prov) / fixed_lighting_efficacy_lm_per_w
if fixed_lighting_efficacy_lm_per_w > 0
else 0.0
)
e_l_topup = lambda_topup / _LIGHTING_TOPUP_EFFICACY_LM_PER_W
e_l_portable = (
(1.0 / 3.0) * lambda_b * daylight_factor / _LIGHTING_PORTABLE_EFFICACY_LM_PER_W
)
e_l_annual_kwh = e_l_fixed + e_l_topup + e_l_portable
monthly: list[float] = []
for m_idx, days in enumerate(_DAYS_PER_MONTH):
m = m_idx + 1

View file

@ -16,6 +16,7 @@ from domain.sap.worksheet.internal_gains import (
InternalGainsResult,
OvershadingCategory,
PumpDateCategory,
annual_lighting_kwh,
appliances_monthly_w,
balanced_mv_no_hr_fan_w,
central_heating_pump_w,
@ -231,6 +232,43 @@ def test_lighting_gains_match_appendix_l1_l12_for_000490() -> None:
assert actual == pytest.approx(exp, abs=5e-3), f"month {m+1}"
def test_annual_lighting_kwh_matches_hand_computed_appendix_l_cascade() -> None:
"""Synthetic L1-L12 cascade on a clean dwelling. Hand-derived via the
SAP 10.2 Appendix L formulas:
TFA=100 , N=2.0, C_L,fixed=10000 lm, ε_fixed=100 lm/W, D=1.0
λ_b = 11.2 × 59.73 × (200)^0.4714 = 8130.477969
λ_req = (2/3) × λ_b × D = 5420.318646
C_L_ref = 330 × TFA = 33000.0
λ_prov = λ_req × C_L_fixed / C_L_ref = 1642.520802
λ_topup = max(0, λ_req/3 - λ_prov) = 164.252080
E_L_fixed = max(λ_req, λ_prov) / ε_fixed = 54.203186
E_L_topup = λ_topup / 21.3 = 7.711365
E_L_portable = (1/3) × λ_b × D / 21.3 = 127.237527
e_l_annual_kwh = 189.152079
Pins the new public leaf `annual_lighting_kwh` directly so the cost
side (`inputs.lighting_kwh_per_yr`) and the gains side (§5 (67) via
`lighting_monthly_w`) share one source of truth.
"""
# Arrange
expected_kwh = 189.152079
# Act
actual_kwh = annual_lighting_kwh(
total_floor_area_m2=100.0,
n_occupants=2.0,
fixed_lighting_capacity_lm=10000.0,
fixed_lighting_efficacy_lm_per_w=100.0,
daylight_factor=1.0,
)
# Assert
assert actual_kwh == pytest.approx(expected_kwh, abs=1e-3)
def test_pumps_fans_seasonal_mask_zeroes_summer_months_jun_to_sep() -> None:
"""SAP10.2 Table 5a footnote a/b: pumps and warm-air fans are "Set to
zero in summer months". Year-round contributions (PIV, balanced MV