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_area += ground.total_floor_area_m2 or 0.0
|
||||||
ground_perim += ground.heat_loss_perimeter_m or 0.0
|
ground_perim += ground.heat_loss_perimeter_m or 0.0
|
||||||
top_area += top.total_floor_area_m2 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
|
# SAP §3 wall area: Σ (heat_loss_perimeter_i × height_i) across each
|
||||||
party_wall += (ground.party_wall_length_m or 0.0) * part_height * part_floor_count
|
# 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:
|
for fd in part.sap_floor_dimensions:
|
||||||
fa = fd.total_floor_area_m2 or 0.0
|
fa = fd.total_floor_area_m2 or 0.0
|
||||||
fh = fd.room_height_m or _DEFAULT_STOREY_HEIGHT_M
|
fh = fd.room_height_m or _DEFAULT_STOREY_HEIGHT_M
|
||||||
sum_per_storey_area_m2 += fa
|
sum_per_storey_area_m2 += fa
|
||||||
sum_per_storey_volume_m3 += fa * fh
|
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 +
|
# Room-in-roof: counts as one additional storey per RdSAP §1.8 +
|
||||||
# §3.9. Both failing certs in the golden suite are Simplified
|
# §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:
|
if not part.sap_floor_dimensions:
|
||||||
return {
|
return {
|
||||||
"ground_floor_area_m2": 0.0,
|
"ground_floor_area_m2": 0.0,
|
||||||
"ground_perimeter_m": 0.0,
|
|
||||||
"top_floor_area_m2": 0.0,
|
"top_floor_area_m2": 0.0,
|
||||||
"party_wall_length_m": 0.0,
|
"gross_wall_area_m2": 0.0,
|
||||||
"avg_room_height_m": _DEFAULT_STOREY_HEIGHT_M,
|
"party_wall_area_m2": 0.0,
|
||||||
"storey_count": 1.0,
|
|
||||||
}
|
}
|
||||||
fds = list(part.sap_floor_dimensions)
|
fds = list(part.sap_floor_dimensions)
|
||||||
ground = next((fd for fd in fds if fd.floor == 0), fds[0])
|
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]
|
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]
|
top = max(indexed, key=lambda kv: kv[0])[1]
|
||||||
total_area = sum(fd.total_floor_area_m2 or 0.0 for fd in fds)
|
# SAP §3 wall area is Σ (perimeter_i × height_i) across each storey of
|
||||||
weighted_height = sum(
|
# the part — same convention as dimensions.gross_wall_area_m2. The
|
||||||
(fd.total_floor_area_m2 or 0.0) * (fd.room_height_m or _DEFAULT_STOREY_HEIGHT_M)
|
# 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
|
for fd in fds
|
||||||
)
|
)
|
||||||
avg_height = (weighted_height / total_area) if total_area > 0 else _DEFAULT_STOREY_HEIGHT_M
|
|
||||||
return {
|
return {
|
||||||
"ground_floor_area_m2": ground.total_floor_area_m2 or 0.0,
|
"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,
|
"top_floor_area_m2": top.total_floor_area_m2 or 0.0,
|
||||||
"party_wall_length_m": ground.party_wall_length_m or 0.0,
|
"gross_wall_area_m2": gross_wall,
|
||||||
"avg_room_height_m": avg_height,
|
"party_wall_area_m2": party_wall,
|
||||||
"storey_count": float(len(fds)),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -286,13 +289,11 @@ def heat_transmission_from_cert(
|
||||||
upw = u_party_wall(party_wall_construction=party_construction)
|
upw = u_party_wall(party_wall_construction=party_construction)
|
||||||
y = thermal_bridging_y(age_band=age_band)
|
y = thermal_bridging_y(age_band=age_band)
|
||||||
|
|
||||||
storey_count = geom["storey_count"]
|
gross_wall_area = geom["gross_wall_area_m2"]
|
||||||
storey_height = geom["avg_room_height_m"]
|
|
||||||
gross_wall_area = geom["ground_perimeter_m"] * storey_height * storey_count
|
|
||||||
w_area = window_total_area_m2 if i == 0 else 0.0
|
w_area = window_total_area_m2 if i == 0 else 0.0
|
||||||
d_area = door_area 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)
|
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
|
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
|
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
|
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:
|
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
|
# Arrange — Main 2-storey with RR + same-height 2-storey extension
|
||||||
# without RR. RR adds one storey to MAIN (giving 3), extension stays
|
# 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)
|
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:
|
def test_main_plus_extension_sums_per_element_contributions() -> None:
|
||||||
# Arrange — Main + single-storey age L extension. Each contributes to the
|
# Arrange — Main + single-storey age L extension. Each contributes to the
|
||||||
# element totals. With_extension > main_only on every populated field
|
# 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:
|
Known divergences:
|
||||||
1. RR walls not computed → smaller (33), smaller (31) for RR fixtures
|
1. RR walls not computed → smaller (33), smaller (31) for RR fixtures
|
||||||
2. Per-storey-different heat-loss perimeters not handled — our code
|
2. Window U-value is per-window in Elmhurst; we pass an area-weighted
|
||||||
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
|
|
||||||
raw U so our effective transform approximates (27)
|
raw U so our effective transform approximates (27)
|
||||||
"""
|
"""
|
||||||
# Arrange — every Elmhurst fixture has known window U=1.4 raw on
|
# 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(
|
assert result.total_w_per_k == pytest.approx(
|
||||||
result.fabric_heat_loss_w_per_k + result.thermal_bridging_w_per_k, rel=1e-9
|
result.fabric_heat_loss_w_per_k + result.thermal_bridging_w_per_k, rel=1e-9
|
||||||
)
|
)
|
||||||
# External-area divergence direction depends on the fixture:
|
# External-area: RR fixtures still under-count vs worksheet because RR
|
||||||
# - RR fixtures: ours < worksheet (RR walls missing — gap #1)
|
# sub-areas are not modelled (gap #1). Non-RR fixtures should match
|
||||||
# - Non-RR with non-constant per-storey perim: ours > worksheet
|
# the worksheet's (31) once the per-storey-perimeter fix lands; for
|
||||||
# (gap #2 — wall-area over-count). Just check non-zero until both
|
# now keep the looser non-zero check until RR closes.
|
||||||
# fixes land.
|
|
||||||
assert result.total_external_element_area_m2 > 0
|
assert result.total_external_element_area_m2 > 0
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue