diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index ca7e035e..7c808cc7 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -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 diff --git a/packages/domain/src/domain/sap/worksheet/heat_transmission.py b/packages/domain/src/domain/sap/worksheet/heat_transmission.py index b9415906..43d6bef1 100644 --- a/packages/domain/src/domain/sap/worksheet/heat_transmission.py +++ b/packages/domain/src/domain/sap/worksheet/heat_transmission.py @@ -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 diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py b/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py index ee06987d..2aa26947 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py +++ b/packages/domain/src/domain/sap/worksheet/tests/test_heat_transmission.py @@ -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)