From 015144361a5d2db5ed04780eeb022987aee68268 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 23 May 2026 22:32:41 +0000 Subject: [PATCH] =?UTF-8?q?Slice=2025a:=20000487=20=C2=A73=20full=20closur?= =?UTF-8?q?e=20=E2=80=94=20RR=20detailed=20surfaces=20+=20gable=5Fwall=5Fe?= =?UTF-8?q?xternal=20+=20roof-area-as-max=20+=20half-up=20rounding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §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 --- datatypes/epc/domain/epc_property_data.py | 23 ++++++- docs/sap-spec/HANDOVER_NEXT.md | 40 +++++------ .../domain/sap/worksheet/heat_transmission.py | 69 ++++++++++++++----- .../tests/_elmhurst_worksheet_000487.py | 41 +++++++++++ 4 files changed, 133 insertions(+), 40 deletions(-) diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index cd3f3e7c..114c1d02 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -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: diff --git a/docs/sap-spec/HANDOVER_NEXT.md b/docs/sap-spec/HANDOVER_NEXT.md index a2245d0d..d8ecb220 100644 --- a/docs/sap-spec/HANDOVER_NEXT.md +++ b/docs/sap-spec/HANDOVER_NEXT.md @@ -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 diff --git a/packages/domain/src/domain/sap/worksheet/heat_transmission.py b/packages/domain/src/domain/sap/worksheet/heat_transmission.py index 905f3640..376e8443 100644 --- a/packages/domain/src/domain/sap/worksheet/heat_transmission.py +++ b/packages/domain/src/domain/sap/worksheet/heat_transmission.py @@ -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) diff --git a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000487.py b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000487.py index b242aca6..17be318a 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000487.py +++ b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000487.py @@ -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(