Dimensions: storey_count is dwelling height (max across parts), not sum

SAP §2 (9) "ns" is the dwelling height — the tallest part — which drives
the (10) additional-infiltration adjustment. Pre-fix code summed
`len(sap_floor_dimensions)` across parts and incremented for every
sap_room_in_roof block, so a 2-storey main + 1-storey side extension
returned ns=3 instead of 2, and a 2-part RR-bearing cert could return
ns=4 or 5. The (10) ach output overstated by 0.1 per spurious storey.

Fix tracks per-part `(floor_count + 1 if RR else 0)` and emits
`max(per_part)`. TFA and volume sums on §1 are unaffected — those are
genuine Σ per RdSAP §3.9.1.

Surfaced by Elmhurst 000474 (2-storey + 2 side extensions): worksheet
says ns=2; we previously had to pass `storey_count=fixture.LINE_9_STOREYS`
explicitly in the §2 Elmhurst conformance test. With the fix, the test
now derives `storey_count` from `dims.storey_count` and the
`LINE_9_STOREYS` field cross-checks the derivation against (9).

Tests:
- New: dwelling_storey_count_is_max_across_parts_not_sum (2-storey main
  + 1-storey ext expects ns=2)
- New: room_in_roof_on_main_adds_one_to_dwelling_storey_count_only_once
  (main with RR + ext without RR expects ns=3, not 5)
- Updated: main_plus_extension_sums_areas_perimeters_and_walls assertion
  ns==2 → ns==1 (both parts single-storey)
- Updated: all_rir_shapes_apply_section_1_2_45m_convention_uniformly —
  storey_delta is now ≤1 not len(parts_with_rr); TFA/volume deltas
  remain Σ per the spec
- Updated: §2 Elmhurst test consumes dims.storey_count + asserts
  dims.storey_count == fixture.LINE_9_STOREYS as an Arrange precondition

826 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-20 10:27:38 +00:00
parent 883028c89e
commit 6ea5727a4e
3 changed files with 113 additions and 17 deletions

View file

@ -98,20 +98,22 @@ def dimensions_from_cert(epc: EpcPropertyData) -> Dimensions:
top_area = 0.0
gross_wall = 0.0
party_wall = 0.0
total_storey_count = 0
# SAP §2 (9) "ns" is dwelling height (tallest part), NOT Σ across parts —
# the (10) additional-infiltration adjustment otherwise inflates by 0.1
# per spurious storey. Track per-part counts and take the max below.
part_storey_counts: list[int] = []
for part in parts:
ground = _part_ground_floor(part)
top = _part_top_floor(part)
if ground is None or top is None:
continue
part_height = _part_avg_storey_height_m(part)
part_storeys = _part_storey_count(part)
part_floor_count = _part_storey_count(part)
ground_area += ground.total_floor_area_m2 or 0.0
ground_perim += ground.heat_loss_perimeter_m or 0.0
top_area += top.total_floor_area_m2 or 0.0
gross_wall += (ground.heat_loss_perimeter_m or 0.0) * part_height * part_storeys
party_wall += (ground.party_wall_length_m or 0.0) * part_height * part_storeys
total_storey_count += part_storeys
gross_wall += (ground.heat_loss_perimeter_m or 0.0) * part_height * part_floor_count
party_wall += (ground.party_wall_length_m or 0.0) * part_height * part_floor_count
for fd in part.sap_floor_dimensions:
fa = fd.total_floor_area_m2 or 0.0
fh = fd.room_height_m or _DEFAULT_STOREY_HEIGHT_M
@ -127,12 +129,16 @@ def dimensions_from_cert(epc: EpcPropertyData) -> Dimensions:
# Figure 4) — neither path appears in current corpus, but
# downstream calcs will silently use 2.45 m if we hit one.
rir = part.sap_room_in_roof
rir_adds_storey = 0
if rir is not None and rir.floor_area > 0:
sum_per_storey_area_m2 += rir.floor_area
sum_per_storey_volume_m3 += (
rir.floor_area * _RR_SIMPLIFIED_STOREY_HEIGHT_M
)
total_storey_count += 1
rir_adds_storey = 1
part_storey_counts.append(part_floor_count + rir_adds_storey)
total_storey_count = max(part_storey_counts) if part_storey_counts else 0
has_storeys = sum_per_storey_area_m2 > 0
avg_height = (

View file

@ -136,7 +136,89 @@ def test_main_plus_extension_sums_areas_perimeters_and_walls() -> None:
assert result.gross_wall_area_m2 == pytest.approx(138.4, abs=0.05)
# 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)
assert result.storey_count == 2 # one storey per part, two parts
# 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_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:
@ -349,16 +431,20 @@ def test_all_rir_shapes_apply_section_1_2_45m_convention_uniformly(
result_with_rr = dimensions_from_cert(epc)
result_without_rr = dimensions_from_cert(_strip_room_in_roof(epc))
# Assert — the RR contribution is exactly the spec convention. One
# storey added per part that carries a sap_room_in_roof block (a
# detached + extension can both have an attic conversion).
# 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 == len(parts_with_rr)
assert storey_delta == 1
from types import ModuleType # noqa: E402 (kept near the Elmhurst tests)

View file

@ -472,20 +472,24 @@ def test_section_2_matches_elmhurst_worksheet(fixture: ModuleType) -> None:
"""Real Elmhurst SAP10.2 worksheets — asserts every populated §2 line
ref against the worksheet output for each registered fixture.
`storey_count` and `sheltered_sides` come from the fixture (Elmhurst
quirks: ns=3 here is dwelling height not Σ parts, sheltered_sides
varies per cert). The HAS_SUSPENDED_TIMBER_FLOOR flag also varies
some Elmhurst assessors lodge "Suspended Timber" floor U-value while
ticking (12) = 0.0 (treat as effectively sealed).
`sheltered_sides` and HAS_SUSPENDED_TIMBER_FLOOR vary per cert and are
carried on the fixture. `storey_count` is now derived from
`dims.storey_count` (post-max-semantic fix); the LINE_9_STOREYS field
on each fixture cross-checks that the derivation matches the
worksheet's (9) value.
"""
# Arrange
from domain.sap.worksheet.dimensions import dimensions_from_cert
dims = dimensions_from_cert(fixture.build_epc())
assert dims.storey_count == fixture.LINE_9_STOREYS, (
f"dims.storey_count={dims.storey_count} should equal worksheet (9) "
f"= {fixture.LINE_9_STOREYS}"
)
# Act
result = ventilation_from_inputs(
volume_m3=dims.volume_m3,
storey_count=fixture.LINE_9_STOREYS,
storey_count=dims.storey_count,
is_timber_or_steel_frame=False,
intermittent_fans=fixture.INTERMITTENT_FANS,
has_suspended_timber_floor=fixture.HAS_SUSPENDED_TIMBER_FLOOR,