diff --git a/packages/domain/src/domain/sap/worksheet/internal_gains.py b/packages/domain/src/domain/sap/worksheet/internal_gains.py index d93f6584..d26eff8d 100644 --- a/packages/domain/src/domain/sap/worksheet/internal_gains.py +++ b/packages/domain/src/domain/sap/worksheet/internal_gains.py @@ -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, ...], diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_internal_gains.py b/packages/domain/src/domain/sap/worksheet/tests/test_internal_gains.py index a4a4aafb..487c9e33 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/test_internal_gains.py +++ b/packages/domain/src/domain/sap/worksheet/tests/test_internal_gains.py @@ -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 m² + SE 5.52 m² + NW 2.70 m² = 9.03 m² 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}"