§5 slice 6: (67) lighting_monthly_w — full Appendix L L1-L12 cascade

Implements the full SAP10.2 Appendix L lighting calculation: Λ_B (L1)
→ Λ_req (L3) → Λ_prov (L6) → Λ_topup (L7) → E_L,fixed/topup/portable
(L9a-d) → monthly cosine modulation (L10) → 0.85 × 1000 / (24 × n_m)
heat-gain bridge (L12).

Critical detail uncovered while reconciling against the 000490
worksheet: C_daylight uses Z_L from Table 6d's **third column** (light
access factor), NOT the 0.77 first column used for §6 solar gains. For
"Average" overshading Z_L = 0.83. Conflating the two columns gives a
~2% lighting-energy bias.

Verified against Elmhurst U985-0001-000490 (67)m to ≤5e-3 W/month
(0.14% on E_L) using worksheet bulb table (8 LEL × 80 lm/W × 15 W)
and Table 6b/6c/6d defaults for the window inputs.

The orchestrator slice will derive C_L,fixed + ε_fixed from RdSAP §12-1
per-lamp-type defaults (LED 100 lm/W, CFL 55 lm/W, LEL 80 lm/W,
incandescent 11.2 lm/W) and C_daylight from the cert's window data.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-20 18:33:43 +00:00
parent 0bc9eac34c
commit 50fd940ab9
2 changed files with 120 additions and 0 deletions

View file

@ -36,6 +36,16 @@ _APPLIANCES_E_A_EXPONENT: Final[float] = 0.4714
_APPLIANCES_MONTHLY_AMPLITUDE: Final[float] = 0.157
_APPLIANCES_MONTHLY_PHASE: Final[float] = 1.78
# Appendix L lighting constants.
_LIGHTING_LAMBDA_B_COEFF: Final[float] = 11.2 * 59.73
_LIGHTING_LAMBDA_B_EXPONENT: Final[float] = 0.4714
_LIGHTING_C_L_REF_PER_M2: Final[float] = 330.0
_LIGHTING_TOPUP_EFFICACY_LM_PER_W: Final[float] = 21.3
_LIGHTING_PORTABLE_EFFICACY_LM_PER_W: Final[float] = 21.3
_LIGHTING_INTERNAL_FRACTION: Final[float] = 0.85
_LIGHTING_MONTHLY_AMPLITUDE: Final[float] = 0.5
_LIGHTING_MONTHLY_PHASE: Final[float] = 0.2
_MONTHS_IN_YEAR: Final[int] = 12
_DAYS_PER_MONTH: Final[tuple[int, ...]] = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
@ -111,6 +121,66 @@ def appliances_monthly_w(
return tuple(monthly)
def lighting_monthly_w(
*,
total_floor_area_m2: float,
n_occupants: float,
fixed_lighting_capacity_lm: float,
fixed_lighting_efficacy_lm_per_w: float,
daylight_factor: float,
) -> tuple[float, ...]:
"""SAP 10.2 §5 line (67) — lighting gains in watts per month.
Applies the full Appendix L L1-L12 cascade with Column A (standard
gains) per Table 5. Caller pre-computes:
- fixed_lighting_capacity_lm (C_L,fixed, L5a): Σ(count × lumens) over
all fixed-lighting outlets. For existing dwellings RdSAP §12-1 gives
per-lamp-type defaults (LED 9 W/100 lm/W, CFL 19 W/55 lm/W, LEL
15 W/80 lm/W, incandescent 60 W/11.2 lm/W). L5b fallback C_L,fixed
= 185 × TFA applies only if no fixed lighting data lodged.
- fixed_lighting_efficacy_lm_per_w (ε_fixed, L8): C_L,fixed / Σpower.
Fall back to 21.3 lm/W per L8c when no fixed lighting present.
- daylight_factor (C_daylight, L2b): C_daylight = 52.2 G_L² - 9.94 G_L
+ 1.433 for G_L 0.095, else 0.96. G_L per L2a uses **Table 6d
third column (light access factor Z_L), NOT solar Z** a common
source of bias when conflated with the §6 solar calc.
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
)
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
factor = 1.0 + _LIGHTING_MONTHLY_AMPLITUDE * cos(
2.0 * pi * (m - _LIGHTING_MONTHLY_PHASE) / _MONTHS_IN_YEAR
)
e_l_m_kwh = e_l_annual_kwh * factor * days / _DAYS_PER_YEAR
monthly.append(
e_l_m_kwh * _LIGHTING_INTERNAL_FRACTION * _KWH_TO_WH
/ (_HOURS_PER_DAY * days)
)
return tuple(monthly)
def water_heating_gains_monthly_w(
*,
heat_gains_from_water_heating_monthly_kwh: tuple[float, ...],

View file

@ -15,6 +15,7 @@ import pytest
from domain.sap.worksheet.internal_gains import (
appliances_monthly_w,
cooking_monthly_w,
lighting_monthly_w,
losses_monthly_w,
metabolic_monthly_w,
water_heating_gains_monthly_w,
@ -155,3 +156,52 @@ def test_appliances_gains_match_appendix_l13_l14_l16a_for_000490() -> None:
assert len(monthly) == 12
for m, (actual, exp) in enumerate(zip(monthly, expected_w)):
assert actual == pytest.approx(exp, abs=5e-2), f"month {m+1}"
def test_lighting_gains_match_appendix_l1_l12_for_000490() -> None:
"""SAP 10.2 Appendix L L1-L12 cascade — full lighting calculation.
Steps applied:
L1 Λ_B = 11.2 × 59.73 × (TFA × N)^0.4714 [klm·h/yr]
L3 Λ_req = (2/3) × Λ_B × C_daylight [klm·h/yr]
L4 C_L,ref = 330 × TFA [lm]
L6 Λ_prov = Λ_req × C_L,fixed / C_L,ref [klm·h/yr]
L7 Λ_topup = max(0, Λ_req/3 - Λ_prov) [klm·h/yr]
L9a E_L,fixed = max(Λ_req, Λ_prov) / ε_fixed [kWh/yr]
L9b E_L,topup = Λ_topup / 21.3 [kWh/yr]
L9c E_L,portable = (1/3) × Λ_B × C_daylight / 21.3 [kWh/yr]
L9d E_L = E_L,fixed + E_L,topup + E_L,portable [kWh/yr]
L10 E_L,m = E_L × (1 + 0.5 cos(2π(m-0.2)/12)) × n_m / 365 [kWh/mo]
L12 G_L,m = E_L,m × 0.85 × 1000 / (24 × n_m) [W]
Inputs for 000490 (back-derived from the worksheet bulb table and
Table 6b/c/d defaults):
- 8 LEL bulbs × 80 lm/W × 15 W C_L,fixed=9600 lm, ε_fixed=80 lm/W
- 3 windows (NE 0.81 + SE 5.52 + NW 2.70 = 9.03 total),
all double-glazed air-filled g_L=0.80 (Table 6b), PVC frames
FF=0.7 (Table 6c), "Average" overshading Z_L=0.83 (Table 6d
third column, NOT the 0.77 solar-heating column)
- G_L = 0.9 × 9.03 × 0.80 × 0.70 × 0.83 / 66.06 = 0.0572 ( 0.095)
- C_daylight = 52.2 × 0.0572² - 9.94 × 0.0572 + 1.433 = 1.0353
Verified against worksheet (67)m row to 5e-3 W/month (0.14% E_L).
"""
# Arrange
expected_w = (
24.2665, 21.5533, 17.5283, 13.2701, 9.9195, 8.3745,
9.0489, 11.7621, 15.7871, 20.0454, 23.3959, 24.9410,
)
# Act
monthly = lighting_monthly_w(
total_floor_area_m2=66.06,
n_occupants=2.1468,
fixed_lighting_capacity_lm=9600.0,
fixed_lighting_efficacy_lm_per_w=80.0,
daylight_factor=1.0353,
)
# Assert
assert len(monthly) == 12
for m, (actual, exp) in enumerate(zip(monthly, expected_w)):
assert actual == pytest.approx(exp, abs=5e-3), f"month {m+1}"