mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Cohort residual slice 13: Detailed §3.10 RR geometry — per-surface lodgement
Adds `SapRoomInRoofSurface` dataclass (kind + area + insulation thickness
+ insulation type) and an optional `detailed_surfaces` list on
`SapRoomInRoof`. When `detailed_surfaces` is present, the Simplified
A_RR formula is bypassed and the calculator iterates each surface,
applying the appropriate Table 17 / Table 4 U-value:
slope → roof_w_per_k via u_rr_slope (Table 17 col 1)
flat_ceiling → roof_w_per_k via u_rr_flat_ceiling (Table 17 col 2)
stud_wall → roof_w_per_k via u_rr_stud_wall (Table 17 col 3)
gable_wall → party_walls_w_per_k at U=0.25 (Table 4 "as
common wall")
This mapping mirrors the U985 worksheet for 000477 where RR stud walls
+ slope + flat-ceiling lines sit under (30) and RR gable walls sit
under (32). The §3.9 deduction of `A_RR_floor` from the storey-below
roof area still applies.
Synthetic test pins a 1-storey + RR dwelling with 4 detailed surfaces
(slope/stud_wall/flat_ceiling/gable_wall) at hand-computed U-values
from Table 17 and Table 4, abs=0.001 tolerance.
Reference: RdSAP 10 (10-06-2025) §3.10 page 24-25; Figure 4; Table 17
page 44; Table 4 page 22.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
3ff864bf86
commit
1928e5a2d6
3 changed files with 157 additions and 2 deletions
|
|
@ -263,6 +263,22 @@ class SapFloorDimension:
|
|||
is_exposed_floor: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SapRoomInRoofSurface:
|
||||
"""One surface lodged via the RdSAP10 §3.10 Detailed measurement path.
|
||||
|
||||
Each RR can carry up to two of each surface kind (flat ceiling,
|
||||
sloping ceiling, stud wall, gable wall) per spec Figure 4. The U-value
|
||||
is resolved from Table 17 when `insulation_thickness_mm` is set, or
|
||||
Table 18 col (4) age-band default otherwise.
|
||||
"""
|
||||
|
||||
kind: str # "slope" | "flat_ceiling" | "stud_wall" | "gable_wall"
|
||||
area_m2: float
|
||||
insulation_thickness_mm: Optional[int] = None
|
||||
insulation_type: Optional[str] = None # "mineral_wool" / "eps" / "pur" / "pir"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SapRoomInRoof:
|
||||
floor_area: Union[int, float]
|
||||
|
|
@ -280,6 +296,11 @@ class SapRoomInRoof:
|
|||
gable_1_height_m: Optional[float] = None
|
||||
gable_2_length_m: Optional[float] = None
|
||||
gable_2_height_m: Optional[float] = None
|
||||
# RdSAP10 §3.10 Detailed measurement path. When `detailed_surfaces` is
|
||||
# set, each entry contributes A × U directly and the Simplified A_RR
|
||||
# formula is bypassed. The storey-below roof area still deducts
|
||||
# `floor_area` per §3.9.
|
||||
detailed_surfaces: Optional[List[SapRoomInRoofSurface]] = None
|
||||
|
||||
|
||||
# RdSAP10 wall_construction integer encoding. The gov-EPC API doesn't publish
|
||||
|
|
|
|||
|
|
@ -57,6 +57,9 @@ from domain.ml.rdsap_uvalues import (
|
|||
u_party_wall,
|
||||
u_roof,
|
||||
u_rr_default_all_elements,
|
||||
u_rr_flat_ceiling,
|
||||
u_rr_slope,
|
||||
u_rr_stud_wall,
|
||||
u_wall,
|
||||
u_window,
|
||||
)
|
||||
|
|
@ -185,7 +188,11 @@ def _part_geometry(part: SapBuildingPart) -> dict[str, float]:
|
|||
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)
|
||||
# Simplified A_RR formula only fires when no Detailed (§3.10)
|
||||
# per-surface lodgement is present. With Detailed lodgement the
|
||||
# main loop iterates `rir.detailed_surfaces` directly.
|
||||
if not rir.detailed_surfaces:
|
||||
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
|
||||
|
|
@ -394,6 +401,7 @@ def heat_transmission_from_cert(
|
|||
rr_a_rr = geom["rr_simplified_a_rr_m2"]
|
||||
rr_common = geom["rr_common_wall_area_m2"]
|
||||
rr_gable = geom["rr_gable_area_m2"]
|
||||
rr_detailed_area = 0.0
|
||||
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
|
||||
|
|
@ -403,6 +411,39 @@ def heat_transmission_from_cert(
|
|||
country=country, age_band=rir.construction_age_band,
|
||||
)
|
||||
roof += u_rr * a_rr_final
|
||||
elif part.sap_room_in_roof is not None and part.sap_room_in_roof.detailed_surfaces:
|
||||
# RdSAP10 §3.10 Detailed RR — iterate per-surface lodgement.
|
||||
# Slope / flat_ceiling / stud_wall route to roof (worksheet
|
||||
# line (30)); gable_wall routes to party_walls at U=0.25
|
||||
# (worksheet line (32), per Table 4 "as common wall").
|
||||
rir = part.sap_room_in_roof
|
||||
for surf in rir.detailed_surfaces:
|
||||
kind = surf.kind
|
||||
area = surf.area_m2
|
||||
rr_detailed_area += area
|
||||
if kind == "slope":
|
||||
roof += area * u_rr_slope(
|
||||
country=country, age_band=rir.construction_age_band,
|
||||
insulation_thickness_mm=surf.insulation_thickness_mm,
|
||||
insulation_type=surf.insulation_type,
|
||||
)
|
||||
elif kind == "flat_ceiling":
|
||||
roof += area * u_rr_flat_ceiling(
|
||||
country=country, age_band=rir.construction_age_band,
|
||||
insulation_thickness_mm=surf.insulation_thickness_mm,
|
||||
insulation_type=surf.insulation_type,
|
||||
)
|
||||
elif kind == "stud_wall":
|
||||
roof += area * u_rr_stud_wall(
|
||||
country=country, age_band=rir.construction_age_band,
|
||||
insulation_thickness_mm=surf.insulation_thickness_mm,
|
||||
insulation_type=surf.insulation_type,
|
||||
)
|
||||
elif kind == "gable_wall":
|
||||
# Treated as party-style at U=0.25 per Table 4. Lines
|
||||
# 196-197 of the U985 worksheet for 000477 confirm
|
||||
# this — RR gable walls land under (32) with U=0.25.
|
||||
party += 0.25 * area
|
||||
floor += uf * floor_area_total
|
||||
party += upw * party_area
|
||||
windows += window_u * w_area
|
||||
|
|
@ -414,7 +455,7 @@ def heat_transmission_from_cert(
|
|||
# the external surfaces per the spec — A_RR contributes to (31)
|
||||
# alongside walls + roof + floor + openings.
|
||||
part_external_area = (
|
||||
main_wall_area + alt_walls_total_area + roof_area + floor_area_total + w_area + d_area + rr_a_rr
|
||||
main_wall_area + alt_walls_total_area + roof_area + floor_area_total + w_area + d_area + rr_a_rr + rr_detailed_area
|
||||
)
|
||||
total_external_area += part_external_area
|
||||
bridging += y * part_external_area
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from datatypes.epc.domain.epc_property_data import (
|
|||
EnergyElement,
|
||||
SapAlternativeWall,
|
||||
SapRoomInRoof,
|
||||
SapRoomInRoofSurface,
|
||||
)
|
||||
|
||||
from domain.ml.tests._fixtures import (
|
||||
|
|
@ -1437,3 +1438,95 @@ def test_room_in_roof_simplified_type_2_common_walls_route_to_walls_w_per_k() ->
|
|||
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)
|
||||
|
||||
|
||||
def test_room_in_roof_detailed_per_surface_lodgement_routes_each_to_correct_line_ref() -> None:
|
||||
"""RdSAP10 §3.10 Detailed measurement path. When a SapRoomInRoof
|
||||
lodges `detailed_surfaces`, the Simplified A_RR formula is bypassed
|
||||
and each surface contributes A × U directly via Tables 17 / 18:
|
||||
|
||||
slope → roof_w_per_k at u_rr_slope by thickness/type
|
||||
flat_ceiling → roof_w_per_k at u_rr_flat_ceiling
|
||||
stud_wall → roof_w_per_k at u_rr_stud_wall
|
||||
gable_wall → party_walls_w_per_k at U=0.25 (Table 4 "as common wall")
|
||||
|
||||
The mapping mirrors the U985 worksheet for 000477 where RR stud walls
|
||||
+ slope + flat-ceiling all sit under (30) and RR gable walls sit
|
||||
under (32) at U=0.25.
|
||||
|
||||
Synthetic dwelling (no Type 2 common walls):
|
||||
- 1 storey × 40 m², 24 m perimeter, 2.5 m height; cavity uninsulated
|
||||
age B → U_wall = 1.5; gross_wall = 60 m².
|
||||
- RR floor_area=10, detailed surfaces:
|
||||
slope 10 m², 100 mm mineral wool → Table 17 col 1a = 0.40
|
||||
stud_wall 5 m², 100 mm mineral wool → Table 17 col 3a = 0.36
|
||||
flat_ceiling 8 m², 200 mm mineral wool → Table 17 col 2a = 0.29
|
||||
gable_wall 7 m², (treated as party) → U = 0.25
|
||||
- No windows, no doors.
|
||||
|
||||
Expected:
|
||||
roof_w_per_k = (40 − 10) × 0.40 + 10 × 0.40 + 5 × 0.36 + 8 × 0.29
|
||||
= 12.0 + 4.00 + 1.80 + 2.32 = 20.12 W/K
|
||||
party_walls_w_per_k = 7 × 0.25 = 1.75 W/K
|
||||
walls_w_per_k = 60 × 1.5 = 90 W/K (RR detailed surfaces don't add
|
||||
here — they're all on (30)/(32))
|
||||
"""
|
||||
# Arrange
|
||||
main = make_building_part(
|
||||
identifier=BuildingPartIdentifier.MAIN,
|
||||
construction_age_band="B",
|
||||
wall_construction=4,
|
||||
wall_insulation_type=4,
|
||||
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",
|
||||
detailed_surfaces=[
|
||||
SapRoomInRoofSurface(
|
||||
kind="slope", area_m2=10.0,
|
||||
insulation_thickness_mm=100, insulation_type="mineral_wool",
|
||||
),
|
||||
SapRoomInRoofSurface(
|
||||
kind="stud_wall", area_m2=5.0,
|
||||
insulation_thickness_mm=100, insulation_type="mineral_wool",
|
||||
),
|
||||
SapRoomInRoofSurface(
|
||||
kind="flat_ceiling", area_m2=8.0,
|
||||
insulation_thickness_mm=200, insulation_type="mineral_wool",
|
||||
),
|
||||
SapRoomInRoofSurface(
|
||||
kind="gable_wall", area_m2=7.0,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
epc = make_minimal_sap10_epc(
|
||||
total_floor_area_m2=50.0,
|
||||
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
|
||||
expected_roof = (
|
||||
(40.0 - 10.0) * 0.40 # storey-below roof (post-§3.9 deduction)
|
||||
+ 10.0 * 0.40 # slope @ Table 17 (1a) 100mm
|
||||
+ 5.0 * 0.36 # stud_wall @ Table 17 (3a) 100mm
|
||||
+ 8.0 * 0.29 # flat_ceiling @ Table 17 (2a) 200mm
|
||||
)
|
||||
expected_party = 7.0 * 0.25 # gable_wall @ U_party
|
||||
expected_walls = 60.0 * 1.5
|
||||
assert result.roof_w_per_k == pytest.approx(expected_roof, abs=0.001)
|
||||
assert result.party_walls_w_per_k == pytest.approx(expected_party, abs=0.001)
|
||||
assert result.walls_w_per_k == pytest.approx(expected_walls, abs=0.001)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue