mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
883028c89e
commit
6ea5727a4e
3 changed files with 113 additions and 17 deletions
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue