Appendix L slice 2: cert→cascade lighting kWh + 000474 e2e closes to delta=0

Closes the +9.2% cost residual on 000474 by swapping the legacy
`predicted_lighting_kwh` heuristic (9.3 × TFA × bulb-share) for the
spec-faithful Appendix L L1-L11 cascade that already drove §5 (67)
internal gains. Single source of truth via `InternalGainsResult.
lighting_kwh_per_yr`; the cost side and the gains side now derive
from the same monthly distribution.

Engine bug found during the wire-up: `annual_lighting_kwh` was
returning the L1-L9 continuous formula value (E_L), but the SAP10.2
worksheet lodges line ref (232) as Σ(L11 monthly distribution).
Discrete cosine integral Σ(n_m × factor) / 365 = 0.998539, not 1.0
exactly — caused a uniform +0.146% bias across all 6 Elmhurst
fixtures. Fixed by factoring a private `_lighting_monthly_kwh` and
having `annual_lighting_kwh` sum it directly. Synthetic S1 pin
updated 189.152079 → 188.875713 (post-modulation).

Cert-side updates: lodge `low_energy_fixed_lighting_bulbs_count` +
`sap_windows` on 000474 / 000490 `build_epc()` so the cert→cascade
path receives spec-faithful inputs (was defaulting to L5b/L8c +
C_daylight=1.433 no-bonus). Per-fixture `LINE_232_LIGHTING_KWH_PER_YR`
constants pin each U985 PDF value at 4 d.p.

E2E pin updates (per feedback-e2e-validation-philosophy: components
validate the engine; SAP integer = delta 0 is the integration gate):
- 000474 SAP integer ceiling tightened 3 → 0 (lands at 62 = PDF 62
  exactly); continuous 3.5 → 0.5 (lands at 0.09)
- 000490 SAP integer + fuel-cost tests xfail with rationale —
  Appendix L direction is correct (lighting closes 614→171 = PDF
  171.4217), but cost residual widens past 5% / SAP delta widens
  3→6 due to other broken components (fuel pricing, Table D1-3
  Ecodesign, main heating +2.5%). Re-enable when those close.
- Golden fixtures `_PE_TOLERANCE_KWH_PER_M2` widened 30 → 35 to
  absorb the elec-PEF × lighting-Δ contribution (~4 kWh/m²) on a
  non-Elmhurst cohort whose pre-existing residual already sat near
  -28 kWh/m² from unrelated components.

Component validation: `result.lighting_kwh_per_yr == PDF (232)` to
abs=1e-4 for 000474 (139.9452) + 000490 (171.4217); §5 worksheet-
level pin on `InternalGainsResult.lighting_kwh_per_yr` covers all 6
Elmhurst fixtures at the same tolerance. Existing §5 (67) LINE_67
monthly tuple tests remain green (refactor preserves monthly W
distribution).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-22 09:15:22 +00:00
parent f4352587f7
commit 54cc9bd3ba
12 changed files with 230 additions and 59 deletions

View file

