From 006e9842c92c7a930d7d781dd2102a6ced91513c Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 25 May 2026 21:00:34 +0000 Subject: [PATCH] Slice 89: PS pitched-sloping-ceiling roof area uses inclined surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RdSAP 10 §3.8 "Roof area" spec: "Roof area is the greatest of the floor areas on each level... In the case of a pitched roof with a sloping ceiling, divide the area so obtained by cos(30°)." The cascade previously used `top_floor_area_m2` (horizontal projection) verbatim for the roof area calculation — correct for flat roofs and pitched-with-loft (where assessors measure on the horizontal), but ~15% under-area for PS pitched-sloping-ceiling roofs (1/cos(30°) = 1.1547). For cert 001479 Ext1 + Ext2 (both PS sloping ceiling): Ext1: cascade 5.37 m² × 0.15 = 0.81 W/K worksheet 6.20 m² × 0.15 = 0.93 W/K (delta -0.12) Ext2: cascade 1.92 m² × 2.30 = 4.42 W/K worksheet 2.22 m² × 2.30 = 5.11 W/K (delta -0.69) Total roof W/K shortfall: -0.81 Fix: detect PS pitched-sloping-ceiling roofs via `bp.roof_construction _type` (string lodgement from the Summary §8 "Roof Type" line) and apply the 1/cos(30°) inclination factor before rounding the gross roof area. Schema addition: `SapBuildingPart.roof_construction_type: Optional[ str] = None` mirrors the existing `floor_construction_type`. Mapper populates it via `_strip_code(roof.roof_type)` for both Main and Extension bps — the Elmhurst Summary lodges the roof type explicitly (e.g. "PS Pitched, sloping ceiling" / "PA Pitched (slates /tiles), access to loft" / "Flat"). **Result: cert 001479 Summary → mapper → cascade now lands at SAP 69.0094 EXACT (delta -0.0000) — Layer 2 GREEN at 1e-4.** Full fabric breakdown matches the worksheet exactly: fabric_heat_loss = 139.4957 W/K ✓ walls = 39.7652 ✓ party = 17.0700 ✓ roof = 10.3438 ✓ floor = 23.1705 ✓ windows = 43.5962 ✓ doors = 5.5500 ✓ Layer 2 status across the 7 cert chain tests: 000477 GREEN (was GREEN) 000516 GREEN (was GREEN) 001479 GREEN (new — was +1.19 before Slice 87) 000474 RED -0.7524 (Elmhurst (12) non-spec — orthogonal) 000480 RED -1.0273 (Elmhurst (12) non-spec — orthogonal) 000487 RED +0.4834 (Elmhurst (12) non-spec — orthogonal) 000490 RED -1.1042 (Elmhurst (12) non-spec — orthogonal) Cohort cascade pins remain GREEN (66 of 66) — hand-built fixtures have roof_construction_type=None (default) so the new code path is inert for them; their roofs use RR detailed_surfaces with explicit areas already. Pyright net-zero on every touched file (heat_transmission 13 → 13, mapper 35 → 35, epc_property_data 0 → 0). Co-Authored-By: Claude Opus 4.7 --- datatypes/epc/domain/epc_property_data.py | 1 + datatypes/epc/domain/mapper.py | 1 + .../domain/sap/worksheet/heat_transmission.py | 19 ++++++++++++++----- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index d18b2341..c75e730f 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -427,6 +427,7 @@ class SapBuildingPart: floor_u_value_known: Optional[bool] = None roof_construction: Optional[int] = None + roof_construction_type: Optional[str] = None # str from site notes e.g. "PS Pitched, sloping ceiling" roof_insulation_location: Optional[Union[int, str]] = ( None # TODO: make enum/mapping? ) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 937100fc..d6d44462 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -2178,6 +2178,7 @@ def _map_elmhurst_building_part( party_wall_construction=_elmhurst_party_wall_construction_int(walls.party_wall_type), sap_floor_dimensions=floor_dims, wall_thickness_mm=walls.thickness_mm, + roof_construction_type=_strip_code(roof.roof_type), roof_insulation_location=_strip_code(roof.insulation), roof_insulation_thickness=_resolve_sloping_ceiling_thickness( roof, age_code, diff --git a/packages/domain/src/domain/sap/worksheet/heat_transmission.py b/packages/domain/src/domain/sap/worksheet/heat_transmission.py index 1c302c61..01d0be61 100644 --- a/packages/domain/src/domain/sap/worksheet/heat_transmission.py +++ b/packages/domain/src/domain/sap/worksheet/heat_transmission.py @@ -68,7 +68,7 @@ from domain.ml.rdsap_uvalues import ( u_wall, u_window, ) -from math import floor, sqrt +from math import cos, floor, radians, sqrt def _round_half_up(value: float, dp: int) -> float: @@ -96,6 +96,10 @@ _WINDOW_CURTAIN_RESISTANCE_M2K_PER_W: Final[float] = 0.04 # rounding policy — applied to gross wall / roof / floor / party / window # / door / alt-wall / RR sub-area inputs to the §3 cascade. _AREA_ROUND_DP: Final[int] = 2 +# RdSAP 10 §3.8 "Roof area" — pitched-sloping-ceiling roofs use the +# inclined surface area (floor area divided by cos(30°)) rather than +# the horizontal projection. +_COS_30_DEG: Final[float] = cos(radians(30.0)) @dataclass(frozen=True) @@ -538,10 +542,15 @@ def heat_transmission_from_cert( rw_area_part = ( _round_half_up(roof_windows_area_total, _AREA_ROUND_DP) if i == 0 else 0.0 ) - gross_roof_area = _round_half_up( - geom["top_floor_area_m2"] if exposure.has_exposed_roof else 0.0, - _AREA_ROUND_DP, - ) + # RdSAP 10 §3.8 "Roof area": roof area is the greatest of the + # floor areas on each level. For a pitched roof with a sloping + # ceiling, divide that area by cos(30°) — the worksheet enters + # the inclined surface area, not the horizontal projection. + top_floor_area = geom["top_floor_area_m2"] if exposure.has_exposed_roof else 0.0 + roof_type = (part.roof_construction_type or "").lower() + if "sloping ceiling" in roof_type: + top_floor_area = top_floor_area / _COS_30_DEG + gross_roof_area = _round_half_up(top_floor_area, _AREA_ROUND_DP) roof_area = max(0.0, gross_roof_area - rw_area_part) floor_area_total = _round_half_up( geom["ground_floor_area_m2"] if exposure.has_exposed_floor else 0.0,