diff --git a/packages/domain/src/domain/sap/worksheet/dimensions.py b/packages/domain/src/domain/sap/worksheet/dimensions.py index 28a7581c..56685c56 100644 --- a/packages/domain/src/domain/sap/worksheet/dimensions.py +++ b/packages/domain/src/domain/sap/worksheet/dimensions.py @@ -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 = ( diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_dimensions.py b/packages/domain/src/domain/sap/worksheet/tests/test_dimensions.py index 7b7e853b..e0cb085b 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/test_dimensions.py +++ b/packages/domain/src/domain/sap/worksheet/tests/test_dimensions.py @@ -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) diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_ventilation.py b/packages/domain/src/domain/sap/worksheet/tests/test_ventilation.py index 3db3778e..7afc1d6f 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/test_ventilation.py +++ b/packages/domain/src/domain/sap/worksheet/tests/test_ventilation.py @@ -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,