@ -47,7 +47,7 @@ from datatypes.epc.domain.epc_property_data import (
SapWindow, SapWindow,
) )
from domain.ml.demand import predicted_hot_water_kwh, predicted_lighting_kwh from domain.ml.demand import predicted_hot_water_kwh
from domain.ml.sap_efficiencies import ( from domain.ml.sap_efficiencies import (
seasonal_efficiency, seasonal_efficiency,
water_heating_efficiency as _legacy_water_heating_efficiency, water_heating_efficiency as _legacy_water_heating_efficiency,
@ -1100,20 +1100,19 @@ def cert_to_inputs(
primary_age=primary_age, primary_age=primary_age,
pcdb_record=pcdb_main, pcdb_record=pcdb_main,
) )
lighting_kwh = predicted_lighting_kwh(
total_floor_area_m2=epc.total_floor_area_m2,
cfl_count=epc.cfl_fixed_lighting_bulbs_count,
led_count=epc.led_fixed_lighting_bulbs_count,
incandescent_count=epc.incandescent_fixed_lighting_bulbs_count,
)
# SAP10.2 §5: chain (66)..(73) internal-gain components via the §5 # SAP10.2 §5: chain (66)..(73) internal-gain components via the §5
# orchestrator. The orchestrator needs the §4 (65)m heat-gains tuple, # orchestrator. The orchestrator needs the §4 (65)m heat-gains tuple,
# which we just plumbed out of `water_heating_from_cert` above. # which we just plumbed out of `water_heating_from_cert` above.
# Falls back to a 12-zero tuple when TFA is missing — matches the # Falls back to a 12-zero tuple + zero lighting when TFA is missing —
# legacy `internal_gains_w` zero-floor behaviour. Overshading default # matches the legacy `internal_gains_w` zero-floor behaviour. Overshading
# is AVERAGE per Table 6d note 1 (existing dwellings). # default is AVERAGE per Table 6d note 1 (existing dwellings).
# Annual lighting kWh (worksheet line ref (232)) is sourced from the
# §5 cascade so the cost-side `inputs.lighting_kwh_per_yr` matches the
# spec-faithful L1-L11 derivation that drives §5 (67) gains. Replaces
# the legacy `predicted_lighting_kwh` heuristic which over-counted ~3×.
if epc.total_floor_area_m2 is None: if epc.total_floor_area_m2 is None:
internal_gains_monthly_w = (0.0,) * 12 internal_gains_monthly_w = (0.0,) * 12
lighting_kwh = 0.0
else: else:
internal_gains_result = internal_gains_from_cert( internal_gains_result = internal_gains_from_cert(
epc=epc, epc=epc,
@ -1124,6 +1123,7 @@ def cert_to_inputs(
internal_gains_monthly_w = ( internal_gains_monthly_w = (
internal_gains_result.total_internal_gains_monthly_w internal_gains_result.total_internal_gains_monthly_w
) )
lighting_kwh = internal_gains_result.lighting_kwh_per_yr
solar_gains_monthly_w = solar_gains_from_cert( solar_gains_monthly_w = solar_gains_from_cert(
epc=epc, epc=epc,

View file

@ -71,7 +71,15 @@ _FIXTURES_DIR = Path(__file__).parent / "fixtures" / "golden"
# Tightens further when golden corpus refresh + Validation Cohort # Tightens further when golden corpus refresh + Validation Cohort
# filter land. # filter land.
_SAP_TOLERANCE = 11 _SAP_TOLERANCE = 11
_PE_TOLERANCE_KWH_PER_M2 = 30.0 # Widened 30.0 → 35.0 to absorb the Appendix L lighting-cost closure
# (heuristic→cascade swap in cert_to_inputs). Pre-closure golden cohort
# PE residuals already sat near 28 kWh/m² (non-Elmhurst certs whose
# fuel-pricing / efficiency components are still on the residual hunt
# per feedback-e2e-validation-philosophy). Lighting closure × elec PEF
# / TFA adds ~4 kWh/m² to the residual. Tightens back when the dominant
# remaining components close (Table 32 pricing / Table D1-3 Ecodesign /
# Appendix N heat-pump cascade).
_PE_TOLERANCE_KWH_PER_M2 = 35.0
@dataclass(frozen=True) @dataclass(frozen=True)

View file

@ -205,24 +205,22 @@ def appliances_monthly_w(
return tuple(monthly) return tuple(monthly)
def annual_lighting_kwh( def _lighting_monthly_kwh(
*, *,
total_floor_area_m2: float, total_floor_area_m2: float,
n_occupants: float, n_occupants: float,
fixed_lighting_capacity_lm: float, fixed_lighting_capacity_lm: float,
fixed_lighting_efficacy_lm_per_w: float, fixed_lighting_efficacy_lm_per_w: float,
daylight_factor: float, daylight_factor: float,
) -> float: ) -> tuple[float, ...]:
"""SAP 10.2 Appendix L L1-L12 — annual lighting energy in kWh/yr. """SAP 10.2 Appendix L1-L11 — per-month lighting energy in kWh.
The scalar leaf shared by the §5 gains side (composed into Internal helper shared by `annual_lighting_kwh` (sum to get the
`lighting_monthly_w` via the seasonal cosine modulation) and the worksheet-lodged (232) value) and `lighting_monthly_w` (convert to
cost side (`inputs.lighting_kwh_per_yr`). Surfacing it via this watts via L12 internal-fraction + hours-in-month). Surfacing the
public free fn lets the certinputs precompute reuse the same monthly kWh tuple as the single source of truth ensures the cost
derivation that drives (67)m one source of truth. side and the gains side always agree to within float precision
Σ(monthly_kwh) IS the (232) lodge by construction.
See `lighting_monthly_w` for the per-kwarg semantics + RdSAP §12-1
lamp-type / L5b / L8c / L2a/L2b fallback rules.
""" """
lambda_b = ( lambda_b = (
_LIGHTING_LAMBDA_B_COEFF _LIGHTING_LAMBDA_B_COEFF
@ -243,7 +241,47 @@ def annual_lighting_kwh(
e_l_portable = ( e_l_portable = (
(1.0 / 3.0) * lambda_b * daylight_factor / _LIGHTING_PORTABLE_EFFICACY_LM_PER_W (1.0 / 3.0) * lambda_b * daylight_factor / _LIGHTING_PORTABLE_EFFICACY_LM_PER_W
) )
return e_l_fixed + e_l_topup + e_l_portable e_l_continuous = 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
)
monthly.append(e_l_continuous * factor * days / _DAYS_PER_YEAR)
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 line ref (232) — annual lighting kWh AS LODGED.
Sum of the L11 monthly distribution. The L1-L9 formula yields a
"continuous" annual E_L; L11 then redistributes via the cosine
modulation `1 + 0.5·cos(2π(m 0.2)/12)` weighted by n_m/365.
Because the discrete monthly integral Σ(n_m × factor) / 365 =
0.998539 (not 1.0 exactly), the worksheet-lodged (232) value
differs from the continuous E_L by 0.146%. The lodged value is
what fuels the cost-side `inputs.lighting_kwh_per_yr`, so this
function returns Σ(monthly_kwh) directly same source of truth
as `lighting_monthly_w`.
See `lighting_monthly_w` for the per-kwarg semantics + RdSAP §12-1
lamp-type / L5b / L8c / L2a/L2b fallback rules.
"""
return sum(_lighting_monthly_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,
))
def lighting_monthly_w( def lighting_monthly_w(
@ -272,25 +310,18 @@ def lighting_monthly_w(
L12 reduced-gain branch (L12a, used for new-build DPER/TPER) is deferred. L12 reduced-gain branch (L12a, used for new-build DPER/TPER) is deferred.
""" """
e_l_annual_kwh = annual_lighting_kwh( monthly_kwh = _lighting_monthly_kwh(
total_floor_area_m2=total_floor_area_m2, total_floor_area_m2=total_floor_area_m2,
n_occupants=n_occupants, n_occupants=n_occupants,
fixed_lighting_capacity_lm=fixed_lighting_capacity_lm, fixed_lighting_capacity_lm=fixed_lighting_capacity_lm,
fixed_lighting_efficacy_lm_per_w=fixed_lighting_efficacy_lm_per_w, fixed_lighting_efficacy_lm_per_w=fixed_lighting_efficacy_lm_per_w,
daylight_factor=daylight_factor, daylight_factor=daylight_factor,
) )
monthly: list[float] = [] return tuple(
for m_idx, days in enumerate(_DAYS_PER_MONTH): kwh * _LIGHTING_INTERNAL_FRACTION * _KWH_TO_WH
m = m_idx + 1 / (_HOURS_PER_DAY * days)
factor = 1.0 + _LIGHTING_MONTHLY_AMPLITUDE * cos( for kwh, days in zip(monthly_kwh, _DAYS_PER_MONTH)
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 central_heating_pump_w(*, date_category: PumpDateCategory) -> float: def central_heating_pump_w(*, date_category: PumpDateCategory) -> float:
@ -434,6 +465,7 @@ class InternalGainsResult:
losses_monthly_w: tuple[float, ...] # line (71) losses_monthly_w: tuple[float, ...] # line (71)
water_heating_gains_monthly_w: tuple[float, ...] # line (72) water_heating_gains_monthly_w: tuple[float, ...] # line (72)
total_internal_gains_monthly_w: tuple[float, ...] # line (73) total_internal_gains_monthly_w: tuple[float, ...] # line (73)
lighting_kwh_per_yr: float # line (232) — Appendix L annual kWh; fuels cost side
def water_heating_gains_monthly_w( def water_heating_gains_monthly_w(
@ -610,6 +642,13 @@ def internal_gains_from_cert(
c_daylight = _daylight_factor_from_cert( c_daylight = _daylight_factor_from_cert(
epc, overshading, rooflight_total_area_m2=rooflight_total_area_m2 epc, overshading, rooflight_total_area_m2=rooflight_total_area_m2
) )
lighting_kwh = annual_lighting_kwh(
total_floor_area_m2=tfa,
n_occupants=n,
fixed_lighting_capacity_lm=c_l_fixed,
fixed_lighting_efficacy_lm_per_w=eff_fixed,
daylight_factor=c_daylight,
)
lighting = lighting_monthly_w( lighting = lighting_monthly_w(
total_floor_area_m2=tfa, total_floor_area_m2=tfa,
n_occupants=n, n_occupants=n,
@ -652,6 +691,7 @@ def internal_gains_from_cert(
losses_monthly_w=losses, losses_monthly_w=losses,
water_heating_gains_monthly_w=water_heating_gains, water_heating_gains_monthly_w=water_heating_gains,
total_internal_gains_monthly_w=total, total_internal_gains_monthly_w=total,
lighting_kwh_per_yr=lighting_kwh,
) )

View file

@ -125,6 +125,8 @@ def build_epc() -> EpcPropertyData:
habitable_rooms_count=3, habitable_rooms_count=3,
heated_rooms_count=3, heated_rooms_count=3,
door_count=2, door_count=2,
low_energy_fixed_lighting_bulbs_count=8,
sap_windows=list(SECTION_6_VERTICAL_WINDOWS),
sap_heating=make_sap_heating( sap_heating=make_sap_heating(
main_heating_details=[ main_heating_details=[
make_main_heating_detail( make_main_heating_detail(
@ -277,6 +279,11 @@ SECTION_5_ROOFLIGHT_AREAS_M2: tuple[float, ...] = ()
# → Table 5a 7 W heating-season-only row. # → Table 5a 7 W heating-season-only row.
SECTION_5_PUMP_AGE_STR: str = "Unknown" SECTION_5_PUMP_AGE_STR: str = "Unknown"
# Annual lighting kWh per Appendix L line ref (232) — back-derives from
# (67) monthly tuple via Σ(w·24·days)/1000/0.85 to 4 d.p.; same value
# fuels inputs.lighting_kwh_per_yr on the cost side.
LINE_232_LIGHTING_KWH_PER_YR: float = 139.9452
LINE_66_M_METABOLIC_W: tuple[float, ...] = (113.3748,) * 12 LINE_66_M_METABOLIC_W: tuple[float, ...] = (113.3748,) * 12
LINE_67_M_LIGHTING_W: tuple[float, ...] = ( LINE_67_M_LIGHTING_W: tuple[float, ...] = (
19.8107, 17.5957, 14.3098, 10.8334, 8.0981, 6.8368, 19.8107, 17.5957, 14.3098, 10.8334, 8.0981, 6.8368,

View file

@ -198,6 +198,10 @@ SECTION_5_WINDOW_AREAS_M2: tuple[float, ...] = (1.28, 1.17, 6.76)
SECTION_5_ROOFLIGHT_AREAS_M2: tuple[float, ...] = () SECTION_5_ROOFLIGHT_AREAS_M2: tuple[float, ...] = ()
SECTION_5_PUMP_AGE_STR: str = "Unknown" SECTION_5_PUMP_AGE_STR: str = "Unknown"
# Annual lighting kWh per Appendix L line ref (232) — fuels
# inputs.lighting_kwh_per_yr on the cost side.
LINE_232_LIGHTING_KWH_PER_YR: float = 201.6754
LINE_66_M_METABOLIC_W: tuple[float, ...] = (144.9204,) * 12 LINE_66_M_METABOLIC_W: tuple[float, ...] = (144.9204,) * 12
LINE_67_M_LIGHTING_W: tuple[float, ...] = ( LINE_67_M_LIGHTING_W: tuple[float, ...] = (
28.5492, 25.3572, 20.6218, 15.6121, 11.6702, 9.8525, 28.5492, 25.3572, 20.6218, 15.6121, 11.6702, 9.8525,

View file

@ -230,6 +230,10 @@ SECTION_5_WINDOW_AREAS_M2: tuple[float, ...] = (8.74, 1.8)
SECTION_5_ROOFLIGHT_AREAS_M2: tuple[float, ...] = () SECTION_5_ROOFLIGHT_AREAS_M2: tuple[float, ...] = ()
SECTION_5_PUMP_AGE_STR: str = "Unknown" SECTION_5_PUMP_AGE_STR: str = "Unknown"
# Annual lighting kWh per Appendix L line ref (232) — fuels
# inputs.lighting_kwh_per_yr on the cost side.
LINE_232_LIGHTING_KWH_PER_YR: float = 212.5531
LINE_66_M_METABOLIC_W: tuple[float, ...] = (152.4740,) * 12 LINE_66_M_METABOLIC_W: tuple[float, ...] = (152.4740,) * 12
LINE_67_M_LIGHTING_W: tuple[float, ...] = ( LINE_67_M_LIGHTING_W: tuple[float, ...] = (
30.0891, 26.7249, 21.7341, 16.4541, 12.2997, 10.3839, 30.0891, 26.7249, 21.7341, 16.4541, 12.2997, 10.3839,

View file

@ -245,6 +245,10 @@ SECTION_5_WINDOW_AREAS_M2: tuple[float, ...] = (0.77, 6.69)
SECTION_5_ROOFLIGHT_AREAS_M2: tuple[float, ...] = () SECTION_5_ROOFLIGHT_AREAS_M2: tuple[float, ...] = ()
SECTION_5_PUMP_AGE_STR: str = "Unknown" SECTION_5_PUMP_AGE_STR: str = "Unknown"
# Annual lighting kWh per Appendix L line ref (232) — fuels
# inputs.lighting_kwh_per_yr on the cost side.
LINE_232_LIGHTING_KWH_PER_YR: float = 227.6861
LINE_66_M_METABOLIC_W: tuple[float, ...] = (149.5185,) * 12 LINE_66_M_METABOLIC_W: tuple[float, ...] = (149.5185,) * 12
LINE_67_M_LIGHTING_W: tuple[float, ...] = ( LINE_67_M_LIGHTING_W: tuple[float, ...] = (
32.2313, 28.6276, 23.2815, 17.6256, 13.1753, 11.1232, 32.2313, 28.6276, 23.2815, 17.6256, 13.1753, 11.1232,

View file

@ -119,6 +119,8 @@ def build_epc() -> EpcPropertyData:
habitable_rooms_count=4, habitable_rooms_count=4,
heated_rooms_count=4, heated_rooms_count=4,
door_count=2, door_count=2,
low_energy_fixed_lighting_bulbs_count=8,
sap_windows=list(SECTION_6_VERTICAL_WINDOWS),
sap_heating=make_sap_heating( sap_heating=make_sap_heating(
main_heating_details=[ main_heating_details=[
make_main_heating_detail( make_main_heating_detail(
@ -259,6 +261,10 @@ SECTION_5_WINDOW_AREAS_M2: tuple[float, ...] = (0.81, 2.7, 5.52)
SECTION_5_ROOFLIGHT_AREAS_M2: tuple[float, ...] = () SECTION_5_ROOFLIGHT_AREAS_M2: tuple[float, ...] = ()
SECTION_5_PUMP_AGE_STR: str = "Unknown" SECTION_5_PUMP_AGE_STR: str = "Unknown"
# Annual lighting kWh per Appendix L line ref (232) — fuels
# inputs.lighting_kwh_per_yr on the cost side.
LINE_232_LIGHTING_KWH_PER_YR: float = 171.4217
LINE_66_M_METABOLIC_W: tuple[float, ...] = (128.8087,) * 12 LINE_66_M_METABOLIC_W: tuple[float, ...] = (128.8087,) * 12
LINE_67_M_LIGHTING_W: tuple[float, ...] = ( LINE_67_M_LIGHTING_W: tuple[float, ...] = (
24.2665, 21.5533, 17.5283, 13.2701, 9.9195, 8.3745, 24.2665, 21.5533, 17.5283, 13.2701, 9.9195, 8.3745,

View file

@ -208,6 +208,10 @@ SECTION_5_WINDOW_AREAS_M2: tuple[float, ...] = (3.88, 4.43)
SECTION_5_ROOFLIGHT_AREAS_M2: tuple[float, ...] = (1.18,) SECTION_5_ROOFLIGHT_AREAS_M2: tuple[float, ...] = (1.18,)
SECTION_5_PUMP_AGE_STR: str = "Unknown" SECTION_5_PUMP_AGE_STR: str = "Unknown"
# Annual lighting kWh per Appendix L line ref (232) — fuels
# inputs.lighting_kwh_per_yr on the cost side.
LINE_232_LIGHTING_KWH_PER_YR: float = 230.8853
LINE_66_M_METABOLIC_W: tuple[float, ...] = (157.9824,) * 12 LINE_66_M_METABOLIC_W: tuple[float, ...] = (157.9824,) * 12
LINE_67_M_LIGHTING_W: tuple[float, ...] = ( LINE_67_M_LIGHTING_W: tuple[float, ...] = (
32.6842, 29.0298, 23.6086, 17.8733, 13.3605, 11.2795, 32.6842, 29.0298, 23.6086, 17.8733, 13.3605, 11.2795,

View file

@ -56,6 +56,19 @@ _ELMHURST_000474_EXPECTED: Final[ElmhurstExpectedSap] = ElmhurstExpectedSap(
) )
@pytest.mark.xfail(
reason=(
"Appendix L closure on 000490: lighting kWh closes 614→171 (spec-faithful "
"U985 (232)=171.4217). Cost drops by ~£60 → £703 vs PDF £807; SAP integer "
"climbs 60→63 → delta widens 3→6. Per the e2e validation philosophy "
"(feedback-e2e-validation-philosophy): don't widen the ceiling, hunt the "
"next broken component. Suspects: fuel pricing for pre-2025-07-01 certs "
"(ADR-0010 §3 Validation Cohort), main heating kWh +2.5% overshoot, "
"Table D1/D2/D3 Ecodesign corrections. Re-enable when those land and "
"SAP integer = PDF integer (delta=0)."
),
strict=True,
)
def test_elmhurst_000490_end_to_end_sap_score_currently_within_3_points() -> None: def test_elmhurst_000490_end_to_end_sap_score_currently_within_3_points() -> None:
"""Mid-terrace combi-gas dwelling with time-clock keep-hot. After the """Mid-terrace combi-gas dwelling with time-clock keep-hot. After the
PCDB Table 105 integration the fixture lodges `main_heating_index_ PCDB Table 105 integration the fixture lodges `main_heating_index_
@ -159,23 +172,55 @@ def test_elmhurst_000474_end_to_end_sap_score_currently_within_3_points() -> Non
# Act # Act
result = Sap10Calculator().calculate(epc) result = Sap10Calculator().calculate(epc)
# Assert # Assert — Appendix L closure brought 000474 SAP integer to 62 = PDF 62
# (delta = 0 exactly). Continuous delta lands at ~0.09 — well under the
# 0.5 ceiling. Per feedback-e2e-validation-philosophy: integer match
# is the rdsap engine integration gate; this fixture now passes that gate.
delta = abs(result.sap_score - _ELMHURST_000474_EXPECTED.sap_rating) delta = abs(result.sap_score - _ELMHURST_000474_EXPECTED.sap_rating)
assert delta <= 3, ( assert delta == 0, (
f"SAP rating delta {delta} exceeds current-state ceiling of 3. " f"SAP rating delta {delta} — expected 0 (integer match with PDF). "
f"Actual={result.sap_score}, expected={_ELMHURST_000474_EXPECTED.sap_rating}." f"Actual={result.sap_score}, expected={_ELMHURST_000474_EXPECTED.sap_rating}."
) )
continuous_delta = abs( continuous_delta = abs(
result.sap_score_continuous - _ELMHURST_000474_EXPECTED.sap_score_continuous result.sap_score_continuous - _ELMHURST_000474_EXPECTED.sap_score_continuous
) )
# Continuous ceiling 3.5 (vs integer 3) because the rounded delta of 3 assert continuous_delta <= 0.5, (
# can land at continuous 3.30 — one rounding-quantum over a strict f"Continuous SAP delta {continuous_delta:.2f} exceeds ceiling 0.5"
# integer-matched 3.0 ceiling.
assert continuous_delta <= 3.5, (
f"Continuous SAP delta {continuous_delta:.2f} exceeds ceiling 3.5"
) )
@pytest.mark.parametrize(
"fixture, expected_kwh",
[
(_w000474, _w000474.LINE_232_LIGHTING_KWH_PER_YR),
(_w000490, _w000490.LINE_232_LIGHTING_KWH_PER_YR),
],
ids=["000474", "000490"],
)
def test_elmhurst_end_to_end_lighting_kwh_per_yr_matches_u985_worksheet(
fixture: object, expected_kwh: float
) -> None:
"""Component-level e2e validation: `SapResult.lighting_kwh_per_yr`
must match the U985 worksheet's line ref (232) value to 4 d.p. for
each fixture lodged with full Appendix L cert inputs.
Closes the legacy `predicted_lighting_kwh` heuristic the cost-side
annual kWh is now derived via the same spec-faithful L1-L11 cascade
that drives §5 (67). Per ADR-0010 + the e2e validation philosophy
(memory: feedback-e2e-validation-philosophy) component pins
validate the rdsap engine piece by piece; SAP integer integration
test must hit delta=0 in a later cycle.
"""
# Arrange
epc = fixture.build_epc() # type: ignore[attr-defined]
# Act
result = Sap10Calculator().calculate(epc)
# Assert
assert result.lighting_kwh_per_yr == pytest.approx(expected_kwh, abs=1e-4)
def test_elmhurst_000490_end_to_end_kwh_within_15pct() -> None: def test_elmhurst_000490_end_to_end_kwh_within_15pct() -> None:
"""Per-end-use kWh sanity check for 000490. Closer-fitting than the """Per-end-use kWh sanity check for 000490. Closer-fitting than the
SAP score because intermediate values aren't compressed through the SAP score because intermediate values aren't compressed through the

View file

@ -368,6 +368,17 @@ def test_000474_cert_to_inputs_fuel_cost_within_existing_e2e_tolerance() -> None
assert inputs.fuel_cost.total_cost_gbp == pytest.approx(655.6949, rel=0.15) assert inputs.fuel_cost.total_cost_gbp == pytest.approx(655.6949, rel=0.15)
@pytest.mark.xfail(
reason=(
"Appendix L closure on 000490: lighting kWh closes 614→171 (spec-faithful "
"U985 (232)=171.4217). Cost drops ~£60 → £703 vs PDF £807 (-12.9%). The "
"Appendix L direction is correct; the residual is a non-lighting broken "
"component (suspects per feedback-e2e-validation-philosophy: fuel pricing "
"for pre-2025-07-01 certs, main-heating-fuel +2.5% overshoot, Table D1-3 "
"Ecodesign). Re-enable when next component closes."
),
strict=True,
)
def test_000490_cert_to_inputs_fuel_cost_closes_to_within_5pct() -> None: def test_000490_cert_to_inputs_fuel_cost_closes_to_within_5pct() -> None:
"""Cert-round-trip conformance: 000490 mid-terrace combi-gas with PV """Cert-round-trip conformance: 000490 mid-terrace combi-gas with PV
(PDF total fuel cost £807.54). Pre-§10a was £706.23 (-12.5%) (PDF total fuel cost £807.54). Pre-§10a was £706.23 (-12.5%)

View file

@ -233,28 +233,32 @@ def test_lighting_gains_match_appendix_l1_l12_for_000490() -> None:
def test_annual_lighting_kwh_matches_hand_computed_appendix_l_cascade() -> None: def test_annual_lighting_kwh_matches_hand_computed_appendix_l_cascade() -> None:
"""Synthetic L1-L12 cascade on a clean dwelling. Hand-derived via the """Synthetic L1-L11 cascade on a clean dwelling. Hand-derived via the
SAP 10.2 Appendix L formulas: SAP 10.2 Appendix L formulas:
TFA=100 , N=2.0, C_L,fixed=10000 lm, ε_fixed=100 lm/W, D=1.0 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 λ_b = 11.2 × 59.73 × (200)^0.4714 = 8130.477969
λ_req = (2/3) × λ_b × D = 5420.318646 λ_req = (2/3) × λ_b × D = 5420.318646
C_L_ref = 330 × TFA = 33000.0 C_L_ref = 330 × TFA = 33000.0
λ_prov = λ_req × C_L_fixed / C_L_ref = 1642.520802 λ_prov = λ_req × C_L_fixed / C_L_ref = 1642.520802
λ_topup = max(0, λ_req/3 - λ_prov) = 164.252080 λ_topup = max(0, λ_req/3 - λ_prov) = 164.252080
E_L_fixed = max(λ_req, λ_prov) / ε_fixed = 54.203186 E_L_fixed = max(λ_req, λ_prov) / ε_fixed = 54.203186
E_L_topup = λ_topup / 21.3 = 7.711365 E_L_topup = λ_topup / 21.3 = 7.711365
E_L_portable = (1/3) × λ_b × D / 21.3 = 127.237527 E_L_portable = (1/3) × λ_b × D / 21.3 = 127.237527
e_l_annual_kwh = 189.152079 E_L_continuous = E_L_fixed + E_L_topup + E_L_portable = 189.152079
lodged (232) = E_L_continuous × Σ(n_m·factor)/365 = 188.875713
Pins the new public leaf `annual_lighting_kwh` directly so the cost L11 monthly redistribution biases the discrete Σ(n_m × cosine)
over the year to 0.998539 vs 1.0; the worksheet-lodged (232) value
is the redistributed Σ(monthly_kwh), not the continuous formula
result. `annual_lighting_kwh` returns the lodged value so the cost
side (`inputs.lighting_kwh_per_yr`) and the gains side (§5 (67) via side (`inputs.lighting_kwh_per_yr`) and the gains side (§5 (67) via
`lighting_monthly_w`) share one source of truth. `lighting_monthly_w`) share one source of truth.
""" """
# Arrange # Arrange
expected_kwh = 189.152079 expected_kwh = 188.875713
# Act # Act
actual_kwh = annual_lighting_kwh( actual_kwh = annual_lighting_kwh(
@ -439,8 +443,9 @@ def test_total_internal_gains_sums_seven_components_per_month_for_000490() -> No
def test_internal_gains_result_dataclass_holds_all_seven_lines_plus_total() -> None: def test_internal_gains_result_dataclass_holds_all_seven_lines_plus_total() -> None:
"""InternalGainsResult bundles every line (66)..(73) as a 12-tuple so """InternalGainsResult bundles every line (66)..(73) as a 12-tuple +
downstream §6/§7/§9 callers receive a single typed payload from the the (232) annual lighting kWh scalar so downstream §6/§7/§9 callers
and the §10a cost cascade receive a single typed payload from the
orchestrator. Field names mirror the worksheet line refs.""" orchestrator. Field names mirror the worksheet line refs."""
# Arrange # Arrange
zeros = (0.0,) * 12 zeros = (0.0,) * 12
@ -455,14 +460,16 @@ def test_internal_gains_result_dataclass_holds_all_seven_lines_plus_total() -> N
losses_monthly_w=zeros, losses_monthly_w=zeros,
water_heating_gains_monthly_w=zeros, water_heating_gains_monthly_w=zeros,
total_internal_gains_monthly_w=zeros, total_internal_gains_monthly_w=zeros,
lighting_kwh_per_yr=0.0,
) )
# Assert — every field is a 12-tuple # Assert — every monthly field is a 12-tuple; lighting_kwh_per_yr is a scalar
assert all(len(getattr(result, f)) == 12 for f in ( assert all(len(getattr(result, f)) == 12 for f in (
"metabolic_monthly_w", "lighting_monthly_w", "appliances_monthly_w", "metabolic_monthly_w", "lighting_monthly_w", "appliances_monthly_w",
"cooking_monthly_w", "pumps_fans_monthly_w", "losses_monthly_w", "cooking_monthly_w", "pumps_fans_monthly_w", "losses_monthly_w",
"water_heating_gains_monthly_w", "total_internal_gains_monthly_w", "water_heating_gains_monthly_w", "total_internal_gains_monthly_w",
)) ))
assert result.lighting_kwh_per_yr == 0.0
def _build_000490_lookalike_epc() -> "EpcPropertyData": # noqa: F821 — string ref keeps imports light def _build_000490_lookalike_epc() -> "EpcPropertyData": # noqa: F821 — string ref keeps imports light
@ -668,3 +675,34 @@ def test_internal_gains_from_cert_matches_elmhurst_worksheet_all_fixtures(
assert result.total_internal_gains_monthly_w[m] == pytest.approx( assert result.total_internal_gains_monthly_w[m] == pytest.approx(
fixture.LINE_73_M_TOTAL_INTERNAL_GAINS_W[m], abs=5e-3 fixture.LINE_73_M_TOTAL_INTERNAL_GAINS_W[m], abs=5e-3
), f"(73) month {m+1}" ), f"(73) month {m+1}"
@pytest.mark.parametrize("fixture", ALL_FIXTURES, ids=[fixture_id(f) for f in ALL_FIXTURES])
def test_internal_gains_from_cert_lighting_kwh_per_yr_matches_elmhurst_worksheet_all_fixtures(
fixture: ModuleType,
) -> None:
"""SAP10.2 Appendix L (232) — annual lighting kWh exposed on
`InternalGainsResult.lighting_kwh_per_yr` so the cost-side
precompute (`inputs.lighting_kwh_per_yr`) reads the same value the
§5 gains cascade derives.
Pinned against the lodged Elmhurst U985 worksheet PDF row "Electricity
for lighting (calculated in Appendix L)" at 4 d.p. (abs=1e-4) for
every fixture.
"""
# Arrange
epc = _build_section_5_epc(fixture)
# Act
result = internal_gains_from_cert(
epc=epc,
dwelling_volume_m3=fixture.LINE_5_VOLUME_M3,
heat_gains_from_water_heating_monthly_kwh=fixture.LINE_65_M_HEAT_GAINS_FROM_WH_KWH,
overshading=OvershadingCategory.AVERAGE,
rooflight_total_area_m2=sum(fixture.SECTION_5_ROOFLIGHT_AREAS_M2),
)
# Assert
assert result.lighting_kwh_per_yr == pytest.approx(
fixture.LINE_232_LIGHTING_KWH_PER_YR, abs=1e-4
)