From f4352587f72f9d8d860f3e3b19844c00025cc383 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 22 May 2026 07:44:24 +0000 Subject: [PATCH] Appendix L slice 1: annual_lighting_kwh extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../domain/sap/worksheet/internal_gains.py | 66 +++++++++++++------ .../worksheet/tests/test_internal_gains.py | 38 +++++++++++ 2 files changed, 85 insertions(+), 19 deletions(-) diff --git a/packages/domain/src/domain/sap/worksheet/internal_gains.py b/packages/domain/src/domain/sap/worksheet/internal_gains.py index 3261f6c9..f4ecc562 100644 --- a/packages/domain/src/domain/sap/worksheet/internal_gains.py +++ b/packages/domain/src/domain/sap/worksheet/internal_gains.py @@ -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 cert→inputs 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 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 f819652d..c79307d1 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 @@ -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 m², 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