Slice 89: PS pitched-sloping-ceiling roof area uses inclined surface

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-25 21:00:34 +00:00
parent c40679d1e1
commit 006e9842c9
3 changed files with 16 additions and 5 deletions

View file

@ -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?
)

View file

@ -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,

View file

@ -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,