Model/tests/domain/sap10_calculator/worksheet/test_dimensions.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

528 lines
22 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.3 §1 dwelling-dimensions module.
Builds the typed `Dimensions` aggregate from an `EpcPropertyData`. Geometry
is summed across every `sap_building_parts` entry (main dwelling + every
extension). Reuses the existing fixtures from the ML test pack so tests
match the shape `transform.py` already sees in production.
SAP 10.3 specification (13-01-2026), §1 reference at
domain/sap10_calculator/docs/specs/sap-10-3-full-specification-2026-01-13.pdf pages 11-12.
"""
import json
from dataclasses import replace
from pathlib import Path
import pytest
from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData,
SapRoomInRoof,
)
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.sap10_ml.tests._fixtures import (
make_building_part,
make_floor_dimension,
make_minimal_sap10_epc,
)
from domain.sap10_calculator.worksheet.dimensions import Dimensions, dimensions_from_cert
from tests.domain.sap10_calculator.worksheet._xlsx_loader import load_cells
_RIR_FIXTURES_DIR = Path(__file__).parent / "fixtures" / "rir"
def test_single_storey_single_part_populates_every_dimension_field() -> None:
# Arrange — Single-storey detached house: TFA 100 m², heat-loss perimeter
# 40 m, storey height 2.5 m, party wall length 5 m. Single building part
# ("Main Dwelling") with one floor dimension. Top-level TFA matches the
# building-part TFA.
main = make_building_part(
identifier="Main Dwelling",
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=100.0, room_height_m=2.5,
party_wall_length_m=5.0, heat_loss_perimeter_m=40.0, floor=0,
),
],
)
epc = make_minimal_sap10_epc(total_floor_area_m2=100.0, sap_building_parts=[main])
# Act
result = dimensions_from_cert(epc)
# Assert
assert isinstance(result, Dimensions)
assert result.total_floor_area_m2 == pytest.approx(100.0)
assert result.volume_m3 == pytest.approx(250.0) # 100 × 2.5
assert result.storey_count == 1
assert result.avg_storey_height_m == pytest.approx(2.5)
assert result.ground_floor_area_m2 == pytest.approx(100.0)
assert result.ground_floor_perimeter_m == pytest.approx(40.0)
assert result.top_floor_area_m2 == pytest.approx(100.0)
assert result.gross_wall_area_m2 == pytest.approx(100.0) # 40 × 2.5 × 1
assert result.party_wall_area_m2 == pytest.approx(12.5) # 5 × 2.5 × 1
def test_two_storey_doubles_wall_area_and_volume_but_not_roof_or_floor() -> None:
# Arrange — Same floor plan, but two storeys. Wall area and party area
# scale linearly with storey count; ground/top floor areas stay tied to
# the single ground/top floor only. Top-level TFA is the sum across both
# storeys = 200 m².
main = make_building_part(
identifier="Main Dwelling",
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=100.0, room_height_m=2.5,
party_wall_length_m=5.0, heat_loss_perimeter_m=40.0, floor=0,
),
make_floor_dimension(
total_floor_area_m2=100.0, room_height_m=2.5,
party_wall_length_m=5.0, heat_loss_perimeter_m=40.0, floor=1,
),
],
)
epc = make_minimal_sap10_epc(total_floor_area_m2=200.0, sap_building_parts=[main])
# Act
result = dimensions_from_cert(epc)
# Assert
assert result.total_floor_area_m2 == pytest.approx(200.0)
assert result.volume_m3 == pytest.approx(500.0) # 200 × 2.5
assert result.storey_count == 2
assert result.gross_wall_area_m2 == pytest.approx(200.0) # 40 × 2.5 × 2
assert result.party_wall_area_m2 == pytest.approx(25.0) # 5 × 2.5 × 2
assert result.ground_floor_area_m2 == pytest.approx(100.0)
assert result.top_floor_area_m2 == pytest.approx(100.0)
def test_main_plus_extension_sums_areas_perimeters_and_walls() -> None:
# Arrange — Main dwelling 100 m² + single-storey extension 15 m². Ground
# floor area, perimeter, wall area, party-wall area must all sum across
# parts. Top-level TFA matches the sum.
main = make_building_part(
identifier="Main Dwelling",
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=100.0, room_height_m=2.5,
party_wall_length_m=5.0, heat_loss_perimeter_m=40.0, floor=0,
),
],
)
extension = make_building_part(
identifier="Extension 1",
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=15.0, room_height_m=2.4,
party_wall_length_m=0.0, heat_loss_perimeter_m=16.0, floor=0,
),
],
)
epc = make_minimal_sap10_epc(
total_floor_area_m2=115.0,
sap_building_parts=[main, extension],
)
# Act
result = dimensions_from_cert(epc)
# Assert
assert result.total_floor_area_m2 == pytest.approx(115.0)
assert result.ground_floor_area_m2 == pytest.approx(115.0) # 100 + 15
assert result.ground_floor_perimeter_m == pytest.approx(56.0) # 40 + 16
assert result.top_floor_area_m2 == pytest.approx(115.0) # both parts are single-storey
# main: 40 × 2.5 × 1 = 100; extension: 16 × 2.4 × 1 = 38.4
assert result.gross_wall_area_m2 == pytest.approx(138.4, abs=1e-12)
# main party: 5 × 2.5 × 1 = 12.5; extension party: 0 × 2.4 × 1 = 0
assert result.party_wall_area_m2 == pytest.approx(12.5)
# SAP §2 (9) "ns": dwelling height (max across parts), NOT Σ across
# parts. Both parts here are single-storey, so the dwelling is one
# storey tall regardless of how many extensions stick out sideways.
assert result.storey_count == 1
def test_dwelling_storey_count_is_max_across_parts_not_sum() -> None:
# Arrange — SAP §2 (9) requires the **dwelling height** for the (10)
# additional-infiltration adjustment, which is the tallest part, not
# the sum of per-part floor counts. Surfaced by Elmhurst 000474: a
# 2-storey main + 1-storey side extension lodges as ns=2 in the
# worksheet, but our pre-fix code returned 2 + 1 = 3 and over-stated
# (10) by 0.1 ach.
main = make_building_part(
identifier=BuildingPartIdentifier.MAIN,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=50.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=30.0, floor=0,
),
make_floor_dimension(
total_floor_area_m2=50.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=30.0, floor=1,
),
],
)
extension = make_building_part(
identifier="Extension 1",
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=15.0, room_height_m=2.4,
party_wall_length_m=0.0, heat_loss_perimeter_m=16.0, floor=0,
),
],
)
epc = make_minimal_sap10_epc(total_floor_area_m2=115.0, sap_building_parts=[main, extension])
# Act
result = dimensions_from_cert(epc)
# Assert — dwelling is 2 storeys tall, not 3.
assert result.storey_count == 2
def test_gross_wall_area_sums_per_storey_perimeter_times_height_not_ground_perim_times_avg() -> None:
# Arrange — 2-storey terrace where the upper storey has a smaller
# heat-loss perimeter than the ground (e.g. set-back upper floor or a
# wider ground addition). Surfaced by Elmhurst 000474: Main has
# ground perim 7.07, first 5.27 — the worksheet sums each storey
# separately, but pre-fix code used `ground_perim × avg_height ×
# storey_count` which over-counts the upper storey's wall area.
main = make_building_part(
identifier=BuildingPartIdentifier.MAIN,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=50.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=10.0, floor=0,
),
make_floor_dimension(
total_floor_area_m2=50.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=6.0, floor=1,
),
],
)
epc = make_minimal_sap10_epc(total_floor_area_m2=100.0, sap_building_parts=[main])
# Act
result = dimensions_from_cert(epc)
# Assert — Σ (perim × height) = 10×2.5 + 6×2.5 = 40.
# Pre-fix would have given 10 × 2.5 × 2 = 50.
assert result.gross_wall_area_m2 == pytest.approx(40.0)
def test_party_wall_area_sums_per_storey_party_length_times_height_not_ground_party_times_avg() -> None:
# Arrange — Same per-storey-differs shape, but applied to the party
# wall. Two-storey main, ground party 5 m, upper party 3 m (e.g. the
# upper storey is set back from the party line). RdSAP §5.10 party
# area is also Σ (party_length_i × height_i), not
# ground_party × avg × count.
main = make_building_part(
identifier=BuildingPartIdentifier.MAIN,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=50.0, room_height_m=2.5,
party_wall_length_m=5.0, heat_loss_perimeter_m=0.0, floor=0,
),
make_floor_dimension(
total_floor_area_m2=50.0, room_height_m=2.5,
party_wall_length_m=3.0, heat_loss_perimeter_m=0.0, floor=1,
),
],
)
epc = make_minimal_sap10_epc(total_floor_area_m2=100.0, sap_building_parts=[main])
# Act
result = dimensions_from_cert(epc)
# Assert — Σ (party × height) = 5×2.5 + 3×2.5 = 20.
# Pre-fix would have given 5 × 2.5 × 2 = 25.
assert result.party_wall_area_m2 == pytest.approx(20.0)
def test_room_in_roof_on_main_adds_one_to_dwelling_storey_count_only_once() -> None:
# Arrange — Main 2-storey with RR + same-height 2-storey extension
# without RR. RR adds one storey to MAIN (giving 3), extension stays
# at 2 storeys. Dwelling height = max(3, 2) = 3. Pre-fix code summed
# main-with-rr (3) + extension (2) = 5.
main = make_building_part(
identifier=BuildingPartIdentifier.MAIN,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=50.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=30.0, floor=0,
),
make_floor_dimension(
total_floor_area_m2=50.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=30.0, floor=1,
),
],
sap_room_in_roof=SapRoomInRoof(floor_area=20.0, construction_age_band="B"),
)
extension = make_building_part(
identifier="Extension 1",
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=15.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=16.0, floor=0,
),
make_floor_dimension(
total_floor_area_m2=15.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=16.0, floor=1,
),
],
)
epc = make_minimal_sap10_epc(total_floor_area_m2=140.0, sap_building_parts=[main, extension])
# Act
result = dimensions_from_cert(epc)
# Assert — Main with RR is 3 storeys, extension is 2 — dwelling is 3.
assert result.storey_count == 3
def test_empty_sap_building_parts_uses_top_level_tfa_with_default_height() -> None:
# Arrange — Some cert flows arrive with sap_building_parts empty but
# total_floor_area_m2 populated (e.g. site-notes-only baseline). The
# calculator must not crash; geometric envelope fields fall back to zero
# and avg_storey_height_m defaults to 2.5 m so volume = TFA × 2.5.
epc = make_minimal_sap10_epc(total_floor_area_m2=80.0, sap_building_parts=[])
# Act
result = dimensions_from_cert(epc)
# Assert
assert result.total_floor_area_m2 == pytest.approx(80.0)
assert result.volume_m3 == pytest.approx(200.0) # 80 × 2.5 default
assert result.storey_count == 0
assert result.avg_storey_height_m == pytest.approx(2.5)
assert result.ground_floor_area_m2 == 0.0
assert result.ground_floor_perimeter_m == 0.0
assert result.top_floor_area_m2 == 0.0
assert result.gross_wall_area_m2 == 0.0
assert result.party_wall_area_m2 == 0.0
def test_party_wall_area_scales_with_room_height_and_storey_count() -> None:
# Arrange — Two-storey terrace with non-default room height 2.7 m and a
# 10 m party wall on each floor. Expected party_wall_area = 10 × 2.7 × 2 = 54.
main = make_building_part(
identifier="Main Dwelling",
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=80.0, room_height_m=2.7,
party_wall_length_m=10.0, heat_loss_perimeter_m=30.0, floor=0,
),
make_floor_dimension(
total_floor_area_m2=80.0, room_height_m=2.7,
party_wall_length_m=10.0, heat_loss_perimeter_m=30.0, floor=1,
),
],
)
epc = make_minimal_sap10_epc(total_floor_area_m2=160.0, sap_building_parts=[main])
# Act
result = dimensions_from_cert(epc)
# Assert
assert result.avg_storey_height_m == pytest.approx(2.7)
assert result.party_wall_area_m2 == pytest.approx(54.0) # 10 × 2.7 × 2
assert result.gross_wall_area_m2 == pytest.approx(162.0) # 30 × 2.7 × 2
assert result.volume_m3 == pytest.approx(432.0) # 160 × 2.7
def test_section_1_matches_excel_worksheet_conformance() -> None:
"""Mirror the worked example in `2026-05-19-17-18 RdSap10Worksheet.xlsx`,
sheet `NonRegionalWeather`, §1 (Overall dwelling dimensions).
Excel cells:
Q7 = (1a) Basement area = 84.44 m²
S7 = (2a) Basement height = 2.92 m
U7 = (3a) Basement volume = 246.5648 m³
Q9 = (1b) Ground area = 74.55 m²
S9 = (2b) Ground height = 3.56 m
U9 = (3b) Ground volume = 265.398 m³
Q23 = (4) Total floor area = Σ (1x) = 158.99 m²
U25 = (5) Dwelling volume = Σ (3x) = 511.9628 m³
`SapFloorDimension` has no basement representation (API never sets
it), so Excel's Basement+Ground are mapped to floor=0 and floor=1 —
§1 is a pure sum so storey labels don't affect the result.
"""
# Arrange
excel = load_cells(
"NonRegionalWeather", ["Q7", "S7", "U7", "Q9", "S9", "U9", "Q23", "U25"]
)
# Sanity-check the Excel arithmetic itself before testing against our code.
assert excel["Q7"] * excel["S7"] == pytest.approx(excel["U7"])
assert excel["Q9"] * excel["S9"] == pytest.approx(excel["U9"])
assert excel["Q7"] + excel["Q9"] == pytest.approx(excel["Q23"])
assert excel["U7"] + excel["U9"] == pytest.approx(excel["U25"])
main = make_building_part(
identifier=BuildingPartIdentifier.MAIN,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=excel["Q7"], room_height_m=excel["S7"],
party_wall_length_m=0.0, heat_loss_perimeter_m=0.0, floor=0,
),
make_floor_dimension(
total_floor_area_m2=excel["Q9"], room_height_m=excel["S9"],
party_wall_length_m=0.0, heat_loss_perimeter_m=0.0, floor=1,
),
],
)
epc = make_minimal_sap10_epc(
total_floor_area_m2=excel["Q23"], sap_building_parts=[main]
)
# Act
result = dimensions_from_cert(epc)
# Assert — line (4) and line (5) match the worksheet.
assert result.total_floor_area_m2 == pytest.approx(excel["Q23"])
assert result.volume_m3 == pytest.approx(excel["U25"])
def test_section_1_uses_per_storey_sums_even_when_cert_top_level_disagrees() -> None:
"""§1 lines (4)/(5) are Σ of per-storey (1x)/(3x), not the cert's
top-level TFA. Catches a regression where Dimensions reads
`epc.total_floor_area_m2` directly instead of summing per-storey."""
# Arrange — Two storeys totalling 100 m² + 50 m² = 150 m². Set the
# cert's top-level TFA to a deliberately wrong 999.0 so a per-storey
# sum gives 150 m² but a cert-level read gives 999.
main = make_building_part(
identifier=BuildingPartIdentifier.MAIN,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=100.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=0.0, floor=0,
),
make_floor_dimension(
total_floor_area_m2=50.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=0.0, floor=1,
),
],
)
epc = make_minimal_sap10_epc(total_floor_area_m2=999.0, sap_building_parts=[main])
# Act
result = dimensions_from_cert(epc)
# Assert
assert result.total_floor_area_m2 == pytest.approx(150.0) # Σ (1x), not 999
assert result.volume_m3 == pytest.approx(375.0) # Σ (3x) = 150 × 2.5
def test_room_in_roof_adds_one_storey_with_simplified_2_45m_height() -> None:
"""RdSAP §1.8 + §3.9: a room-in-roof counts as a separate storey for
§1. For Simplified type 1 (true RR, the common API shape — only
gable-wall lengths populated) RdSAP §3.9.1 fixes the storey height
at 2.45 m (= 2.2 m internal + 0.25 m floor structure between RR and
storey below). Modelled after golden cert 0240: ground floor 97.72 m²
+ room-in-roof 83.2 m² should sum to TFA 180.92 (matches cert TFA
202 to within the ~10 m² rounding the cert applies elsewhere)."""
# Arrange — single part with one ground floor + a room-in-roof block.
main = make_building_part(
identifier=BuildingPartIdentifier.MAIN,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=97.72, room_height_m=2.28,
party_wall_length_m=0.0, heat_loss_perimeter_m=36.45, floor=0,
),
],
sap_room_in_roof=SapRoomInRoof(
floor_area=83.2, construction_age_band="J",
),
)
epc = make_minimal_sap10_epc(total_floor_area_m2=180.92, sap_building_parts=[main])
# Act
result = dimensions_from_cert(epc)
# Assert — TFA = ground + RR; volume = ground×ht + RR×2.45.
assert result.total_floor_area_m2 == pytest.approx(97.72 + 83.2)
assert result.volume_m3 == pytest.approx(97.72 * 2.28 + 83.2 * 2.45)
assert result.storey_count == 2 # ground floor + room-in-roof storey
def _strip_room_in_roof(epc: EpcPropertyData) -> EpcPropertyData:
"""Return a copy of `epc` with every building part's `sap_room_in_roof`
set to None. Used to isolate the RR contribution to §1 outputs by
diffing against the with-RR result."""
parts_no_rr = [
replace(p, sap_room_in_roof=None) for p in (epc.sap_building_parts or [])
]
return replace(epc, sap_building_parts=parts_no_rr)
@pytest.mark.parametrize(
"cert_filename, rr_shape_label",
[
("0782-3058-6209-9186-1200.json", "room_in_roof_type_2 (Detailed type 2 — gable + common wall heights)"),
("0636-8125-6600-0416-2202.json", "room_in_roof_details with stud_walls (Detailed type 1)"),
("0636-6824-0100-0500-6222.json", "room_in_roof_details with common_walls (Detailed type 2)"),
],
)
def test_all_rir_shapes_apply_section_1_2_45m_convention_uniformly(
cert_filename: str, rr_shape_label: str,
) -> None:
"""RdSAP §3.9.2 wall-area formulas and §3.10 detailed measurements
are for §3 heat-loss U-value calculation, **not** §1 dimensions —
confirmed at `domain/sap10_calculator/docs/specs/RdSAP 10 Specification 10-06-2025.pdf`
pages 22-24. The §1 storey-height convention of 2.45 m from §3.9.1
extends uniformly to every RR shape: each contributes exactly
`floor_area` to TFA, `floor_area × 2.45` to volume, and +1 storey.
Real-corpus fixtures (from /workspaces/model/data/ml_training/bulk/
certificates-2026.json.zip) exercise the three non-Simplified-type-1
shapes; the dynamic-delta assertion catches any future code path
that special-cases by shape."""
# Arrange — load a real cert with a non-Simplified-type-1 RR block
doc = json.loads((_RIR_FIXTURES_DIR / cert_filename).read_text())
epc = EpcPropertyDataMapper.from_api_response(doc)
parts_with_rr = [
p for p in (epc.sap_building_parts or []) if p.sap_room_in_roof is not None
]
rir_floor_area_total = sum(p.sap_room_in_roof.floor_area for p in parts_with_rr)
assert rir_floor_area_total > 0, f"Fixture {cert_filename} should carry RR floor_area"
# Act — compute §1 outputs with and without the RR block to isolate its delta
result_with_rr = dimensions_from_cert(epc)
result_without_rr = dimensions_from_cert(_strip_room_in_roof(epc))
# Assert — TFA and volume DO sum across every RR-bearing part (genuine
# sums per RdSAP §3.9.1). Storey count, by contrast, is the dwelling
# height (max across parts) per SAP §2 (9), so RR contributes at most
# one extra storey to the dwelling — the storey it adds to the part
# that ends up tallest. All three fixtures here have single-storey
# parts, so any part gaining RR becomes the new tallest stack and
# storey_delta is exactly 1.
tfa_delta = result_with_rr.total_floor_area_m2 - result_without_rr.total_floor_area_m2
volume_delta = result_with_rr.volume_m3 - result_without_rr.volume_m3
storey_delta = result_with_rr.storey_count - result_without_rr.storey_count
assert tfa_delta == pytest.approx(rir_floor_area_total)
assert volume_delta == pytest.approx(rir_floor_area_total * 2.45)
assert storey_delta == 1
from types import ModuleType # noqa: E402 (kept near the Elmhurst tests)
from tests.domain.sap10_calculator.worksheet._elmhurst_fixtures import ( # noqa: E402
ALL_FIXTURES as _ELMHURST_FIXTURES,
fixture_id as _elmhurst_fixture_id,
)
@pytest.mark.parametrize("fixture", _ELMHURST_FIXTURES, ids=_elmhurst_fixture_id)
def test_section_1_matches_elmhurst_worksheet(fixture: ModuleType) -> None:
"""Real Elmhurst SAP10.2 worksheets — asserts §1 lines (4) Total Floor
Area and (5) Dwelling Volume against the canonical Elmhurst output for
each registered fixture. Pytest id = the worksheet reference number."""
# Arrange / Act
result = dimensions_from_cert(fixture.build_epc())
# Assert
# PDF 4-d.p. display floor per [[feedback-e2e-validation-philosophy]].
# Actual cohort diffs are 1e-14 (essentially exact) for these scalars.
assert result.total_floor_area_m2 == pytest.approx(fixture.LINE_4_TFA_M2, abs=1e-4)
assert result.volume_m3 == pytest.approx(fixture.LINE_5_VOLUME_M3, abs=1e-4)