mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
5e34594d8a
commit
6bfb0614aa
1 changed files with 144 additions and 301 deletions
|
|
@ -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
|
||||
EpcPropertyData → cert_to_inputs → calculate_sap_from_inputs
|
||||
and compare the resulting SAP score against the Elmhurst worksheet's
|
||||
SAP rating (line 258).
|
||||
The Elmhurst corpus is a deterministic test-vector set: each cert has
|
||||
input lodgement (the Summary_NNNNNN PDF), intermediate values per
|
||||
line ref (the U985-0001-NNNNNN PDF), and final SAP outputs (the
|
||||
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
|
||||
shrink it (worksheet §4 wired into cert_to_inputs, PCDB Table 3b combi
|
||||
loss, etc.) show up as tolerance tightening rather than silent drift.
|
||||
Each pin is its own test case via pytest parametrize so failures are
|
||||
named like `test_sap_result_pin[000487-main_heating_fuel_kwh_per_yr]`
|
||||
— making the work queue legible.
|
||||
|
||||
Reference: Elmhurst U985-0001-000474.pdf and U985-0001-000490.pdf
|
||||
(supplied by the user; not stored in repo).
|
||||
Per `[[feedback-e2e-validation-philosophy]]` + `[[feedback-continuous-
|
||||
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
|
||||
|
||||
import pytest
|
||||
|
||||
from types import ModuleType
|
||||
|
||||
from domain.sap.calculator import Sap10Calculator
|
||||
from domain.sap.rdsap.cert_to_inputs import cert_to_inputs
|
||||
from domain.sap.worksheet.tests import (
|
||||
_elmhurst_worksheet_000474 as _w000474,
|
||||
_elmhurst_worksheet_000477 as _w000477,
|
||||
_elmhurst_worksheet_000480 as _w000480,
|
||||
_elmhurst_worksheet_000487 as _w000487,
|
||||
_elmhurst_worksheet_000490 as _w000490,
|
||||
_elmhurst_worksheet_000516 as _w000516,
|
||||
)
|
||||
from domain.sap.worksheet.tests._elmhurst_fixtures import (
|
||||
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)
|
||||
class ElmhurstExpectedSap:
|
||||
"""Headline figures from the Elmhurst worksheet's SAP rating section
|
||||
(xlsx rows around line refs 240 / 255 / 257 / 258 / 261)."""
|
||||
|
||||
sap_rating: int # (258) integer
|
||||
sap_score_continuous: float # (258) un-rounded
|
||||
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.
|
||||
class FixtureCascadePins:
|
||||
"""PDF-extracted expected values for every top-level `SapResult`
|
||||
field. Each value is the canonical line ref from the U985 worksheet
|
||||
(page references in the field comments). Field names mirror
|
||||
`SapResult` exactly so the parametrized test can read both via
|
||||
`getattr` without per-field plumbing.
|
||||
"""
|
||||
# Arrange
|
||||
epc = _w000490.build_epc()
|
||||
|
||||
# Act
|
||||
result = Sap10Calculator().calculate(epc)
|
||||
|
||||
# Assert — full cohort residual hunt closed: Appendix L lighting +
|
||||
# secondary heating cascade + ventilation cert lodgements + Table 4f
|
||||
# pumps_fans + SAP 10.2 rating constants. 000490 now hits SAP integer
|
||||
# delta=0 (continuous ~0.002 under PDF).
|
||||
delta = abs(result.sap_score - _ELMHURST_000490_EXPECTED.sap_rating)
|
||||
assert delta == 0, (
|
||||
f"SAP rating delta {delta} — expected 0 (integer match with PDF). "
|
||||
f"Actual={result.sap_score}, expected={_ELMHURST_000490_EXPECTED.sap_rating}."
|
||||
)
|
||||
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"
|
||||
)
|
||||
sap_score: int # (258) integer rating
|
||||
sap_score_continuous: float # (258) un-rounded
|
||||
ecf: float # (257) energy cost factor
|
||||
total_fuel_cost_gbp: float # (255) total energy cost
|
||||
co2_kg_per_yr: float # (272) total CO2 emissions
|
||||
space_heating_kwh_per_yr: float # (98c) annual space heat
|
||||
main_heating_fuel_kwh_per_yr: float # (211) main system 1 fuel
|
||||
secondary_heating_fuel_kwh_per_yr: float # (215) secondary fuel
|
||||
hot_water_kwh_per_yr: float # (219) water heating fuel
|
||||
lighting_kwh_per_yr: float # (232) electricity for lighting
|
||||
pumps_fans_kwh_per_yr: float # (231) pumps + fans + flue
|
||||
|
||||
|
||||
def test_elmhurst_000474_end_to_end_sap_score_currently_within_3_points() -> None:
|
||||
"""End-terrace PCDB-tested Vaillant boiler. After the PCDB Table 105
|
||||
integration the fixture lodges `main_heating_index_number=16839`
|
||||
(Vaillant ecoTEC pro 28 VUW GB 286/5-3, winter eff 88.7%, summer
|
||||
87.0%, comparative HW 75.1%) per the PDF's "PCDF boiler reference:
|
||||
16839 Vaillant ecoTEC pro 28 88.70%" lodgement.
|
||||
_FIXTURE_PINS: Final[dict[str, FixtureCascadePins]] = {
|
||||
"000474": FixtureCascadePins(
|
||||
sap_score=62, sap_score_continuous=62.2584, ecf=2.7055,
|
||||
total_fuel_cost_gbp=655.6949, co2_kg_per_yr=3036.2933,
|
||||
space_heating_kwh_per_yr=10612.8595,
|
||||
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 |
|
||||
| --------------- | ------- | -------- | ----- |
|
||||
| space heating | 10914.3 | 10612.86 | +2.8% |
|
||||
| hot water fuel | 2621.7 | 2291.78 | +14.4%|
|
||||
| total fuel cost | £651.85 | £655.69 | -0.6% |
|
||||
| SAP rating | 63 | 62 | +1 |
|
||||
_FIXTURE_MODULES: Final[dict[str, ModuleType]] = {
|
||||
"000474": _w000474,
|
||||
"000477": _w000477,
|
||||
"000480": _w000480,
|
||||
"000487": _w000487,
|
||||
"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)
|
||||
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"
|
||||
)
|
||||
_PIN_FIELDS: Final[tuple[str, ...]] = tuple(f.name for f in fields(FixtureCascadePins))
|
||||
|
||||
|
||||
@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"],
|
||||
"fixture_name,field_name",
|
||||
[(name, fld) for name in _FIXTURE_PINS for fld in _PIN_FIELDS],
|
||||
ids=lambda x: x,
|
||||
)
|
||||
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.
|
||||
def test_sap_result_pin(fixture_name: str, field_name: str) -> None:
|
||||
"""Strict cascade pin — `SapResult.<field>` matches the U985 PDF's
|
||||
line ref to abs=1e-4 for every fixture × field combination.
|
||||
|
||||
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.
|
||||
Each (fixture, field) pair is its own pytest case so failures
|
||||
surface as `test_sap_result_pin[000487-main_heating_fuel_kwh_per_yr]`
|
||||
— the work queue is the test list. Per validation philosophy:
|
||||
no tolerance widening, no xfail; a failing pin is a calculator
|
||||
bug to fix.
|
||||
"""
|
||||
# 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
|
||||
result = Sap10Calculator().calculate(epc)
|
||||
actual = getattr(result, field_name)
|
||||
|
||||
# Assert
|
||||
assert result.lighting_kwh_per_yr == pytest.approx(expected_kwh, abs=1e-4)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fixture, expected_kwh",
|
||||
[
|
||||
(_w000474, 160.0),
|
||||
(_w000490, 160.0),
|
||||
],
|
||||
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
|
||||
)
|
||||
if isinstance(expected, int):
|
||||
assert actual == expected, (
|
||||
f"{fixture_name}.{field_name}: actual={actual}, expected={expected}"
|
||||
)
|
||||
else:
|
||||
assert actual == pytest.approx(expected, abs=_FLOAT_PIN_ABS), (
|
||||
f"{fixture_name}.{field_name}: actual={actual}, "
|
||||
f"expected={expected}, diff={abs(actual - expected):.4f}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fixture", _ELMHURST_FIXTURES, ids=_elmhurst_fixture_id)
|
||||
def test_elmhurst_cert_to_inputs_monthly_infiltration_ach_matches_u985_worksheet(
|
||||
fixture: ModuleType,
|
||||
) -> None:
|
||||
"""Component-level pin on `cert_to_inputs(epc).monthly_infiltration_ach`
|
||||
for every Elmhurst fixture. The cert→inputs path must produce the same
|
||||
(25)m effective ACH tuple that the §2 ventilation module test pins
|
||||
against `LINE_25_EFFECTIVE_ACH`.
|
||||
"""Cert→inputs (25)m effective ACH pin (existing test, retained).
|
||||
|
||||
Pre-fix the cert path under-counted (8) openings (extract_fans_count
|
||||
defaulted to 0; PDF lodges 1-2) AND over-counted (15) window infil
|
||||
(percent_draughtproofed defaulted to 0; PDF lodges 75-100). The net
|
||||
bias on (25)m propagates 1:1 to HLC × ΔT → useful space heat → main
|
||||
+ secondary fuel kWh → cost / SAP integer.
|
||||
|
||||
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).
|
||||
Each fixture's `monthly_infiltration_ach` from `cert_to_inputs` must
|
||||
match the U985 worksheet's LINE_25_EFFECTIVE_ACH at abs=1e-3 — the
|
||||
upstream of `_sap_result_pin`'s heat-loss-rate path. Lives outside
|
||||
the cascade-pin grid because it asserts an intermediate (cert→
|
||||
inputs) value, not a SAP result field.
|
||||
"""
|
||||
# Arrange
|
||||
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(
|
||||
fixture.LINE_25_EFFECTIVE_ACH[m], abs=1e-3
|
||||
), 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
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue