diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 38197adb..ca7e035e 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -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 diff --git a/packages/domain/src/domain/sap/worksheet/heat_transmission.py b/packages/domain/src/domain/sap/worksheet/heat_transmission.py index aa97f647..b9415906 100644 --- a/packages/domain/src/domain/sap/worksheet/heat_transmission.py +++ b/packages/domain/src/domain/sap/worksheet/heat_transmission.py @@ -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 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 b39c0adf..ee06987d 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 @@ -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)