Slice 27b: §3 element-area rounding to 2 d.p. per RdSAP10 §15 (p.66)

Spec text (RdSAP 10 §15, p.66): "For consistency of application, after
expanding the RdSAP data into SAP data using the rules in this Appendix,
the data are rounded before being passed to the SAP calculator. The
rounding rules are: U-values: 2 d.p. / All element areas (gross)
including window areas and conservatory wall area: 2 d.p. / [...]"

Applied 2-d.p. rounding to every per-element gross area inside
heat_transmission_from_cert: gross_wall + party_wall (in _part_geometry),
window total area, door area, top_floor (roof) area, ground_floor area,
roof-window area, alt-wall area, RR-detailed-surface area. U-values
already came from table lookups at 2 d.p.

§3 cascade pins (LINE_31/33/36/37) now close at abs=1e-4 for 5 of 6
fixtures. 000487 remains failing on the RR defect (slice 25).

Scoreboard:
  section_cascade_pins: 151 → 170 PASS (+19)
  e2e SapResult:        27 →  29 PASS (+2)

Per-fixture §3 status:
  field    | 474 | 477 | 480 | 487 | 490 | 516
  LINE_31  |  ✓  |  ✓  |  ✓  |  ✗  |  ✓  |  ✓
  LINE_33  |  ✓  |  ✓  |  ✓  |  ✗  |  ✓  |  ✓
  LINE_36  |  ✓  |  ✓  |  ✓  |  ✗  |  ✓  |  ✓
  LINE_37  |  ✓  |  ✓  |  ✓  |  ✗  |  ✓  |  ✓

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-23 09:13:57 +00:00
parent 1821f3fef3
commit d4c090fc7c
2 changed files with 74 additions and 49 deletions

View file

@ -133,9 +133,12 @@ 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-24: pin counts unchanged at abs=1e-4 — closure was numeric, not
gate-clearing. 000516 LINE_33 went 0.8215 → 0.0038 W/K; still > 1e-4 due to
unrelated pre-existing wall + window precision drift.)
(Post-slice-27b: section_cascade_pins 170 PASS / 16 FAIL, e2e SapResult
29 PASS / 43 FAIL. §3 fully closes for 5 of 6 fixtures at abs=1e-4 — every
LINE_31/33/36/37 pin passes on 000474/477/480/490/516. Remaining cascade
failures are §4 monthly (000477/487 HW defects, slice 25), §3 (000487 RR
defect, slice 25), and downstream SapResult pins still drifting because
of §5§9a precision not yet pinned.)
### B.2 SapResult pin matrix (post-slice-22/23)
@ -159,29 +162,27 @@ pumps_fans_kwh_per_yr | ✓ | ✓ | ✓ | ✓ | ✓ |
is still off by sub-SAP-point amounts on every fixture — none of `sap_score_
continuous` is closed at abs=1e-4.
### B.3 §3 residuals after slice 27 (floor U §5.12 rounding)
### B.3 §3 residuals after slice 27b (RdSAP10 §15 element-area rounding)
```
fixture | LINE_31 Δ | LINE_33 Δ | LINE_36 Δ | LINE_37 Δ
000474 | 0.0014 | 0.0032 | 0.0002 | 0.0030
000477 | 0.0004 | 0.0013 | ✓ | 0.0013
000480 | 0.0060 | 0.0075 | 0.0009 | 0.0084
000487 | 8.82 | 37.88 | 1.32 | 39.21
000490 | 0.0010 | 0.0013 | 0.0002 | 0.0014
000516 | 0.0025 | 0.0038 | 0.0004 | 0.0042
000474 | ✓ | ✓ | ✓ | ✓
000477 | ✓ | ✓ | ✓ | ✓
000480 | ✓ | ✓ | ✓ | ✓
000487 | 8.83 | 37.79 | 1.32 | 39.11
000490 | ✓ | ✓ | ✓ | ✓
000516 | ✓ | ✓ | ✓ | ✓
```
5 of 6 fixtures now have §3 LINE_33 residuals under 0.01 W/K. Slice 27
applied the §5.12 mandated 2-d.p. rounding to BS EN ISO 13370 floor
U-values, closing 9095% of the residual on 000474/477/490 (000516 is
exposed-floor only; 000487 still has the RR defect).
**§3 now closes for 5 of 6 fixtures at abs=1e-4.** Slice 27b applied the
RdSAP10 §15 (p.66) rounding policy: "All element areas (gross) including
window areas: 2 d.p." Per-element gross wall / party / roof / floor /
window / door / alt-wall / RR-sub-area inputs to the §3 cascade are now
rounded to 2 d.p. before A × U.
Remaining 0.00130.0075 W/K comes from wall + party-wall area precision
(my calc carries `36.4492 m²` where the PDF stores `36.4500` — clearly
2-d.p. rounded). The spec page for §3 element-area rounding hasn't been
read; if a "round to 0.01 m²" rule exists for §3 areas, applying it
would close these. 000487's huge gaps are the RR fixture defect + the
U=0.86 external-gable variant our `gable_wall` enum doesn't handle.
The remaining work is on 000487 — the worst fixture — driven by an
RR detailed-surface lodgement defect + a U=0.86 external-gable variant
our `gable_wall` enum doesn't handle. That's slice 25.
### B.4 §4 residuals
@ -198,6 +199,7 @@ fixture | section §4 pin status
### B.5 Recent slices (in reverse order — newest first)
```
Slice 27b: §3 element-area + door-area rounding to 2 d.p. per RdSAP10 §15 (p.66)
Slice 27: BS EN ISO 13370 floor U rounded to 2 d.p. per RdSAP10 §5.12
Slice 24: rooflight (line 27a) — SapRoofWindow datatype + 000516 cascade closure
ac68cf88 Slice 23: 000516 detailed RR + exposed_floor + door_count fixture lodgement
@ -290,18 +292,14 @@ PDF stores 2-d.p.-rounded element areas (e.g. `36.4500 m²` for a wall I
compute as `36.4492 m²`). Closing these needs the §3 area-rounding spec
rule — see slice 27b below.
### C.4b Slice 27b — Wall + party-wall area precision (PDF rounds to 2 d.p.)
### C.4b Slice 27b — ~~§3 element-area rounding~~ DONE
The 0.00130.0075 W/K LINE_33 residual on 000474/477/480/490/516 is
consistently traceable to gross wall-area and party-wall-area values:
- 000474 Main wall area: my 36.4492 vs PDF 36.4500 (Δ × 1.5 U = 0.0012 W/K)
- 000516 wall area: my 45.3675 vs PDF 45.3700 (Δ × 1.5 U = 0.00375 W/K)
- per-window U_eff aggregation: my per-window curtain transform diverges
from PDF's aggregate by ~0.0001 per fixture (slice 22 trade-off)
If §3 mandates area rounding to 0.01 m² (or 4 d.p.) at the element level,
applying it would close LINE_33 to ≤ 1e-4. Need SAP 10.2 §3 page reference
from the user.
Done. RdSAP10 §15 (p.66) lodges the rounding policy: "All element areas
(gross) including window areas: 2 d.p." Applied to gross wall + party
wall + roof + floor + window + door + alt-wall + RR-sub-area inputs in
`heat_transmission_from_cert`. §3 cascade pins (LINE_31/33/36/37) now
close at abs=1e-4 for 5 of 6 fixtures; 000487 alone remains failing on
the RR defect (slice 25).
### C.5 Slice 28 — Continuous SAP / fuel cost / CO2 closure

View file

@ -77,6 +77,12 @@ _DEFAULT_STOREY_HEIGHT_M: Final[float] = 2.5
# SAP10.2 §3.2 curtain/blind thermal resistance applied to windows (and
# roof windows) — turns raw window U into the worksheet's (27) effective U.
_WINDOW_CURTAIN_RESISTANCE_M2K_PER_W: Final[float] = 0.04
# RdSAP10 §15 "Rounding of data" (p.66): "All element areas (gross)
# including window areas and conservatory wall area: 2 d.p." plus
# "U-values: 2 d.p.". This is the data-passed-to-SAP-calculator
# 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
@dataclass(frozen=True)
@ -169,15 +175,16 @@ def _part_geometry(part: SapBuildingPart) -> dict[str, float]:
# 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).
gross_wall = sum(
# 5.27). RdSAP10 §15 rounds the gross to 2 d.p. before it enters the
# SAP calculator.
gross_wall = round(sum(
(fd.heat_loss_perimeter_m or 0.0) * (fd.room_height_m or _DEFAULT_STOREY_HEIGHT_M)
for fd in fds
)
party_wall = sum(
), _AREA_ROUND_DP)
party_wall = round(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)
# 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
@ -268,7 +275,8 @@ def heat_transmission_from_cert(
wall_description = _joined_descriptions(epc.walls)
floor_description = _joined_descriptions(epc.floors)
door_area = max(0, door_count) * _DEFAULT_DOOR_AREA_M2
# 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)
# 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
@ -283,7 +291,9 @@ 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 = float(w.window_width) * float(w.window_height)
a_w = round(
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]
u_eff_w = (
1.0 / (1.0 / u_raw_w + _WINDOW_CURTAIN_RESISTANCE_M2K_PER_W)
@ -299,7 +309,9 @@ def heat_transmission_from_cert(
if window_u_raw > 0
else 0.0
)
windows_w_per_k_total = window_u * window_total_area_m2
windows_w_per_k_total = (
window_u * round(window_total_area_m2, _AREA_ROUND_DP)
)
# SAP10.2 §3 (27a) — per-roof-window curtain transform, same R=0.04
# rule as (27). Total area is apportioned to the first (main) part
@ -309,7 +321,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 = float(rw.area_m2)
a_rw = round(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)
@ -404,20 +416,32 @@ def heat_transmission_from_cert(
upw = u_party_wall(party_wall_construction=party_construction)
y = thermal_bridging_y(age_band=age_band)
# RdSAP10 §15 — element gross areas enter the SAP calculator at
# 2 d.p. precision. `_part_geometry` rounds gross wall + party
# wall; here we round the per-window aggregate area, the door
# area, the floor area, and the roof area at the point of use.
gross_wall_area = geom["gross_wall_area_m2"]
w_area = window_total_area_m2 if i == 0 else 0.0
w_area = (
round(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)
party_area = geom["party_wall_area_m2"]
# Roof windows cut into the storey-below roof, reducing the regular
# roof's net area. Allocated to the first (main) part — same
# convention as `sap_windows` / `door_area`.
rw_area_part = roof_windows_area_total if i == 0 else 0.0
gross_roof_area = (
geom["top_floor_area_m2"] if exposure.has_exposed_roof else 0.0
rw_area_part = (
round(roof_windows_area_total, _AREA_ROUND_DP) if i == 0 else 0.0
)
gross_roof_area = round(
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 = geom["ground_floor_area_m2"] if exposure.has_exposed_floor else 0.0
floor_area_total = round(
geom["ground_floor_area_m2"] if exposure.has_exposed_floor else 0.0,
_AREA_ROUND_DP,
)
# RdSAP §1.4.2: a building part can have up to 2 alternative walls,
# each a sub-area of the gross wall with its OWN construction +
@ -429,7 +453,8 @@ def heat_transmission_from_cert(
for alt_wall in (part.sap_alternative_wall_1, part.sap_alternative_wall_2):
if alt_wall is None:
continue
alt_walls_total_area += alt_wall.wall_area
# RdSAP10 §15 — alt wall area rounded to 2 d.p.
alt_walls_total_area += round(alt_wall.wall_area, _AREA_ROUND_DP)
alt_walls_contribution += _alt_wall_w_per_k(
alt_wall=alt_wall,
country=country,
@ -471,7 +496,8 @@ def heat_transmission_from_cert(
rir = part.sap_room_in_roof
for surf in rir.detailed_surfaces:
kind = surf.kind
area = surf.area_m2
# RdSAP10 §15 — RR detailed sub-area rounded to 2 d.p.
area = round(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
@ -547,9 +573,10 @@ 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."""
entirely. Area rounded to 2 d.p. per RdSAP10 §15."""
alt_area = round(alt_wall.wall_area, _AREA_ROUND_DP)
if alt_wall.is_basement_wall:
return u_basement_wall(age_band) * alt_wall.wall_area
return u_basement_wall(age_band) * alt_area
alt_thickness = _parse_thickness_mm(alt_wall.wall_insulation_thickness)
alt_insulation_present = (
alt_wall.wall_insulation_type != _WALL_INSULATION_NONE