mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Cohort residual slice 12: Simplified Type 2 RR geometry (common walls <1.8m)
Extends `SapRoomInRoof` with six optional fields capturing the RdSAP10
§3.9.2 Simplified Type 2 lodgement: common_wall_length_m / height_m
plus two gable length/height pairs.
Type 2 fires when `common_wall_height_m` is set and < 1.8 m (otherwise
the space is a separate storey). Geometry per spec page 23:
A_common_wall = L × (0.25 + H)
A_gable = L × (0.25 + H_gable)
− Σ ((H_gable − H_common_wall_i)² / 2)
A_RR_final = A_RR − Σ A_common_wall − Σ A_gable
(− party / sheltered / connected when lodged, future
slice when a fixture exercises them)
Common walls and gables route to walls_w_per_k at U_main_wall (per spec:
"Common wall U-value is inferred from the U-value of the main wall in
the building part below"). A_RR_final routes to roof_w_per_k at
u_rr_default_all_elements (Table 18 col 4).
Synthetic test: 1-storey cavity-uninsulated dwelling at age B + RR
(floor 10 m², common_wall_length 5 m × 1 m height). Pins
walls_w_per_k = 60 × 1.5 + 6.25 × 1.5 = 99.375 W/K and
roof_w_per_k = 30 × 0.40 + 26.025 × 2.30 = 71.857 W/K at abs=0.001.
No production fixture exercises Type 2 yet — synthetic test is the
unit-level guard until a Type 2 cert lands in the corpus.
Reference: RdSAP 10 (10-06-2025) §3.9.2 page 22-23.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
4df056859e
commit
3ff864bf86
3 changed files with 129 additions and 8 deletions
|
|
@ -267,6 +267,19 @@ class SapFloorDimension:
|
|||
class SapRoomInRoof:
|
||||
floor_area: Union[int, float]
|
||||
construction_age_band: str
|
||||
# RdSAP10 §3.9.2 Simplified Type 2 — RR built into a roof space that
|
||||
# has continuous common walls outside the RR boundaries. The space is
|
||||
# treated as Room-in-Roof when the height of accessible common walls
|
||||
# is < 1.8 m (otherwise it counts as a separate storey).
|
||||
common_wall_length_m: Optional[float] = None
|
||||
common_wall_height_m: Optional[float] = None
|
||||
# Optional gable lengths/heights for the Type 2 quadratic correction:
|
||||
# A_gable = L × (0.25 + H) − Σ ((H − H_common_wall_i)² / 2)
|
||||
# If absent, the gable contribution is 0 (Simplified Type 1).
|
||||
gable_1_length_m: Optional[float] = None
|
||||
gable_1_height_m: Optional[float] = None
|
||||
gable_2_length_m: Optional[float] = None
|
||||
gable_2_height_m: Optional[float] = None
|
||||
|
||||
|
||||
# RdSAP10 wall_construction integer encoding. The gov-EPC API doesn't publish
|
||||
|
|
|
|||
|
|
@ -180,10 +180,40 @@ def _part_geometry(part: SapBuildingPart) -> dict[str, float]:
|
|||
# their own U-values, deducted from A_RR_final per step e.
|
||||
rr_floor_area = 0.0
|
||||
rr_a_rr = 0.0
|
||||
rr_common_wall_area = 0.0
|
||||
rr_gable_area = 0.0
|
||||
rir = part.sap_room_in_roof
|
||||
if rir is not None and rir.floor_area > 0:
|
||||
rr_floor_area = float(rir.floor_area)
|
||||
rr_a_rr = 12.5 * sqrt(rr_floor_area / 1.5)
|
||||
# RdSAP10 §3.9.2 Simplified Type 2 — accessible common walls
|
||||
# under 1.8 m treat the space as RR. Common wall area = L ×
|
||||
# (0.25 + H). The 0.25 m accounts for the structural gap between
|
||||
# RR floor and the storey-below ceiling.
|
||||
if rir.common_wall_height_m is not None and rir.common_wall_length_m is not None:
|
||||
rr_common_wall_area = (
|
||||
rir.common_wall_length_m * (0.25 + rir.common_wall_height_m)
|
||||
)
|
||||
# Gable walls of the Type 2 RR. Quadratic correction subtracts
|
||||
# the triangular slice above each common wall:
|
||||
# A_gable = L × (0.25 + H_gable) − ((H_gable − H_common_wall)² / 2)
|
||||
# summed across up to two common walls bordering the gable.
|
||||
h_common = rir.common_wall_height_m
|
||||
for gable_length, gable_height in (
|
||||
(rir.gable_1_length_m, rir.gable_1_height_m),
|
||||
(rir.gable_2_length_m, rir.gable_2_height_m),
|
||||
):
|
||||
if gable_length is None or gable_height is None:
|
||||
continue
|
||||
area = gable_length * (0.25 + gable_height)
|
||||
if h_common is not None:
|
||||
# The spec uses two common-wall heights in the
|
||||
# correction; we apply twice in the absence of separate
|
||||
# H_common_wall_1 / H_common_wall_2 lodgement (the modal
|
||||
# case is a symmetric gable).
|
||||
correction = 2.0 * ((gable_height - h_common) ** 2) / 2.0
|
||||
area = max(0.0, area - correction)
|
||||
rr_gable_area += area
|
||||
return {
|
||||
"ground_floor_area_m2": ground.total_floor_area_m2 or 0.0,
|
||||
"top_floor_area_m2": max(0.0, (top.total_floor_area_m2 or 0.0) - rr_floor_area),
|
||||
|
|
@ -191,6 +221,8 @@ def _part_geometry(part: SapBuildingPart) -> dict[str, float]:
|
|||
"party_wall_area_m2": party_wall,
|
||||
"rr_floor_area_m2": rr_floor_area,
|
||||
"rr_simplified_a_rr_m2": rr_a_rr,
|
||||
"rr_common_wall_area_m2": rr_common_wall_area,
|
||||
"rr_gable_area_m2": rr_gable_area,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -349,21 +381,28 @@ def heat_transmission_from_cert(
|
|||
|
||||
walls += uw * main_wall_area + alt_walls_contribution
|
||||
roof += ur * roof_area
|
||||
# RdSAP10 §3.9.1 Simplified RR contribution. A_RR is treated as
|
||||
# timber-framed roof structure with U from Table 18 column (4)
|
||||
# "Room-in-roof, all elements" (the as-built / unknown default
|
||||
# per footnote (1)). The age band lives on the SapRoomInRoof, not
|
||||
# the SapBuildingPart, so we read it from there. Lines under (30)
|
||||
# in the U985 worksheet — same line ref as the regular roof, so
|
||||
# we fold the contribution into roof_w_per_k.
|
||||
# RdSAP10 §3.9.1/§3.9.2 Simplified RR contribution. A_RR is the
|
||||
# total RR exposed area (12.5 × √(A_RR_floor / 1.5)); from that,
|
||||
# Type 2 common walls (and any gables) are deducted to obtain
|
||||
# A_RR_final, treated as timber-framed roof structure with U from
|
||||
# Table 18 column (4) "Room-in-roof, all elements". The age band
|
||||
# lives on the SapRoomInRoof, not the SapBuildingPart, so we read
|
||||
# it from there. Common walls + gables of the RR contribute at
|
||||
# U_main_wall per spec page 23 ("Common wall U-value is inferred
|
||||
# from the U-value of the main wall in the building part below";
|
||||
# gables fall under the same Table 4 rule).
|
||||
rr_a_rr = geom["rr_simplified_a_rr_m2"]
|
||||
rr_common = geom["rr_common_wall_area_m2"]
|
||||
rr_gable = geom["rr_gable_area_m2"]
|
||||
if rr_a_rr > 0:
|
||||
rir = part.sap_room_in_roof
|
||||
assert rir is not None # rr_a_rr > 0 ⇒ rir present per _part_geometry
|
||||
walls += uw * (rr_common + rr_gable)
|
||||
a_rr_final = max(0.0, rr_a_rr - rr_common - rr_gable)
|
||||
u_rr = u_rr_default_all_elements(
|
||||
country=country, age_band=rir.construction_age_band,
|
||||
)
|
||||
roof += u_rr * rr_a_rr
|
||||
roof += u_rr * a_rr_final
|
||||
floor += uf * floor_area_total
|
||||
party += upw * party_area
|
||||
windows += window_u * w_area
|
||||
|
|
|
|||
|
|
@ -1368,3 +1368,72 @@ def test_room_in_roof_simplified_type_1_adds_a_rr_timber_framed_area_to_roof_w_p
|
|||
a_rr = 12.5 * math.sqrt(15.0 / 1.5)
|
||||
expected_roof_w_per_k = (40.0 - 15.0) * 0.40 + a_rr * 2.30
|
||||
assert result.roof_w_per_k == pytest.approx(expected_roof_w_per_k, abs=0.001)
|
||||
|
||||
|
||||
def test_room_in_roof_simplified_type_2_common_walls_route_to_walls_w_per_k() -> None:
|
||||
"""RdSAP10 §3.9.2 Simplified Type 2 (RR with accessible common walls
|
||||
under 1.8 m). Per spec page 23:
|
||||
A_common_wall = L_common_wall × (0.25 + H_common_wall)
|
||||
A_RR = 12.5 × √(A_RR_floor / 1.5) (same as Type 1)
|
||||
A_RR_final = A_RR − ΣA_common_wall (no gables in this test)
|
||||
|
||||
Common walls of the RR contribute at U_common = U_main_wall (the
|
||||
building part's own wall U-value, inferred per spec "Common wall
|
||||
U-value is inferred from the U-value of the main wall in the
|
||||
building part below"). A_RR_final is timber-framed roof structure at
|
||||
U_RR_default (Table 18 col 4).
|
||||
|
||||
Synthetic dwelling:
|
||||
- 1 storey × 40 m², 24 m perimeter, 2.5 m height → gross_wall = 60.
|
||||
- Cavity uninsulated age B → U_wall = 1.5 (Table 6 cavity as-built).
|
||||
- RR floor_area=10, common_wall_length_m=5.0, common_wall_height_m=1.0.
|
||||
- No gables, no windows, no doors.
|
||||
|
||||
Expected:
|
||||
A_common_wall = 5 × (0.25 + 1.0) = 6.25 m²
|
||||
A_RR = 12.5 × √(10/1.5) ≈ 32.275 m²
|
||||
A_RR_final = 32.275 − 6.25 = 26.025 m²
|
||||
walls_w_per_k = 60 × 1.5 + 6.25 × 1.5 = 99.375 W/K
|
||||
roof_w_per_k = (40 − 10) × 0.40 + 26.025 × 2.30
|
||||
= 12.0 + 59.857 = 71.857 W/K
|
||||
"""
|
||||
# Arrange
|
||||
import math
|
||||
main = make_building_part(
|
||||
identifier=BuildingPartIdentifier.MAIN,
|
||||
construction_age_band="B",
|
||||
wall_construction=4, # WALL_CAVITY
|
||||
wall_insulation_type=4, # "as-built / assumed" → no insulation
|
||||
party_wall_construction=1,
|
||||
roof_construction=4,
|
||||
floor_dimensions=[
|
||||
make_floor_dimension(
|
||||
total_floor_area_m2=40.0, room_height_m=2.5,
|
||||
heat_loss_perimeter_m=24.0, party_wall_length_m=0.0, floor=0,
|
||||
),
|
||||
],
|
||||
sap_room_in_roof=SapRoomInRoof(
|
||||
floor_area=10.0, construction_age_band="B",
|
||||
common_wall_length_m=5.0, common_wall_height_m=1.0,
|
||||
),
|
||||
)
|
||||
epc = make_minimal_sap10_epc(
|
||||
total_floor_area_m2=50.0, # 40 + 10
|
||||
habitable_rooms_count=3,
|
||||
country_code="ENG",
|
||||
sap_building_parts=[main],
|
||||
)
|
||||
|
||||
# Act
|
||||
result = heat_transmission_from_cert(
|
||||
epc, window_total_area_m2=0.0, window_avg_u_value=None, door_count=0,
|
||||
)
|
||||
|
||||
# Assert
|
||||
a_common = 5.0 * (0.25 + 1.0)
|
||||
a_rr = 12.5 * math.sqrt(10.0 / 1.5)
|
||||
a_rr_final = a_rr - a_common
|
||||
expected_walls = 60.0 * 1.5 + a_common * 1.5
|
||||
expected_roof = (40.0 - 10.0) * 0.40 + a_rr_final * 2.30
|
||||
assert result.walls_w_per_k == pytest.approx(expected_walls, abs=0.001)
|
||||
assert result.roof_w_per_k == pytest.approx(expected_roof, abs=0.001)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue