diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 94bc539e..3630b482 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -250,6 +250,12 @@ class SapFloorDimension: floor: Optional[int] = None floor_insulation: Optional[int] = None floor_construction: Optional[int] = None + # RdSAP10 §5.13 Table 20: True when this floor is open to outside air + # (exposed) or sits over enclosed unheated space (semi-exposed) — e.g. + # the lowest floor of an extension that hangs off the main from the + # first storey upward. False means a ground floor (on soil), the + # default path through the BS EN ISO 13370 / Table 19 cascade. + is_exposed_floor: bool = False @dataclass diff --git a/packages/domain/src/domain/sap/worksheet/heat_transmission.py b/packages/domain/src/domain/sap/worksheet/heat_transmission.py index ab75b0c4..eb68138f 100644 --- a/packages/domain/src/domain/sap/worksheet/heat_transmission.py +++ b/packages/domain/src/domain/sap/worksheet/heat_transmission.py @@ -52,6 +52,7 @@ from domain.ml.rdsap_uvalues import ( u_basement_floor, u_basement_wall, u_door, + u_exposed_floor, u_floor, u_party_wall, u_roof, @@ -273,11 +274,20 @@ def heat_transmission_from_cert( wall_insulation_type=wall_ins_type, ) ur = u_roof(country=country, age_band=age_band, insulation_thickness_mm=roof_thickness, description=roof_description) - # When the part carries a basement, the WHOLE floor=0 is the - # basement floor (per user-confirmed convention). Table 23 F-column - # overrides the regular floor U-value cascade. + # Floor U-value routing (in priority order): + # 1. Basement floor — Table 23 F-column override (whole floor=0). + # 2. Exposed/semi-exposed upper floor — Table 20 lookup; no + # geometry input. Set on the ground SapFloorDimension when + # the part hangs off the main from the first storey upward + # (e.g. 000490 Extension 1). + # 3. Ground floor — BS EN ISO 13370 / Table 19 cascade. + is_exposed_floor = bool(ground_fd is not None and ground_fd.is_exposed_floor) if part.has_basement: uf = u_basement_floor(age_band) + elif is_exposed_floor: + uf = u_exposed_floor( + age_band=age_band, insulation_thickness_mm=floor_ins_thickness + ) else: uf = u_floor( country=country, age_band=age_band, construction=floor_construction, 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 e50969d1..3badb7d7 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 @@ -80,6 +80,45 @@ def test_roof_insulated_assumed_with_ni_thickness_uses_50mm_per_section_5_11_4() assert result.roof_w_per_k == pytest.approx(68.0, abs=2.0) +def test_exposed_timber_floor_age_b_uses_table_20_u_120_not_iso_13370() -> None: + # Arrange — RdSAP10 §5.13 Table 20: a part whose lowest floor sits + # over outside air (or unheated space) rather than soil takes its + # U-value from Table 20, not the BS EN ISO 13370 ground-floor branch. + # Elmhurst worksheet 000490 Extension 1 is exactly this shape — the + # extension hangs off the main from the first storey upward, so its + # floor=0 is "exposed timber" at U=1.20 W/m²K. Without the routing, + # the cascade would treat the same dimensions as a small ground-floor + # rectangle and return a much lower U via ISO 13370. + # Geometry: 18 m² × 8.68 m perimeter at age B, no insulation lodged. + # floor_w_per_k expected = 1.20 × 18 = 21.6 W/K. + main = make_building_part( + identifier="Main Dwelling", + 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=18.0, room_height_m=2.88, + party_wall_length_m=0.0, heat_loss_perimeter_m=8.68, floor=0, + ), + ], + ) + main.sap_floor_dimensions[0].is_exposed_floor = True + epc = make_minimal_sap10_epc( + total_floor_area_m2=18.0, + country_code="ENG", + sap_building_parts=[main], + ) + + # Act + result = heat_transmission_from_cert(epc) + + # Assert + assert result.floor_w_per_k == pytest.approx(21.6, abs=0.1) + + def test_floor_insulated_assumed_with_ni_thickness_uses_50mm_per_table19_footnote() -> None: # Arrange — 2 413 corpus certs lodge floors with thickness="NI" and # description "Solid, insulated (assumed)". The retrofit-insulation