diff --git a/docs/sap-spec/HANDOVER_NEXT.md b/docs/sap-spec/HANDOVER_NEXT.md index f16b6bb2..ea002f5e 100644 --- a/docs/sap-spec/HANDOVER_NEXT.md +++ b/docs/sap-spec/HANDOVER_NEXT.md @@ -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 90–95% 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.0013–0.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.0013–0.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 diff --git a/packages/domain/src/domain/sap/worksheet/heat_transmission.py b/packages/domain/src/domain/sap/worksheet/heat_transmission.py index e3b2b2ad..905f3640 100644 --- a/packages/domain/src/domain/sap/worksheet/heat_transmission.py +++ b/packages/domain/src/domain/sap/worksheet/heat_transmission.py @@ -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