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