Wall + party-wall area = Σ (perim_i × height_i), not ground × avg × count

SAP §3 wall heat-loss area sums each storey individually:
`Σ (heat_loss_perimeter_i × room_height_i)`. Pre-fix used the short-cut
`ground_perimeter × avg_height × storey_count`, which over-counts upper
storeys whenever they have a smaller perimeter than the ground (set-back
top floors, ground-floor additions, etc.). RdSAP §5.10 party-wall area
follows the same per-storey-sum convention.

Surfaced by Elmhurst 000474 Main (ground perim 7.07, first 5.27): our
gross-wall over-counted by ~10 m², the (29a) W/K downstream by ~15 W/K
on this cert. Documented at the time as follow-up #2; this slice closes
it. The §3 partial-conformance test's gap-#2 entry is removed; gap #1
(RR sub-areas) remains.

Fix lives in two parallel code paths:
- dimensions.py: per-storey accumulation inside the existing fd loop
- heat_transmission.py: _part_geometry now emits gross_wall_area_m2 and
  party_wall_area_m2 directly, dropping the avg_height + storey_count
  intermediate fields (no other consumer)

Tests:
- New: gross_wall_area_sums_per_storey_perimeter_times_height_…
  (2-storey main, ground 10 m / first 6 m, same height — expects
  Σ=40 m² not ground×avg×count=50)
- New: party_wall_area_sums_per_storey_party_length_… (same shape,
  ground party 5 / first party 3 → Σ=20 not 25)
- New: walls_w_per_k_uses_sum_of_per_storey_perimeter_… (heat-
  transmission counterpart: 0.6 × 40 = 24 W/K not 30)

829 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-20 10:33:14 +00:00
parent 6ea5727a4e
commit e6c768c356
4 changed files with 121 additions and 29 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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