diff --git a/packages/domain/src/domain/sap/worksheet/heat_transmission.py b/packages/domain/src/domain/sap/worksheet/heat_transmission.py index eb68138f..aa97f647 100644 --- a/packages/domain/src/domain/sap/worksheet/heat_transmission.py +++ b/packages/domain/src/domain/sap/worksheet/heat_transmission.py @@ -56,9 +56,11 @@ from domain.ml.rdsap_uvalues import ( u_floor, u_party_wall, u_roof, + u_rr_default_all_elements, u_wall, u_window, ) +from math import sqrt _WALL_INSULATION_NONE: Final[int] = 4 @@ -147,6 +149,8 @@ def _part_geometry(part: SapBuildingPart) -> dict[str, float]: "top_floor_area_m2": 0.0, "gross_wall_area_m2": 0.0, "party_wall_area_m2": 0.0, + "rr_floor_area_m2": 0.0, + "rr_simplified_a_rr_m2": 0.0, } fds = list(part.sap_floor_dimensions) ground = next((fd for fd in fds if fd.floor == 0), fds[0]) @@ -165,11 +169,28 @@ def _part_geometry(part: SapBuildingPart) -> dict[str, float]: (fd.party_wall_length_m or 0.0) * (fd.room_height_m or _DEFAULT_STOREY_HEIGHT_M) for fd in fds ) + # RdSAP10 §3.9.1 Simplified Type 1 (True Room-in-Roof): when an RR is + # lodged with only its floor area (no gable/party/sheltered/connected + # wall lengths), the spec's empirical formula treats it as one chunk + # of timber-framed roof structure of area A_RR = 12.5 × √(A_RR_floor + # / 1.5). The storey-below roof area (§3.8) is deducted by A_RR_floor + # — the regular roof "becomes" the residual footprint not under the + # RR. Type 2 (common walls < 1.8m) and the Detailed §3.10 path are + # additive on top — they decompose A_RR into per-surface areas with + # their own U-values, deducted from A_RR_final per step e. + rr_floor_area = 0.0 + rr_a_rr = 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) return { "ground_floor_area_m2": ground.total_floor_area_m2 or 0.0, - "top_floor_area_m2": top.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), "gross_wall_area_m2": gross_wall, "party_wall_area_m2": party_wall, + "rr_floor_area_m2": rr_floor_area, + "rr_simplified_a_rr_m2": rr_a_rr, } @@ -328,6 +349,21 @@ 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. + rr_a_rr = geom["rr_simplified_a_rr_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 + u_rr = u_rr_default_all_elements( + country=country, age_band=rir.construction_age_band, + ) + roof += u_rr * rr_a_rr floor += uf * floor_area_total party += upw * party_area windows += window_u * w_area @@ -335,9 +371,11 @@ def heat_transmission_from_cert( # (31) — total external element area used by both the worksheet # readout and the (36) thermal-bridging multiplier. Excludes the # party wall (party walls have their own line (32)) per RdSAP - # §5.15: bridging applies to *exposed* area only. + # §5.15: bridging applies to *exposed* area only. RR area joins + # 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 + main_wall_area + alt_walls_total_area + roof_area + floor_area_total + w_area + d_area + rr_a_rr ) 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 c2e48d72..b39c0adf 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 @@ -21,6 +21,7 @@ from datatypes.epc.domain.epc_property_data import ( BuildingPartIdentifier, EnergyElement, SapAlternativeWall, + SapRoomInRoof, ) from domain.ml.tests._fixtures import ( @@ -1306,3 +1307,64 @@ def test_section_3_partial_match_against_elmhurst_worksheet(fixture: ModuleType) # (gap #1). Non-RR fixtures get exact (31) + (36) asserted by the # dedicated `test_section_3_non_rr_line_31_and_36_match_elmhurst_worksheet`. assert result.total_external_element_area_m2 > 0 + + +def test_room_in_roof_simplified_type_1_adds_a_rr_timber_framed_area_to_roof_w_per_k() -> None: + """RdSAP10 §3.9.1 Simplified Type 1 (True Room-in-Roof, no common + walls). Formula (page 22 step d): A_RR = 12.5 × √(A_RR_floor / 1.5); + this area is treated as timber-framed construction and assigned a + U-value from Table 18 column (4) "Room-in-roof, all elements" by age + band when no gable / party / sheltered / connected wall lengths are + lodged (ΣA_RR_gable/other = 0 → A_RR_final = A_RR). + + Also (§3.9 page 21): A_RR_floor is deducted from the storey-below + roof area determined by §3.8 (which takes the greatest floor area + across levels). + + Synthetic dwelling for arithmetic clarity: + - 2 storeys × 40 m² each → §3.8 roof area = 40 + - RR floor area = 15 m² + - §3.9 deducted top-floor roof area = 40 − 15 = 25 m² + - A_RR = 12.5 × √(15 / 1.5) = 12.5 × √10 ≈ 39.5285 m² + - Age band B → U_roof (Table 18 col 1) = 0.40; U_RR (Table 18 + col 4 "as built") = 2.30. + - roof_w_per_k = 25 × 0.40 + 39.5285 × 2.30 = 10.00 + 90.9156 + = 100.9156 W/K. + """ + # Arrange + import math + main = make_building_part( + identifier=BuildingPartIdentifier.MAIN, + construction_age_band="B", + wall_construction=3, + 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=20.0, party_wall_length_m=0.0, floor=0, + ), + make_floor_dimension( + total_floor_area_m2=40.0, room_height_m=2.5, + heat_loss_perimeter_m=20.0, party_wall_length_m=0.0, floor=1, + ), + ], + sap_room_in_roof=SapRoomInRoof(floor_area=15.0, construction_age_band="B"), + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=95.0, # 40 + 40 + 15 + habitable_rooms_count=5, + 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_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)