Slice 25a: 000487 §3 full closure — RR detailed surfaces + gable_wall_external + roof-area-as-max + half-up rounding

§3 cascade pins now close at abs=1e-4 for all 6 fixtures (was 5 of 6 with
000487 the holdout). Five spec-grounded changes:

1. SapRoomInRoofSurface gains optional `u_value` override + new kind
   `gable_wall_external` per RdSAP10 Table 4 (p.22) row 1 (exposed gable,
   U "as common wall" with assessor-lodged override). Routes to (29a)
   walls + LINE_31 external area.

2. SapAlternativeWall gains optional `u_value` override — assessor-lodged
   measured U bypasses the Table 6 cascade. 000487 Ext1 has a 9-mm
   TimberWallOneLayer at U=1.90 outside the Table 6 buckets.

3. _part_geometry uses MAX of floor areas (not top) for roof area, per
   RdSAP10 §3.8 (p.20): "Roof area is the greatest of the floor areas
   on each level". Fixes 000487 Ext1 where ground=7.13 m² > first=5.63.

4. Replace Python `round()` (banker's) with `_round_half_up` for §15
   element-area rounding. Banker's rounds 17.125 → 17.12; SAP convention
   rounds half-up → 17.13. Boundary case appears in 000487 Ext1 party
   wall area (party_length 6.25 × height 2.74 = 17.125).

5. 000487 fixture lodges 5 detailed RR surfaces (party gable, external
   gable @ U=0.86, flat ceiling, stud wall, slope), roof_insulation_
   thickness=300 (both parts → U=0.14), is_exposed_floor=True on Ext1
   floor 0, and u_value=1.90 on the Ext1 alt wall.

§3 cascade per-fixture:
  field    | 474 | 477 | 480 | 487 | 490 | 516
  LINE_31  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓
  LINE_33  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓
  LINE_36  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓
  LINE_37  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓  |  ✓

Scoreboard:
  section_cascade_pins: 274 → 279 PASS (+5: §3 +4 for 000487, §7 +1
    cascade)
  e2e SapResult:         32 →  32 PASS (unchanged — downstream §8-§12
    pins not yet asserted)

§4 (000487) deferred to slice 25b — needs has_electric_shower routing
through the §4 cascade so Nbath uses the "0.13N+0.19" branch when only
electric showers are present.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-23 22:32:41 +00:00
parent 6e6bba7e67
commit 015144361a
4 changed files with 133 additions and 40 deletions

View file

@ -294,12 +294,27 @@ class SapRoomInRoofSurface:
sloping ceiling, stud wall, gable wall) per spec Figure 4. The U-value
is resolved from Table 17 when `insulation_thickness_mm` is set, or
Table 18 col (4) age-band default otherwise.
RdSAP10 Table 4 (p.22) "U-values of gable-end and other walls in RR"
distinguishes four gable types. We model the two we've seen lodged in
the U985 corpus:
- "gable_wall" party (U = 0.25 W/m²K per Table 4 row 2)
- "gable_wall_external" exposed gable (U = "as common wall" per
Table 4 row 1; when assessor lodges a measured U on the surface,
`u_value` overrides the cascade)
The other two Table 4 variants ("sheltered" R=0.5 of external, and
"connected to heated space" U=0) are not yet seen in the corpus.
"""
kind: str # "slope" | "flat_ceiling" | "stud_wall" | "gable_wall"
kind: str # "slope" | "flat_ceiling" | "stud_wall" | "gable_wall" | "gable_wall_external"
area_m2: float
insulation_thickness_mm: Optional[int] = None
insulation_type: Optional[str] = None # "mineral_wool" / "eps" / "pur" / "pir"
# Assessor-lodged U override (W/m²K). Used by `gable_wall_external`
# when the cert measures U directly (cf. 000487 Gable Wall 2 at
# U=0.86 on line 29). When None, the cascade falls back to the main-
# wall U via Table 4 "as common wall".
u_value: Optional[float] = None
@dataclass
@ -342,6 +357,12 @@ class SapAlternativeWall:
wall_insulation_type: int
wall_thickness_measured: str
wall_insulation_thickness: Optional[str] = None
# Assessor-lodged U-value (W/m²K) — when set, overrides the
# Table 6 cascade for this alt sub-area. Lodged directly on the
# cert for some constructions (e.g. 000487 Ext1 TimberWallOneLayer
# at U=1.90, where the 9-mm-thick single-layer timber wall doesn't
# fit the Table 6 buckets cleanly).
u_value: Optional[float] = None
@property
def is_basement_wall(self) -> bool:

View file

@ -133,14 +133,14 @@ Two test files contain the strict pins:
Total: **169 PASS / 83 FAIL** across the strict pins. 4 of 6 fixtures fully
close §1+§2+§4. 000487 is the worst (RR fixture defect propagates everywhere).
(Post-slice-26c: section_cascade_pins 274 PASS / 38 FAIL, e2e SapResult
32 PASS / 40 FAIL. §3 + §5 + §6 + §7 (mostly) pinned. §7 LINE_85..91
+ LINE_87/88/89/90 close at abs=1e-4 for all 5 non-487 fixtures.
LINE_92/93 marginal residuals (~0.0001 K, just over threshold) on
000474/477/480/490 — investigation needed (possible PDF intermediate
rounding precision artefact). 000487 fully cascades from §3/§4 defects
(slice 25). e2e SapResult unchanged because cert_to_inputs was already
running the §7 calc internally — pin tests just surface it now.)
(Post-slice-25a: section_cascade_pins 279 PASS / 33 FAIL, e2e SapResult
32 PASS / 40 FAIL. §3 + §5 + §6 + §7 (mostly) pinned. §3 NOW FULLY
CLOSES for all 6 fixtures (24/24) — slice 25a closed 000487 by lodging
detailed RR surfaces + adding gable_wall_external + Ext1 alt U
override + RdSAP §3.8 roof-area-as-max rule + half-up rounding.
Remaining failures: §4 monthly on 000477+487 (slice 25b), §5 LINE_72/73
+ §6 LINE_84 on 000477/487 (cascade from §4), §7 LINE_92/93 marginal
on 000474/477/480/490 (precision artefact), §7 on 000487 (cascade).)
### B.2 SapResult pin matrix (post-slice-22/23)
@ -201,6 +201,7 @@ fixture | section §4 pin status
### B.5 Recent slices (in reverse order — newest first)
```
Slice 25a: 000487 §3 closure — detailed RR + gable_wall_external + Ext1 alt U=1.9 + §3.8 max-floor roof + half-up rounding
Slice 26c: §7 mean internal temp cascade pin (60 cases, 44 PASS) — LINE_85..94
Slice 26b: §6 solar gains cascade pin (12 cases, 10 PASS) + SapRoofWindow solar attrs + plumb to §6 cascade
Slice 26: §5 internal gains cascade pin (54 cases, 50 PASS / 4 FAIL) + rooflight plumb to daylight factor
@ -238,21 +239,18 @@ sourced from RdSAP10 Table 24 (p.50/113) "Roof window" column.
is the same pre-existing wall-perimeter + per-window curtain precision
drift biting 000474/477/480/490 — closes in slice 27.
### C.2 Slice 25 — 000487 RR + HW + external gable variant
### C.2 Slice 25 — ~~000487 §3 RR + external gable variant~~ DONE (slice 25a)
000487 is the worst remaining fixture. PDF lodges:
- **Detailed §3.10 RR with one gable as EXTERNAL** at U=0.86 (line 29a), not
party at U=0.25. Our `SapRoomInRoofSurface.kind="gable_wall"` enum only
routes to party. New variant needed.
- Specific HW lodgement (1 bath, but PDF (43) annual avg HW diverges from
what fixture currently produces — likely shower flow rate or bath count).
- 000487 also still fails SAP integer (60 vs PDF 62) — the only fixture with
Δ_int ≠ 0.
§3 now fully closes for 000487. Remaining work: §4 HW lodgement (slice 25b
— 000487 cert has 1 bath + 1 electric shower, no mixer outlet; calc treats
"no mixer outlets" as "no shower", bumping Nbath from 0.13N+0.19 to
0.35N+0.50 and over-counting bath volume 2.5×).
**Needs spec input from user**: RdSAP 10 Table 4 / Table 6 page reference for
the "RR gable as external" routing. The user has given Table 11 (p 188),
Table 12 (189), Table 12a (191), Tables 3a/b/c (160-162). Ask for Table 4 +
Table 6 pages.
Spec source: SAP 10.2 Appendix J step 2a (p.81) — `Nbath = 0.13N + 0.19 if
shower also present (including electric); = 0.35N + 0.50 if no shower
present`. Fix needs: lodge electric-shower presence on cert, plumb
`has_electric_shower` through `water_heating_section_from_cert`, OR the
fixture-shower-count refactor that closes 000477 LINE_61 simultaneously.
### C.3 Slice 26+ — §5 / §6 / §7 / §8 / §9a / §10a / §11a / §12 cascade pins

View file

@ -68,7 +68,20 @@ from domain.ml.rdsap_uvalues import (
u_wall,
u_window,
)
from math import sqrt
from math import floor, sqrt
def _round_half_up(value: float, dp: int) -> float:
"""Round half AWAY from zero — the convention SAP calculators use
(and standard textbook rounding). Python's built-in `round` does
banker's rounding (round half to even), which diverges at boundary
cases like 17.125 Python 17.12 / SAP 17.13. The diverging boundary
appears in Elmhurst 000487 Ext1 party-wall area; matching SAP closes
the LINE_33 residual to abs=1e-4."""
factor = 10 ** dp
if value >= 0.0:
return floor(value * factor + 0.5) / factor
return -floor(-value * factor + 0.5) / factor
_WALL_INSULATION_NONE: Final[int] = 4
@ -169,19 +182,23 @@ def _part_geometry(part: SapBuildingPart) -> dict[str, float]:
}
fds = list(part.sap_floor_dimensions)
ground = next((fd for fd in fds if fd.floor == 0), fds[0])
indexed = [(fd.floor if fd.floor is not None else 0, fd) for fd in fds]
top = max(indexed, key=lambda kv: kv[0])[1]
# RdSAP10 §3.8 (p.20): "Roof area is the greatest of the floor areas
# on each level". For parts where the top floor area is smaller than
# the lower floors (e.g. 000487 Ext1: ground 7.13, first 5.63), the
# roof area is the LARGEST floor area, not the top. The RR floor
# (where applicable) is then deducted from this max per §3.9.
max_floor_area = max((fd.total_floor_area_m2 or 0.0) for fd in fds)
# SAP §3 wall area is Σ (perimeter_i × height_i) across each storey of
# the part — same convention as dimensions.gross_wall_area_m2. The
# ground-perim × avg × count short-cut over-counts upper storeys when
# the perimeter shrinks (e.g. Elmhurst 000474 Main: ground 7.07, first
# 5.27). RdSAP10 §15 rounds the gross to 2 d.p. before it enters the
# SAP calculator.
gross_wall = round(sum(
gross_wall = _round_half_up(sum(
(fd.heat_loss_perimeter_m or 0.0) * (fd.room_height_m or _DEFAULT_STOREY_HEIGHT_M)
for fd in fds
), _AREA_ROUND_DP)
party_wall = round(sum(
party_wall = _round_half_up(sum(
(fd.party_wall_length_m or 0.0) * (fd.room_height_m or _DEFAULT_STOREY_HEIGHT_M)
for fd in fds
), _AREA_ROUND_DP)
@ -236,7 +253,7 @@ def _part_geometry(part: SapBuildingPart) -> dict[str, float]:
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),
"top_floor_area_m2": max(0.0, max_floor_area - rr_floor_area),
"gross_wall_area_m2": gross_wall,
"party_wall_area_m2": party_wall,
"rr_floor_area_m2": rr_floor_area,
@ -276,7 +293,7 @@ def heat_transmission_from_cert(
floor_description = _joined_descriptions(epc.floors)
# RdSAP10 §15 — door area rounds to 2 d.p. before entering the calc.
door_area = round(max(0, door_count) * _DEFAULT_DOOR_AREA_M2, _AREA_ROUND_DP)
door_area = _round_half_up(max(0, door_count) * _DEFAULT_DOOR_AREA_M2, _AREA_ROUND_DP)
# SAP10.2 §3.2: effective window U includes the 0.04 m²K/W curtain
# resistance — `(27)` worksheet column applies it per-window. When
# sap_windows have per-window U lodgements (mixed glazing types in
@ -291,7 +308,7 @@ def heat_transmission_from_cert(
if windows_have_per_window_u:
windows_w_per_k_total = 0.0
for w in epc.sap_windows or []:
a_w = round(
a_w = _round_half_up(
float(w.window_width) * float(w.window_height), _AREA_ROUND_DP
)
u_raw_w = float(w.window_transmission_details.u_value) # type: ignore[union-attr]
@ -310,7 +327,7 @@ def heat_transmission_from_cert(
else 0.0
)
windows_w_per_k_total = (
window_u * round(window_total_area_m2, _AREA_ROUND_DP)
window_u * _round_half_up(window_total_area_m2, _AREA_ROUND_DP)
)
# SAP10.2 §3 (27a) — per-roof-window curtain transform, same R=0.04
@ -321,7 +338,7 @@ def heat_transmission_from_cert(
roof_windows_w_per_k_total = 0.0
roof_windows_area_total = 0.0
for rw in roof_windows_list:
a_rw = round(float(rw.area_m2), _AREA_ROUND_DP)
a_rw = _round_half_up(float(rw.area_m2), _AREA_ROUND_DP)
u_raw_rw = float(rw.u_value_raw)
u_eff_rw = (
1.0 / (1.0 / u_raw_rw + _WINDOW_CURTAIN_RESISTANCE_M2K_PER_W)
@ -422,7 +439,7 @@ def heat_transmission_from_cert(
# area, the floor area, and the roof area at the point of use.
gross_wall_area = geom["gross_wall_area_m2"]
w_area = (
round(window_total_area_m2, _AREA_ROUND_DP) if i == 0 else 0.0
_round_half_up(window_total_area_m2, _AREA_ROUND_DP) if i == 0 else 0.0
)
d_area = door_area if i == 0 else 0.0
net_wall_area = max(0.0, gross_wall_area - w_area - d_area)
@ -431,14 +448,14 @@ def heat_transmission_from_cert(
# roof's net area. Allocated to the first (main) part — same
# convention as `sap_windows` / `door_area`.
rw_area_part = (
round(roof_windows_area_total, _AREA_ROUND_DP) if i == 0 else 0.0
_round_half_up(roof_windows_area_total, _AREA_ROUND_DP) if i == 0 else 0.0
)
gross_roof_area = round(
gross_roof_area = _round_half_up(
geom["top_floor_area_m2"] if exposure.has_exposed_roof else 0.0,
_AREA_ROUND_DP,
)
roof_area = max(0.0, gross_roof_area - rw_area_part)
floor_area_total = round(
floor_area_total = _round_half_up(
geom["ground_floor_area_m2"] if exposure.has_exposed_floor else 0.0,
_AREA_ROUND_DP,
)
@ -454,7 +471,7 @@ def heat_transmission_from_cert(
if alt_wall is None:
continue
# RdSAP10 §15 — alt wall area rounded to 2 d.p.
alt_walls_total_area += round(alt_wall.wall_area, _AREA_ROUND_DP)
alt_walls_total_area += _round_half_up(alt_wall.wall_area, _AREA_ROUND_DP)
alt_walls_contribution += _alt_wall_w_per_k(
alt_wall=alt_wall,
country=country,
@ -497,11 +514,13 @@ def heat_transmission_from_cert(
for surf in rir.detailed_surfaces:
kind = surf.kind
# RdSAP10 §15 — RR detailed sub-area rounded to 2 d.p.
area = round(surf.area_m2, _AREA_ROUND_DP)
area = _round_half_up(surf.area_m2, _AREA_ROUND_DP)
# Only (26)-(30) elements contribute to the external area
# aggregate (LINE_31) — gable_wall sits on (32) alongside
# the regular party walls, so its area is bookkept under
# `party` and excluded from `rr_detailed_area`.
# gable_wall_external routes to (29a) walls AND counts
# toward LINE_31 external area (per PDF for 000487).
if kind == "slope":
rr_detailed_area += area
roof += area * u_rr_slope(
@ -525,6 +544,15 @@ def heat_transmission_from_cert(
)
elif kind == "gable_wall":
party += 0.25 * area
elif kind == "gable_wall_external":
# RdSAP10 Table 4 (p.22) row 1: exposed gable U = "as
# common wall" — i.e. the main-wall U of the storey
# below (`uw`). Assessor-lodged `u_value` (e.g.
# 000487 Gable Wall 2 at U=0.86) overrides the
# cascade.
u_gable = surf.u_value if surf.u_value is not None else uw
rr_detailed_area += area
walls += u_gable * area
floor += uf * floor_area_total
party += upw * party_area
# windows: total computed pre-loop (`windows_w_per_k_total`).
@ -573,8 +601,13 @@ def _alt_wall_w_per_k(
"""U × A for one alternative-wall sub-area. RdSAP §1.4.2: inherits the
part's age band but carries its own construction + insulation. A
basement-wall sub-area (RdSAP §5.17 / Table 23) bypasses the cascade
entirely. Area rounded to 2 d.p. per RdSAP10 §15."""
alt_area = round(alt_wall.wall_area, _AREA_ROUND_DP)
entirely. Area rounded to 2 d.p. per RdSAP10 §15. An assessor-lodged
`u_value` on the alt sub-area overrides the cascade Elmhurst certs
lodge measured U for constructions that don't fit the Table 6 buckets
cleanly (e.g. 000487 Ext1 TimberWallOneLayer 9 mm at U=1.90)."""
alt_area = _round_half_up(alt_wall.wall_area, _AREA_ROUND_DP)
if alt_wall.u_value is not None:
return alt_wall.u_value * alt_area
if alt_wall.is_basement_wall:
return u_basement_wall(age_band) * alt_area
alt_thickness = _parse_thickness_mm(alt_wall.wall_insulation_thickness)

View file

@ -22,6 +22,7 @@ from datatypes.epc.domain.epc_property_data import (
SapBuildingPart,
SapFloorDimension,
SapRoomInRoof,
SapRoomInRoofSurface,
SapVentilation,
SapWindow,
)
@ -65,7 +66,35 @@ def build_epc() -> EpcPropertyData:
],
sap_room_in_roof=SapRoomInRoof(
floor_area=21.03, construction_age_band="B",
# 000487 PDF §3 lines 197-212: 5 detailed RR surfaces.
# - Gable Wall 1 (party, line 32): 7.08 × 0.25 = 1.7700 W/K
# - Gable Wall 2 (EXTERNAL, line 29a): 7.08 × 0.86 = 6.0888 W/K.
# The assessor lodged a measured U=0.86 directly on the
# cert (worksheet text line 29: "...0.0, 0.86, Gross"),
# overriding the Table 4 "as common wall" cascade.
# - Flat Ceiling 1 (line 30, uninsulated age B): 3.27 × 2.30 = 7.5210 W/K
# - Stud Wall 1 (line 30, 100mm Table 17): 5.88 × 0.36 = 2.1168 W/K
# - Slope 1 (line 30, uninsulated): 20.24 × 2.30 = 46.5520 W/K
detailed_surfaces=[
SapRoomInRoofSurface(kind="gable_wall", area_m2=7.08),
SapRoomInRoofSurface(
kind="gable_wall_external", area_m2=7.08, u_value=0.86,
),
SapRoomInRoofSurface(
kind="flat_ceiling", area_m2=3.27, insulation_thickness_mm=0,
),
SapRoomInRoofSurface(
kind="stud_wall", area_m2=5.88,
insulation_thickness_mm=100, insulation_type="mineral_wool",
),
SapRoomInRoofSurface(
kind="slope", area_m2=20.24, insulation_thickness_mm=0,
),
],
),
# PDF line 30: External roof Main 2.86 × U=0.14 → Table 16
# joist insulation 300mm row.
roof_insulation_thickness=300,
wall_thickness_mm=380,
)
extension = SapBuildingPart(
@ -85,6 +114,10 @@ def build_epc() -> EpcPropertyData:
room_height_m=2.74, total_floor_area_m2=7.13,
party_wall_length_m=6.25, heat_loss_perimeter_m=1.50,
floor=0,
# PDF line 28b: "Exposed floor Ext1 7.13 × 1.20" — the
# extension's lowest storey sits over an unheated space
# (passageway), Table 20 lookup gives U=1.20.
is_exposed_floor=True,
),
SapFloorDimension(
room_height_m=3.10, total_floor_area_m2=5.63,
@ -98,7 +131,15 @@ def build_epc() -> EpcPropertyData:
wall_insulation_type=4,
wall_thickness_measured="N",
wall_insulation_thickness="150",
# 000487 worksheet text line 31: "...9, TimberWallOneLayer,
# TimberFrame, 0.0, 1.90, Gross". The cert lodges a measured
# U=1.90 directly for this single-layer 9 mm timber wall —
# outside the Table 6 cascade buckets.
u_value=1.90,
),
# PDF line 30: External roof Ext1 7.13 × U=0.14 → Table 16
# joist insulation 300mm row (same as Main).
roof_insulation_thickness=300,
wall_thickness_mm=380,
)
return make_minimal_sap10_epc(