Model/tests/domain/sap10_calculator/worksheet/test_mev.py
Khalim Conn-Kowlessar d7d5084f90 Move sap10_calculator tests to tests/domain/sap10_calculator/ for CI
The calculator tests lived under domain/sap10_calculator/{tests,worksheet/
tests,rdsap/tests,climate/tests,validation/tests}, none of which are in
pytest.ini testpaths — so CI (which collects tests/) never ran them. Relocate
all five dirs to tests/domain/sap10_calculator/{,worksheet,rdsap,climate,
validation}, mirroring the tests/domain/property_baseline/ convention, so the
cascade-pin / golden / e2e conformance suites run in CI.

Mechanics:
- git mv preserves history (110 files).
- Flattening the trailing /tests keeps each file's depth-to-repo-root
  identical, so all 16 repo-root parents[4] fixture refs stay valid. Only
  test_pcdb_etl.py's parents[1] (→ pcdb data) and one hardcoded absolute
  golden-fixture path in test_cert_to_inputs.py needed rebasing.
- Cross-imports rewritten domain.sap10_calculator.worksheet.tests →
  tests.domain.sap10_calculator.worksheet (21 files incl. the external
  importer backend/documents_parser/tests/test_summary_pdf_mapper_chain.py).
- Golden-fixture path strings in test_summary_pdf_mapper_chain.py +
  scripts/fetch_cohort2_api_jsons.py updated to the new location (the JSONs
  moved with the rdsap tests).

load_cells / gitignored worksheet xlsx: the xlsx-pinned tests (test_dimensions
/ ventilation / water_heating) read 2026-05-19-17-18 RdSap10Worksheet.xlsx,
which is gitignored (.gitignore `*.xlsx`) and so absent in CI. _xlsx_loader.
load_cells now pytest.skip()s when the file is absent, so those tests run
locally and skip cleanly in CI instead of erroring — no new CI failures from
the move, and the gitignore policy is respected.

Verified: tests/domain/sap10_calculator + backend/documents_parser +
tests/domain/property_baseline = 2248 pass, 1 skipped; pyright resolves the
new import paths with zero import-resolution errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 16:58:00 +00:00

137 lines
5.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for SAP 10.2 §2.6.4 decentralised MEV cascade helpers.
Pin both `mev_sfp_av` (equation (1)) and `mev_decentralised_kwh_per_yr`
(line (230a)) against the U985-0001-000565 worksheet's documented MEV
breakdown:
"MEVDecentralised, Database: total watage = 11.7205,
total flow = 92.0000,
SFP = 0.1274"
mechanical ventilation fans (SFP = 0.1274) 127.5159 (230a)
Cert 000565 lodges PCDB index 500755 (Titon Ultimate dMEV); the
fan installation is 1 in-room kitchen + 1 in-room other-wet-room
+ 2 through-wall kitchens + 3 through-wall other-wet-rooms, with
"Duct Type: Flexible" and "Approved Installation: No".
References:
- SAP 10.2 specification (14-03-2025) §2.6.4 + §5 Table 4f
- U985-0001-000565.pdf lines 558-559 (MEV electricity)
"""
from __future__ import annotations
from domain.sap10_calculator.worksheet.mev import (
MevFanEntry,
mev_decentralised_kwh_per_yr,
mev_sfp_av,
)
# Cert 000565 lodged fan installation per the worksheet line 122-127
# breakdown:
# 1 × in-room kitchen (SFP 0.15, FR 13, IUF 1.45 — flexible)
# 1 × in-room other wet (SFP 0.15, FR 8, IUF 1.45 — flexible)
# 1 × in-duct kitchen (SFP blank — Table 322 record 500755
# doesn't lodge this configuration;
# contributes 0 to the numerator but
# FR to the denominator per SAP §2.6.4
# "summation is over all the fans")
# 1 × in-duct other wet (SFP blank, FR 8)
# 2 × through-wall kitchen (SFP 0.11, FR 13, IUF 1.15 — no-duct)
# 3 × through-wall other wet (SFP 0.14, FR 8, IUF 1.15 — no-duct)
#
# Σ FR = 13 + 8 + 13 + 8 + 26 + 24 = 92 l/s (worksheet total_flow)
# Σ SFP×FR×IUF = 2.8275 + 1.74 + 0 + 0 + 3.289 + 3.864 = 11.7205 W
# (worksheet total_watage)
_CERT_000565_FAN_ENTRIES: tuple[MevFanEntry, ...] = (
# 1 × in-room kitchen
MevFanEntry(sfp_w_per_l_per_s=0.15, flow_rate_l_per_s=13.0, iuf=1.45),
# 1 × in-room other wet room
MevFanEntry(sfp_w_per_l_per_s=0.15, flow_rate_l_per_s=8.0, iuf=1.45),
# 1 × in-duct kitchen — SFP blank (PCDB-untested for this configuration);
# contributes only flow to the SFPav denominator.
MevFanEntry(sfp_w_per_l_per_s=0.0, flow_rate_l_per_s=13.0, iuf=1.45),
# 1 × in-duct other wet room — SFP blank
MevFanEntry(sfp_w_per_l_per_s=0.0, flow_rate_l_per_s=8.0, iuf=1.45),
# 2 × through-wall kitchen
MevFanEntry(sfp_w_per_l_per_s=0.11, flow_rate_l_per_s=13.0, iuf=1.15),
MevFanEntry(sfp_w_per_l_per_s=0.11, flow_rate_l_per_s=13.0, iuf=1.15),
# 3 × through-wall other wet room
MevFanEntry(sfp_w_per_l_per_s=0.14, flow_rate_l_per_s=8.0, iuf=1.15),
MevFanEntry(sfp_w_per_l_per_s=0.14, flow_rate_l_per_s=8.0, iuf=1.15),
MevFanEntry(sfp_w_per_l_per_s=0.14, flow_rate_l_per_s=8.0, iuf=1.15),
)
# Cert 000565 dwelling volume from U985-0001-000565 line (5):
# (3a)+(3b)+...+(3n) = 820.4385 m³
_CERT_000565_DWELLING_VOLUME_M3: float = 820.4385
def test_mev_sfp_av_for_cert_000565_matches_worksheet_0p1274() -> None:
"""SAP 10.2 §2.6.4 equation (1):
SFPav = Σ(SFP × FR × IUF) / Σ(FR)
Worksheet line 558: "total watage = 11.7205, total flow = 92.0000,
SFP = 0.1274". Closed-form check:
numerator = 11.7205 W
denominator = 92.0 l/s
SFPav = 0.127397826... W/(l/s)
"""
# Arrange / Act
sfp_av = mev_sfp_av(_CERT_000565_FAN_ENTRIES)
# Assert — 1e-4 strict floor per [[feedback-zero-error-strict]].
assert abs(sfp_av - 0.1274) <= 1e-4
def test_mev_decentralised_kwh_per_yr_for_cert_000565_matches_worksheet_127p5159() -> None:
"""SAP 10.2 §5 Table 4f line (230a):
E_fans_kwh = SFPav × 1.22 × V
Worksheet line 559: 127.5159 kWh/year. Closed-form:
0.127397826 × 1.22 × 820.4385 = 127.5163 ≈ 127.5159 (worksheet
rounds the printed SFP to 4 d.p. before display; the underlying
high-precision SFP from the database yields the exact figure).
"""
# Arrange
sfp_av = mev_sfp_av(_CERT_000565_FAN_ENTRIES)
# Act
kwh = mev_decentralised_kwh_per_yr(
sfp_av_w_per_l_per_s=sfp_av,
dwelling_volume_m3=_CERT_000565_DWELLING_VOLUME_M3,
)
# Assert — strict 1e-4 against worksheet line (230a).
assert abs(kwh - 127.5159) <= 1e-4
def test_mev_sfp_av_returns_zero_for_empty_installation() -> None:
"""No fans lodged → no MEV electricity contribution. Caller should
not invoke `mev_decentralised_kwh_per_yr` in that case, but the
primitive returns 0.0 defensively for callers that want a single
code path."""
# Arrange / Act
sfp_av = mev_sfp_av(())
# Assert
assert sfp_av == 0.0
def test_mev_decentralised_kwh_per_yr_scales_linearly_with_volume() -> None:
"""The line (230a) formula is linear in dwelling volume. Doubling
V doubles the annual fan electricity; this pin guards against any
future regression in the per-unit coefficient (currently 1.22)."""
# Arrange
sfp = 0.25
# Act
kwh_at_100 = mev_decentralised_kwh_per_yr(
sfp_av_w_per_l_per_s=sfp, dwelling_volume_m3=100.0,
)
kwh_at_200 = mev_decentralised_kwh_per_yr(
sfp_av_w_per_l_per_s=sfp, dwelling_volume_m3=200.0,
)
# Assert
assert abs(kwh_at_100 - 30.5) <= 1e-4 # 0.25 × 1.22 × 100
assert abs(kwh_at_200 - 2 * kwh_at_100) <= 1e-4