Slice 19a: strict cascade-pin scoreboard for SapResult vs U985 PDFs

Replaces the loose collection of fixture-specific SAP score tests +
parametrized lighting / pumps_fans / secondary spot-checks with a
single strict cascade pin: every SapResult float field vs PDF line
ref at abs=1e-4, every fixture × field pair as its own parametrized
case. 66 cases (11 fields × 6 fixtures); 18 pass, 48 fail.

Why: the Elmhurst corpus is a deterministic test-vector set — input
lodgement, intermediate values per line ref, final SAP outputs all
known to 4 d.p. To replicate SAP 10.2 exactly there is no reason to
accept tolerance >0 on the final outputs. The prior pattern (per-
section unit tests using PDF values as INPUTS, fixture-specific SAP
tests at <=0.5 continuous, fuel-cost tests at rel=0.05 / rel=0.15)
let cascade biases propagate without surfacing as named failures.

Pin matrix:

  field                              | 474 | 477 | 480 | 487 | 490 | 516
  -----------------------------------|-----|-----|-----|-----|-----|-----
  sap_score (int)                    |  ✓  |  ✓  |  ✓  |  ✗  |  ✓  |  ✓
  sap_score_continuous               |  ✗  |  ✗  |  ✗  |  ✗  |  ✗  |  ✗
  ecf                                |  ✗  |  ✗  |  ✓  |  ✗  |  ✗  |  ✗
  total_fuel_cost_gbp                |  ✗  |  ✗  |  ✗  |  ✗  |  ✗  |  ✗
  co2_kg_per_yr                      |  ✗  |  ✗  |  ✗  |  ✗  |  ✗  |  ✗
  space_heating_kwh_per_yr           |  ✗  |  ✗  |  ✗  |  ✗  |  ✗  |  ✗
  main_heating_fuel_kwh_per_yr       |  ✗  |  ✗  |  ✗  |  ✗  |  ✗  |  ✗
  secondary_heating_fuel_kwh_per_yr  |  ✓  |  ✗  |  ✗  |  ✗  |  ✗  |  ✗
  hot_water_kwh_per_yr               |  ✗  |  ✗  |  ✗  |  ✗  |  ✗  |  ✗
  lighting_kwh_per_yr                |  ✓  |  ✓  |  ✓  |  ✓  |  ✓  |  ✗
  pumps_fans_kwh_per_yr              |  ✓  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓

Each failing test name is the work queue. No tolerance widening, no
xfail — a failing pin is a named calculator bug. Subsequent slices
close them one at a time.

Existing loose-tolerance tests in test_fuel_cost.py (rel=0.15 for
000474 and rel=0.05 for 000490) are subsumed by the new
total_fuel_cost_gbp pin at abs=1e-4 and will be removed in 19b.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-22 22:28:59 +00:00
parent 5e34594d8a
commit 6bfb0614aa

View file

@ -1,31 +1,38 @@
"""End-to-end SAP score validation against the Elmhurst worksheet outputs. """End-to-end cascade pins against the Elmhurst U985 worksheet PDFs.
For each non-RR Elmhurst fixture, run the full calculator chain The Elmhurst corpus is a deterministic test-vector set: each cert has
EpcPropertyData cert_to_inputs calculate_sap_from_inputs input lodgement (the Summary_NNNNNN PDF), intermediate values per
and compare the resulting SAP score against the Elmhurst worksheet's line ref (the U985-0001-NNNNNN PDF), and final SAP outputs (the
SAP rating (line 258). rating section). To replicate the SAP 10.2 engine exactly we pin
every SAP-result field against the PDF at `abs=1e-4` for every
fixture in the cohort.
These tests pin the current end-to-end gap so subsequent slices that Each pin is its own test case via pytest parametrize so failures are
shrink it (worksheet §4 wired into cert_to_inputs, PCDB Table 3b combi named like `test_sap_result_pin[000487-main_heating_fuel_kwh_per_yr]`
loss, etc.) show up as tolerance tightening rather than silent drift. making the work queue legible.
Reference: Elmhurst U985-0001-000474.pdf and U985-0001-000490.pdf Per `[[feedback-e2e-validation-philosophy]]` + `[[feedback-continuous-
(supplied by the user; not stored in repo). sap-tolerance]]`: tolerances are NOT widened to mask drift. A failing
pin is a named calculator bug to fix, not a tolerance to relax.
Reference: SAP 10.2 specification (14-03-2025).
""" """
from dataclasses import dataclass from dataclasses import dataclass, fields
from types import ModuleType
from typing import Final from typing import Final
import pytest import pytest
from types import ModuleType
from domain.sap.calculator import Sap10Calculator from domain.sap.calculator import Sap10Calculator
from domain.sap.rdsap.cert_to_inputs import cert_to_inputs from domain.sap.rdsap.cert_to_inputs import cert_to_inputs
from domain.sap.worksheet.tests import ( from domain.sap.worksheet.tests import (
_elmhurst_worksheet_000474 as _w000474, _elmhurst_worksheet_000474 as _w000474,
_elmhurst_worksheet_000477 as _w000477, _elmhurst_worksheet_000477 as _w000477,
_elmhurst_worksheet_000480 as _w000480,
_elmhurst_worksheet_000487 as _w000487,
_elmhurst_worksheet_000490 as _w000490, _elmhurst_worksheet_000490 as _w000490,
_elmhurst_worksheet_000516 as _w000516,
) )
from domain.sap.worksheet.tests._elmhurst_fixtures import ( from domain.sap.worksheet.tests._elmhurst_fixtures import (
ALL_FIXTURES as _ELMHURST_FIXTURES, ALL_FIXTURES as _ELMHURST_FIXTURES,
@ -33,301 +40,158 @@ from domain.sap.worksheet.tests._elmhurst_fixtures import (
) )
# Absolute tolerance for every float field — matches the PDF's 4 d.p.
# display precision. Anything closer is sub-spec resolution; anything
# looser is a drift gap.
_FLOAT_PIN_ABS: Final[float] = 1e-4
@dataclass(frozen=True) @dataclass(frozen=True)
class ElmhurstExpectedSap: class FixtureCascadePins:
"""Headline figures from the Elmhurst worksheet's SAP rating section """PDF-extracted expected values for every top-level `SapResult`
(xlsx rows around line refs 240 / 255 / 257 / 258 / 261).""" field. Each value is the canonical line ref from the U985 worksheet
(page references in the field comments). Field names mirror
sap_rating: int # (258) integer `SapResult` exactly so the parametrized test can read both via
sap_score_continuous: float # (258) un-rounded `getattr` without per-field plumbing.
space_heating_kwh: float # (98c) annual
hot_water_kwh: float # (219) annual fuel
total_energy_cost_gbp: float # (255)
ecf: float # (257)
_ELMHURST_000490_EXPECTED: Final[ElmhurstExpectedSap] = ElmhurstExpectedSap(
sap_rating=57,
sap_score_continuous=57.3979,
space_heating_kwh=11183.2751,
hot_water_kwh=2850.5701,
total_energy_cost_gbp=807.5421,
ecf=3.0539,
)
_ELMHURST_000477_EXPECTED: Final[ElmhurstExpectedSap] = ElmhurstExpectedSap(
sap_rating=65,
sap_score_continuous=65.0050,
space_heating_kwh=10111.2019,
hot_water_kwh=2116.0365,
total_energy_cost_gbp=732.1396,
ecf=2.5086,
)
_ELMHURST_000474_EXPECTED: Final[ElmhurstExpectedSap] = ElmhurstExpectedSap(
sap_rating=62,
sap_score_continuous=62.2584,
space_heating_kwh=10612.8595,
hot_water_kwh=2291.7784,
total_energy_cost_gbp=655.6949,
ecf=2.7055,
)
def test_elmhurst_000477_end_to_end_sap_score_matches_pdf() -> None:
"""Cohort closure pin for 000477. Mid-terrace combi-gas with PCDF
Vaillant ecoTEC sustain 24 (index 18118) + Electricity Electric
Panel secondary heater (SAP code 691). PDF SAP rating 65."""
# Arrange
epc = _w000477.build_epc()
# Act
result = Sap10Calculator().calculate(epc)
# Assert — integer match (the rdsap engine integration gate).
delta = abs(result.sap_score - _ELMHURST_000477_EXPECTED.sap_rating)
assert delta == 0, (
f"SAP rating delta {delta} — expected 0 (integer match with PDF). "
f"Actual={result.sap_score}, expected={_ELMHURST_000477_EXPECTED.sap_rating}."
)
continuous_delta = abs(
result.sap_score_continuous - _ELMHURST_000477_EXPECTED.sap_score_continuous
)
assert continuous_delta <= 0.5, (
f"Continuous SAP delta {continuous_delta:.2f} exceeds ceiling 0.5"
)
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
PCDB Table 105 integration the fixture lodges `main_heating_index_
number=10328` (Vaillant Ecotec Pro 28kW, winter eff 88.2%, summer
79.6%) per the PDF's "PCDF boiler reference: 10328 Vaillant Ecotec
Pro 88.20%" lodgement. Cert path now resolves efficiency via the
spec-faithful PCDB precedence rather than the Table 4a category-2
default (80%).
Post-PCDB residuals:
| metric | actual | PDF | delta |
| --------------- | -------- | --------- | ----- |
| space heating | 11467.18 | 11183.275 | +2.5% |
| hot water fuel | 3028.27 | 2850.570 | +6.2% |
| main heating fuel | 13001.3| 13003.85 | -0.02%|
| total fuel cost | £706.23 | £807.54 | 12.5%|
| SAP rating | 63 | 57 | +6 |
The PCDB efficiency override closes the main-heating-fuel gap to <0.1%
(matches PDF), and the HW kWh gap narrows from +8.4% +6.2%. The
total fuel cost diverges from -6.3% -12.5% and the SAP score moves
+3 +6 because the lodged cert pre-dates the 14-March-2025 SAP10.2
amendment which lowered gas unit prices ~13% (per ADR-0010 §3
Validation Cohort: only certs lodged 2025-07-01 are spec-comparable
on cost / SAP rating). The fuel-kWh tightening is the spec-faithful
direction; the cost / SAP residuals are documented spec-version
drift, not a calculator regression.
Ceiling raised 3 6 (SAP integer) and 3.0 6.0 (continuous) to
reflect the post-PCDB current state. **§10a slice 2 tightening:**
ceiling dropped 6 2 after the cost-side rewrite (Table 32 prices
+ Table 12 note (a) standing-charge gating per ADR-0010 amendment)
landed. The "spec-version drift" framing in the handover turned out
to be wrong-table + missing-standing-charges a real calculator
regression, not a corpus issue. **§4 HW slice 2 update:** ceiling
raised 2 3 because the Equation D1 monthly cascade closes the HW
kWh gap (3028 2847 = 0.1% of PDF 2851), which slightly *reduces*
cost (£776 £770) and pushes SAP score from 59 60 further
from the spec-version-drifted PDF SAP 57. The HW kWh closure is
the spec-faithful direction; the +3 SAP delta is the ADR-0010 §3
Validation Cohort filter at work. Tightens further when Tables
D1/D2/D3 Ecodesign + Appendix N adjustments land.
""" """
# Arrange
epc = _w000490.build_epc()
# Act sap_score: int # (258) integer rating
result = Sap10Calculator().calculate(epc) sap_score_continuous: float # (258) un-rounded
ecf: float # (257) energy cost factor
# Assert — full cohort residual hunt closed: Appendix L lighting + total_fuel_cost_gbp: float # (255) total energy cost
# secondary heating cascade + ventilation cert lodgements + Table 4f co2_kg_per_yr: float # (272) total CO2 emissions
# pumps_fans + SAP 10.2 rating constants. 000490 now hits SAP integer space_heating_kwh_per_yr: float # (98c) annual space heat
# delta=0 (continuous ~0.002 under PDF). main_heating_fuel_kwh_per_yr: float # (211) main system 1 fuel
delta = abs(result.sap_score - _ELMHURST_000490_EXPECTED.sap_rating) secondary_heating_fuel_kwh_per_yr: float # (215) secondary fuel
assert delta == 0, ( hot_water_kwh_per_yr: float # (219) water heating fuel
f"SAP rating delta {delta} — expected 0 (integer match with PDF). " lighting_kwh_per_yr: float # (232) electricity for lighting
f"Actual={result.sap_score}, expected={_ELMHURST_000490_EXPECTED.sap_rating}." pumps_fans_kwh_per_yr: float # (231) pumps + fans + flue
)
continuous_delta = abs(
result.sap_score_continuous - _ELMHURST_000490_EXPECTED.sap_score_continuous
)
assert continuous_delta <= 0.5, (
f"Continuous SAP delta {continuous_delta:.2f} exceeds ceiling 0.5"
)
def test_elmhurst_000474_end_to_end_sap_score_currently_within_3_points() -> None: _FIXTURE_PINS: Final[dict[str, FixtureCascadePins]] = {
"""End-terrace PCDB-tested Vaillant boiler. After the PCDB Table 105 "000474": FixtureCascadePins(
integration the fixture lodges `main_heating_index_number=16839` sap_score=62, sap_score_continuous=62.2584, ecf=2.7055,
(Vaillant ecoTEC pro 28 VUW GB 286/5-3, winter eff 88.7%, summer total_fuel_cost_gbp=655.6949, co2_kg_per_yr=3036.2933,
87.0%, comparative HW 75.1%) per the PDF's "PCDF boiler reference: space_heating_kwh_per_yr=10612.8595,
16839 Vaillant ecoTEC pro 28 88.70%" lodgement. main_heating_fuel_kwh_per_yr=11964.8924,
secondary_heating_fuel_kwh_per_yr=0.0,
hot_water_kwh_per_yr=2291.7784,
lighting_kwh_per_yr=139.9452,
pumps_fans_kwh_per_yr=160.0,
),
"000477": FixtureCascadePins(
sap_score=65, sap_score_continuous=65.0057, ecf=2.5086,
total_fuel_cost_gbp=732.1396, co2_kg_per_yr=2807.8621,
space_heating_kwh_per_yr=10111.2019,
main_heating_fuel_kwh_per_yr=10270.9726,
secondary_heating_fuel_kwh_per_yr=1011.1202,
hot_water_kwh_per_yr=2116.0365,
lighting_kwh_per_yr=201.6754,
pumps_fans_kwh_per_yr=160.0,
),
"000480": FixtureCascadePins(
sap_score=61, sap_score_continuous=61.2986, ecf=2.7743,
total_fuel_cost_gbp=854.8139, co2_kg_per_yr=3393.8852,
space_heating_kwh_per_yr=12398.5783,
main_heating_fuel_kwh_per_yr=12580.2936,
secondary_heating_fuel_kwh_per_yr=1239.8578,
hot_water_kwh_per_yr=2423.6393,
lighting_kwh_per_yr=212.5531,
pumps_fans_kwh_per_yr=160.0,
),
"000487": FixtureCascadePins(
sap_score=62, sap_score_continuous=61.6431, ecf=2.7496,
total_fuel_cost_gbp=828.6119, co2_kg_per_yr=2931.4900,
space_heating_kwh_per_yr=10834.7778,
main_heating_fuel_kwh_per_yr=11018.4181,
secondary_heating_fuel_kwh_per_yr=1083.4778,
hot_water_kwh_per_yr=1489.1033,
lighting_kwh_per_yr=227.6861,
pumps_fans_kwh_per_yr=160.0,
),
"000490": FixtureCascadePins(
sap_score=57, sap_score_continuous=57.3979, ecf=3.0539,
total_fuel_cost_gbp=807.5421, co2_kg_per_yr=3213.5359,
space_heating_kwh_per_yr=11183.2751,
main_heating_fuel_kwh_per_yr=11411.5052,
secondary_heating_fuel_kwh_per_yr=1118.3275,
hot_water_kwh_per_yr=2850.5701,
lighting_kwh_per_yr=171.4217,
pumps_fans_kwh_per_yr=160.0,
),
"000516": FixtureCascadePins(
sap_score=63, sap_score_continuous=62.7937, ecf=2.6671,
total_fuel_cost_gbp=860.7162, co2_kg_per_yr=3416.8449,
space_heating_kwh_per_yr=12410.3170,
main_heating_fuel_kwh_per_yr=12606.4169,
secondary_heating_fuel_kwh_per_yr=1241.0317,
hot_water_kwh_per_yr=2493.1900,
lighting_kwh_per_yr=230.8853,
pumps_fans_kwh_per_yr=160.0,
),
}
Post-PCDB residuals nearly closed:
| metric | actual | expected | delta | _FIXTURE_MODULES: Final[dict[str, ModuleType]] = {
| --------------- | ------- | -------- | ----- | "000474": _w000474,
| space heating | 10914.3 | 10612.86 | +2.8% | "000477": _w000477,
| hot water fuel | 2621.7 | 2291.78 | +14.4%| "000480": _w000480,
| total fuel cost | £651.85 | £655.69 | -0.6% | "000487": _w000487,
| SAP rating | 63 | 62 | +1 | "000490": _w000490,
"000516": _w000516,
}
The PCDB summer efficiency override (was 80% 87.0%) closes the HW
fuel gap from +32% to +14.4% the residual is the Appendix J §3b
PCDB combi loss table that our HW cascade still uses Table 3a row
defaults for. The SAP rating sits comfortably within tolerance.
Ceiling dropped 7 2 (SAP integer) and 7.0 2.0 (continuous) _PIN_FIELDS: Final[tuple[str, ...]] = tuple(f.name for f in fields(FixtureCascadePins))
reflecting the post-PCDB current state. **§10a slice 2 update:**
ceiling raised 2 4 because the post-§10a Table 32 + standing-
charge rewrite exposed upstream HW kWh + Appendix L lighting kWh
overestimates that the wrong pre-§10a prices had been masking.
**§4 HW slices 1 + 2 update:** ceiling dropped 4 3 PCDB Table
3b combi-loss override + Equation D1 monthly water-eff cascade
close 000474 HW kWh from 2622 2292 (matches PDF 2292 to 0.1%).
The remaining +9% cost residual and +3 SAP delta are Appendix L
lighting (528 vs ~169 back-derived) a separate ticket per memory
`project_section_4_hw_next_ticket`'s "secondary upstream" note.
"""
# Arrange
epc = _w000474.build_epc()
# Act
result = Sap10Calculator().calculate(epc)
# 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)
assert delta == 0, (
f"SAP rating delta {delta} — expected 0 (integer match with PDF). "
f"Actual={result.sap_score}, expected={_ELMHURST_000474_EXPECTED.sap_rating}."
)
continuous_delta = abs(
result.sap_score_continuous - _ELMHURST_000474_EXPECTED.sap_score_continuous
)
assert continuous_delta <= 0.5, (
f"Continuous SAP delta {continuous_delta:.2f} exceeds ceiling 0.5"
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"fixture, expected_kwh", "fixture_name,field_name",
[ [(name, fld) for name in _FIXTURE_PINS for fld in _PIN_FIELDS],
(_w000474, _w000474.LINE_232_LIGHTING_KWH_PER_YR), ids=lambda x: x,
(_w000490, _w000490.LINE_232_LIGHTING_KWH_PER_YR),
],
ids=["000474", "000490"],
) )
def test_elmhurst_end_to_end_lighting_kwh_per_yr_matches_u985_worksheet( def test_sap_result_pin(fixture_name: str, field_name: str) -> None:
fixture: object, expected_kwh: float """Strict cascade pin — `SapResult.<field>` matches the U985 PDF's
) -> None: line ref to abs=1e-4 for every fixture × field combination.
"""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 Each (fixture, field) pair is its own pytest case so failures
annual kWh is now derived via the same spec-faithful L1-L11 cascade surface as `test_sap_result_pin[000487-main_heating_fuel_kwh_per_yr]`
that drives §5 (67). Per ADR-0010 + the e2e validation philosophy the work queue is the test list. Per validation philosophy:
(memory: feedback-e2e-validation-philosophy) component pins no tolerance widening, no xfail; a failing pin is a calculator
validate the rdsap engine piece by piece; SAP integer integration bug to fix.
test must hit delta=0 in a later cycle.
""" """
# Arrange # Arrange
epc = fixture.build_epc() # type: ignore[attr-defined] pin = _FIXTURE_PINS[fixture_name]
epc = _FIXTURE_MODULES[fixture_name].build_epc()
expected = getattr(pin, field_name)
# Act # Act
result = Sap10Calculator().calculate(epc) result = Sap10Calculator().calculate(epc)
actual = getattr(result, field_name)
# Assert # Assert
assert result.lighting_kwh_per_yr == pytest.approx(expected_kwh, abs=1e-4) if isinstance(expected, int):
assert actual == expected, (
f"{fixture_name}.{field_name}: actual={actual}, expected={expected}"
@pytest.mark.parametrize( )
"fixture, expected_kwh", else:
[ assert actual == pytest.approx(expected, abs=_FLOAT_PIN_ABS), (
(_w000474, 160.0), f"{fixture_name}.{field_name}: actual={actual}, "
(_w000490, 160.0), f"expected={expected}, diff={abs(actual - expected):.4f}"
], )
ids=["000474", "000490"],
)
def test_elmhurst_end_to_end_pumps_fans_kwh_matches_u985_worksheet(
fixture: object, expected_kwh: float
) -> None:
"""Component-level pin on `SapResult.pumps_fans_kwh_per_yr` for the
e2e fixtures. PDF (231) for both 000474 + 000490: 115 (central
heating pump (230c)) + 45 (main heating flue fan (230e)) = 160.
Pre-fix `cert_to_inputs` hardcoded 130 kWh/yr via
`_DEFAULT_PUMPS_FANS_KWH_PER_YR`. The shortfall (-30 kWh × elec
price = -£4) was the dominant remaining residual on 000490 after
Appendix L + secondary heating + ventilation closures pushed
continuous SAP +0.38 over PDF integer 58 vs 57.
"""
# Arrange
epc = fixture.build_epc() # type: ignore[attr-defined]
# Act
result = Sap10Calculator().calculate(epc)
# Assert
assert result.pumps_fans_kwh_per_yr == pytest.approx(expected_kwh, abs=1e-3)
def test_elmhurst_000490_end_to_end_secondary_heating_fuel_kwh_matches_u985_worksheet() -> None:
"""Component-level e2e pin on `SapResult.secondary_heating_fuel_kwh_per_yr`
for 000490 cert lodges secondary heating system "Electricity Electric
Panel, convector or radiant heaters" (SAP Code 691, 100% efficiency).
Table 11 fraction 0.10 of total space heat goes to the secondary
system (215) = 1118.3275 kWh.
Closes the next 000490 residual after Appendix L: secondary fuel was
silently 0 because build_epc didn't lodge secondary_heating_type, so
`_secondary_fraction` early-returned 0.0 all useful space heat
routed to main 1 main_fuel_kwh +1357 kWh over PDF, secondary -1118
under PDF. Cost gap was £147 secondary missing minus £47 main
overshoot = -£104 (the dominant residual after Appendix L closure).
"""
# Arrange
epc = _w000490.build_epc()
# Act
result = Sap10Calculator().calculate(epc)
# Assert — useful space heating now matches PDF to ~0.01% (post
# ventilation-cert closures); secondary cascade propagates 1:1 →
# residual ≤ 0.1 kWh.
assert result.secondary_heating_fuel_kwh_per_yr == pytest.approx(
_w000490.LINE_215_SECONDARY_HEATING_FUEL_KWH, abs=0.1
)
@pytest.mark.parametrize("fixture", _ELMHURST_FIXTURES, ids=_elmhurst_fixture_id) @pytest.mark.parametrize("fixture", _ELMHURST_FIXTURES, ids=_elmhurst_fixture_id)
def test_elmhurst_cert_to_inputs_monthly_infiltration_ach_matches_u985_worksheet( def test_elmhurst_cert_to_inputs_monthly_infiltration_ach_matches_u985_worksheet(
fixture: ModuleType, fixture: ModuleType,
) -> None: ) -> None:
"""Component-level pin on `cert_to_inputs(epc).monthly_infiltration_ach` """Cert→inputs (25)m effective ACH pin (existing test, retained).
for every Elmhurst fixture. The certinputs path must produce the same
(25)m effective ACH tuple that the §2 ventilation module test pins
against `LINE_25_EFFECTIVE_ACH`.
Pre-fix the cert path under-counted (8) openings (extract_fans_count Each fixture's `monthly_infiltration_ach` from `cert_to_inputs` must
defaulted to 0; PDF lodges 1-2) AND over-counted (15) window infil match the U985 worksheet's LINE_25_EFFECTIVE_ACH at abs=1e-3 — the
(percent_draughtproofed defaulted to 0; PDF lodges 75-100). The net upstream of `_sap_result_pin`'s heat-loss-rate path. Lives outside
bias on (25)m propagates 1:1 to HLC × ΔT useful space heat main the cascade-pin grid because it asserts an intermediate (cert
+ secondary fuel kWh cost / SAP integer. inputs) value, not a SAP result field.
Once this pin lands the only remaining ventilation-cascade gap is
`sheltered_sides` (not on EPC schema; cert_to_inputs hardcodes 2
addressed in the next cycle).
""" """
# Arrange # Arrange
epc = fixture.build_epc() epc = fixture.build_epc()
@ -340,24 +204,3 @@ def test_elmhurst_cert_to_inputs_monthly_infiltration_ach_matches_u985_worksheet
assert inputs.monthly_infiltration_ach[m] == pytest.approx( assert inputs.monthly_infiltration_ach[m] == pytest.approx(
fixture.LINE_25_EFFECTIVE_ACH[m], abs=1e-3 fixture.LINE_25_EFFECTIVE_ACH[m], abs=1e-3
), f"(25) month {m+1} drift" ), f"(25) month {m+1} drift"
def test_elmhurst_000490_end_to_end_kwh_within_15pct() -> None:
"""Per-end-use kWh sanity check for 000490. Closer-fitting than the
SAP score because intermediate values aren't compressed through the
cost-deflator + rating equations."""
# Arrange
epc = _w000490.build_epc()
# Act
result = Sap10Calculator().calculate(epc)
# Assert
exp = _ELMHURST_000490_EXPECTED
assert result.space_heating_kwh_per_yr == pytest.approx(
exp.space_heating_kwh, rel=0.15
)
assert result.hot_water_kwh_per_yr == pytest.approx(exp.hot_water_kwh, rel=0.15)
assert result.total_fuel_cost_gbp == pytest.approx(
exp.total_energy_cost_gbp, rel=0.15
)