diff --git a/packages/domain/src/domain/sap/worksheet/dimensions.py b/packages/domain/src/domain/sap/worksheet/dimensions.py index 56685c56..a2c58bbd 100644 --- a/packages/domain/src/domain/sap/worksheet/dimensions.py +++ b/packages/domain/src/domain/sap/worksheet/dimensions.py @@ -112,13 +112,17 @@ def dimensions_from_cert(epc: EpcPropertyData) -> Dimensions: 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_floor_count - party_wall += (ground.party_wall_length_m or 0.0) * part_height * part_floor_count + # SAP §3 wall area: Σ (heat_loss_perimeter_i × height_i) across each + # storey of the part. Pre-fix `ground_perim × avg_height × count` + # over-counts upper storeys whenever they have a different + # perimeter (e.g. set-back top floor, Elmhurst 000474 Main). 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 sum_per_storey_area_m2 += fa sum_per_storey_volume_m3 += fa * fh + gross_wall += (fd.heat_loss_perimeter_m or 0.0) * fh + party_wall += (fd.party_wall_length_m or 0.0) * fh # Room-in-roof: counts as one additional storey per RdSAP §1.8 + # §3.9. Both failing certs in the golden suite are Simplified diff --git a/packages/domain/src/domain/sap/worksheet/heat_transmission.py b/packages/domain/src/domain/sap/worksheet/heat_transmission.py index 036d1828..ab75b0c4 100644 --- a/packages/domain/src/domain/sap/worksheet/heat_transmission.py +++ b/packages/domain/src/domain/sap/worksheet/heat_transmission.py @@ -143,29 +143,32 @@ def _part_geometry(part: SapBuildingPart) -> dict[str, float]: if not part.sap_floor_dimensions: return { "ground_floor_area_m2": 0.0, - "ground_perimeter_m": 0.0, "top_floor_area_m2": 0.0, - "party_wall_length_m": 0.0, - "avg_room_height_m": _DEFAULT_STOREY_HEIGHT_M, - "storey_count": 1.0, + "gross_wall_area_m2": 0.0, + "party_wall_area_m2": 0.0, } fds = list(part.sap_floor_dimensions) ground = next((fd for fd in fds if fd.floor == 0), fds[0]) indexed = [(fd.floor if fd.floor is not None else 0, fd) for fd in fds] top = max(indexed, key=lambda kv: kv[0])[1] - total_area = sum(fd.total_floor_area_m2 or 0.0 for fd in fds) - weighted_height = sum( - (fd.total_floor_area_m2 or 0.0) * (fd.room_height_m or _DEFAULT_STOREY_HEIGHT_M) + # SAP §3 wall area is Σ (perimeter_i × height_i) across each storey of + # the part — same convention as dimensions.gross_wall_area_m2. The + # ground-perim × avg × count short-cut over-counts upper storeys when + # the perimeter shrinks (e.g. Elmhurst 000474 Main: ground 7.07, first + # 5.27). + gross_wall = sum( + (fd.heat_loss_perimeter_m or 0.0) * (fd.room_height_m or _DEFAULT_STOREY_HEIGHT_M) + for fd in fds + ) + party_wall = sum( + (fd.party_wall_length_m or 0.0) * (fd.room_height_m or _DEFAULT_STOREY_HEIGHT_M) for fd in fds ) - avg_height = (weighted_height / total_area) if total_area > 0 else _DEFAULT_STOREY_HEIGHT_M return { "ground_floor_area_m2": ground.total_floor_area_m2 or 0.0, - "ground_perimeter_m": ground.heat_loss_perimeter_m or 0.0, "top_floor_area_m2": top.total_floor_area_m2 or 0.0, - "party_wall_length_m": ground.party_wall_length_m or 0.0, - "avg_room_height_m": avg_height, - "storey_count": float(len(fds)), + "gross_wall_area_m2": gross_wall, + "party_wall_area_m2": party_wall, } @@ -286,13 +289,11 @@ def heat_transmission_from_cert( upw = u_party_wall(party_wall_construction=party_construction) y = thermal_bridging_y(age_band=age_band) - storey_count = geom["storey_count"] - storey_height = geom["avg_room_height_m"] - gross_wall_area = geom["ground_perimeter_m"] * storey_height * storey_count + gross_wall_area = geom["gross_wall_area_m2"] w_area = window_total_area_m2 if i == 0 else 0.0 d_area = door_area if i == 0 else 0.0 net_wall_area = max(0.0, gross_wall_area - w_area - d_area) - party_area = geom["party_wall_length_m"] * storey_height * storey_count + party_area = geom["party_wall_area_m2"] roof_area = geom["top_floor_area_m2"] if exposure.has_exposed_roof else 0.0 floor_area_total = geom["ground_floor_area_m2"] if exposure.has_exposed_floor else 0.0 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 e0cb085b..88811585 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/test_dimensions.py +++ b/packages/domain/src/domain/sap/worksheet/tests/test_dimensions.py @@ -180,6 +180,65 @@ def test_dwelling_storey_count_is_max_across_parts_not_sum() -> None: 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 diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py b/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py index 9f3165fe..4960166e 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py +++ b/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py @@ -518,6 +518,40 @@ def test_thermal_bridging_drops_for_newer_age_band_per_table_21() -> None: assert bridging_m == pytest.approx(bridging_g * 0.08 / 0.15, abs=2.0) +def test_walls_w_per_k_uses_sum_of_per_storey_perimeter_times_height_not_ground_perim_times_avg() -> None: + # Arrange — 2-storey age G cavity, upper storey set back so the + # ground perimeter (10 m) exceeds the first-floor perimeter (6 m). + # Both storey heights 2.5 m. Σ (perim × height) = 10×2.5 + 6×2.5 = 40 + # m² gross wall (no windows/doors → 40 m² net). U_wall(G, cavity) = 0.6. + # Expected walls_w_per_k = 0.6 × 40 = 24. Pre-fix used + # ground_perim × avg × count = 10 × 2.5 × 2 = 50 m² → 30 W/K (overstates). + main = make_building_part( + identifier=BuildingPartIdentifier.MAIN, + construction_age_band="G", + wall_construction=4, wall_insulation_type=4, + party_wall_construction=1, roof_construction=4, + 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, country_code="ENG", sap_building_parts=[main], + ) + + # Act + result = heat_transmission_from_cert(epc) + + # Assert — 0.6 × 40 = 24, not 30. + assert result.walls_w_per_k == pytest.approx(24.0, abs=0.5) + + def test_main_plus_extension_sums_per_element_contributions() -> None: # Arrange — Main + single-storey age L extension. Each contributes to the # element totals. With_extension > main_only on every populated field @@ -1079,12 +1113,7 @@ def test_section_3_partial_match_against_elmhurst_worksheet(fixture: ModuleType) Known divergences: 1. RR walls not computed → smaller (33), smaller (31) for RR fixtures - 2. Per-storey-different heat-loss perimeters not handled — our code - does `ground_perim × avg_height × storey_count` which over-counts - when upper storeys are smaller than the ground (surfaced by - worksheet 000474 where Main has ground perim 7.07 / first 5.27). - Right formula: Σ (perim_i × height_i). Tracked as follow-up. - 3. Window U-value is per-window in Elmhurst; we pass an area-weighted + 2. Window U-value is per-window in Elmhurst; we pass an area-weighted raw U so our effective transform approximates (27) """ # Arrange — every Elmhurst fixture has known window U=1.4 raw on @@ -1110,9 +1139,8 @@ def test_section_3_partial_match_against_elmhurst_worksheet(fixture: ModuleType) assert result.total_w_per_k == pytest.approx( result.fabric_heat_loss_w_per_k + result.thermal_bridging_w_per_k, rel=1e-9 ) - # External-area divergence direction depends on the fixture: - # - RR fixtures: ours < worksheet (RR walls missing — gap #1) - # - Non-RR with non-constant per-storey perim: ours > worksheet - # (gap #2 — wall-area over-count). Just check non-zero until both - # fixes land. + # External-area: RR fixtures still under-count vs worksheet because RR + # sub-areas are not modelled (gap #1). Non-RR fixtures should match + # the worksheet's (31) once the per-storey-perimeter fix lands; for + # now keep the looser non-zero check until RR closes. assert result.total_external_element_area_m2 > 0