From e1b7b30c40bed4585deba5c54af751d38e7b757d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 28 May 2026 17:11:39 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.42:=20Decimal=20HALF=5FUP=20per-w?= =?UTF-8?q?indow=20areas=20per=20RdSAP10=20=C2=A715=20=E2=80=94=20closes?= =?UTF-8?q?=20cert=201536?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cert 1536 lodged window dimensions including (0.65 × 0.70) × 3 windows. In float arithmetic 0.65 × 0.70 = 0.45499999999999996, which the `_round_half_up(float, dp)` helper snaps to 0.45 vs the spec answer 0.46 (Decimal: 0.65 × 0.70 = 0.4550 exact, HALF_UP at 2 d.p. = 0.46). The shortfall of 0.01 m² × 3 windows = 0.03 m² under-counted as ~0.073 W/K of conduction loss vs the worksheet's windows_w_per_k = 25.6354 — closing the cert 1536 residual at +0.00152 to <2e-6. Same class of bug as the S0380.34/35 living-area / gross-wall / party-wall closures (Decimal HALF_UP at the 0.005 boundary that float drops). RdSAP10 §15 (p.66) lists "all element areas (gross) including window areas: 2 d.p." — Decimal is the only arithmetic that matches that boundary deterministically. Three cascade sites now use Decimal HALF_UP for per-window areas: - heat_transmission.py: `_decimal_round_half_up_product(W, H, 2)` replaces `_round_half_up(W × H, 2)` at the windows_w_per_k cascade AND at the per-bp window-area accumulation (the wall-net deduction branch must agree with the conduction branch for cascade-internal consistency, per the existing comment at line 575-583). - internal_gains.py: `_decimal_window_area_2dp(W, H)` replaces the inline `_round_area_2dp(W × H)` in the daylight factor `g_l` sum so §5 (66)..(67) sees the same per-window areas as §3 (27). - solar_gains.py: same Decimal helper replaces `_round_area_2dp` in `_wall_window_solar_gain_monthly_w` so §6 (74)..(81) area = (27). The `_round_area_2dp` helpers were inlined per-module in pre-S0380.42 work; this slice deletes them since the Decimal-aware product replaces all call sites. `_round_half_up` stays in heat_transmission for non-product per-element area calls (single-value rounds). Test impact: - Cohort-2 cert 1536 API path: +0.00152 → -1e-6 (<1e-4 ✓). Moves from _COHORT_2_API_OPEN to _COHORT_2_API_CLOSED. Cohort distribution: 37/38 exact (was 34/38 at start of session); only cert 2102 (-6.30 secondary-heating routing) remains open. - Cohort-2 cert 0300/9380 unchanged (already <1e-4 after S0380.41). - Cohort-1 ASHP 9/9 unchanged: <1e-4 on both paths. - Elmhurst 6-cert worksheet sweep: unchanged (lodges `window_width=area, window_height=1.0` per the Elmhurst lodging convention — Decimal(area) × Decimal(1.0) = Decimal(area), no rounding shift). Test suite: 750 pass + 0 fail. Pyright net-zero per touched file (heat_transmission 13/13; internal_gains 4/4 pre-existing; solar_gains 0/0; chain test 0/0). Spec citation: RdSAP 10 Specification §15 "Rounding of data" p.66 — "All element areas (gross) including window areas and conservatory wall area: 2 d.p." Decimal is the float-precision-stable arithmetic that matches this rule at the .005 boundary. Co-Authored-By: Claude Opus 4.7 --- .../tests/test_summary_pdf_mapper_chain.py | 8 +------ .../worksheet/heat_transmission.py | 22 ++++++++++++++----- .../worksheet/internal_gains.py | 22 ++++++++++++------- .../sap10_calculator/worksheet/solar_gains.py | 20 ++++++++++------- 4 files changed, 44 insertions(+), 28 deletions(-) diff --git a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py index 1b4ba5f6..eeb8bcc3 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -1912,6 +1912,7 @@ _COHORT_2_API_CLOSED: list[tuple[str, float]] = [ ("0390-2066-4250-2026-4555", 65.3253), ("0464-3032-0205-4276-3204", 80.4533), ("0652-3022-1205-2826-1200", 70.9577), + ("1536-9325-5100-0433-1226", 65.8928), # S0380.42 closure ("2007-3011-9205-8136-3204", 68.3914), ("2031-3007-0205-1296-3204", 64.1734), ("2130-3018-4205-4686-5204", 71.3158), @@ -1958,13 +1959,6 @@ _COHORT_2_API_CLOSED: list[tuple[str, float]] = [ # API mapper likely lodges the secondary fuel differently. Probe # the API JSON's `secondary_heating` block first. _COHORT_2_API_OPEN: list[tuple[str, float, float]] = [ - # S0380.41 partially closed this cert: residual moved from +0.4445 - # to +0.0015 via the same RdSAP 21 → SAP 10.2 glazing-type alias - # that closed 0300/9380 cleanly. A sub-2e-3 secondary tail remains - # — likely a windows-area Decimal-rounding boundary case in the - # cert's specific dimensions; investigate per the cohort closure - # convention in Slice S0380.42. - ("1536-9325-5100-0433-1226", 65.8928, 65.894324), ("2102-3018-0205-7886-5204", 63.8732, 57.570156), ] diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 841210a1..e504d38a 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -101,6 +101,19 @@ def _round_half_up(value: float, dp: int) -> float: return -floor(-value * factor + 0.5) / factor +def _decimal_round_half_up_product(a: float, b: float, dp: int) -> float: + """a × b in Decimal arithmetic, HALF_UP-quantised at `dp` decimal + places. Mirrors `_round_half_up(a * b, dp)` but lands on the exact + .005 spec boundary that float multiplication drops (e.g. window + dimensions 0.65 × 0.70 = 0.4550 exact / 0.45499... in float — + float `_round_half_up` snaps to 0.45 vs spec answer 0.46). Used + for per-window area rounding under the RdSAP10 §15 "round per + element area" convention; the §15 Σ-then-round counterpart is + `_decimal_round_half_up_sum`.""" + d = Decimal(str(a)) * Decimal(str(b)) + return float(d.quantize(Decimal(10) ** -dp, rounding=ROUND_HALF_UP)) + + _WALL_INSULATION_NONE: Final[int] = 4 _DEFAULT_DOOR_AREA_M2: Final[float] = 1.85 _DEFAULT_STOREY_HEIGHT_M: Final[float] = 2.5 @@ -476,8 +489,8 @@ 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_half_up( - float(w.window_width) * float(w.window_height), _AREA_ROUND_DP + a_w = _decimal_round_half_up_product( + 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 = ( @@ -583,9 +596,8 @@ def heat_transmission_from_cert( # cascade-internal consistency. for w in epc.sap_windows: idx = _window_bp_index(w.window_location, len(parts)) - area = _round_half_up( - float(w.window_width) * float(w.window_height), - _AREA_ROUND_DP, + area = _decimal_round_half_up_product( + float(w.window_width), float(w.window_height), _AREA_ROUND_DP, ) window_area_by_bp[idx] += area if _window_on_alt_wall(w): diff --git a/domain/sap10_calculator/worksheet/internal_gains.py b/domain/sap10_calculator/worksheet/internal_gains.py index 084e1797..470e59de 100644 --- a/domain/sap10_calculator/worksheet/internal_gains.py +++ b/domain/sap10_calculator/worksheet/internal_gains.py @@ -24,19 +24,25 @@ factor for L2a daylighting calc). from __future__ import annotations from dataclasses import dataclass +from decimal import Decimal, ROUND_HALF_UP from enum import Enum -from math import cos, exp, floor, pi +from math import cos, exp, pi from typing import Final, Optional from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapWindow -def _round_area_2dp(value: float) -> float: - """Half-away-from-zero rounding to 2 d.p. matching heat_transmission. - RdSAP 10 §15 "Rounding of data" (p.66): "All element areas (gross) - including window areas: 2 d.p." Inlined rather than imported so this - module doesn't reach into heat_transmission's private helpers.""" - return floor(value * 100.0 + 0.5) / 100.0 +def _decimal_window_area_2dp(width: float, height: float) -> float: + """W × H in Decimal arithmetic, HALF_UP-quantised at 2 d.p. Per + RdSAP10 §15 "Rounding of data" (p.66) "All element areas (gross) + including window areas: 2 d.p." — uses Decimal so the lookup lands + on the exact .005 spec boundary that float multiplication drops + (e.g. 0.65 × 0.70 = 0.4550 exact / 0.45499... in float — float + rounding snaps to 0.45 vs spec 0.46). Matches `heat_transmission. + _decimal_round_half_up_product` so the daylight factor's per-window + areas agree with the fabric cascade.""" + d = Decimal(str(width)) * Decimal(str(height)) + return float(d.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)) _DAYS_PER_YEAR: Final[float] = 365.0 _APPLIANCES_E_A_COEFF: Final[float] = 207.8 @@ -600,7 +606,7 @@ def _daylight_factor_from_cert( # including window areas: 2 d.p." — mirrors solar_gains and heat_ # transmission so G_L sees the same area as the fabric cascade. wall_g_l_numerator = sum( - _round_area_2dp(float(w.window_width) * float(w.window_height)) + _decimal_window_area_2dp(float(w.window_width), float(w.window_height)) * _g_light(w) * _frame_factor(w) * z_l for w in epc.sap_windows ) diff --git a/domain/sap10_calculator/worksheet/solar_gains.py b/domain/sap10_calculator/worksheet/solar_gains.py index d5f747c4..8d3d12a4 100644 --- a/domain/sap10_calculator/worksheet/solar_gains.py +++ b/domain/sap10_calculator/worksheet/solar_gains.py @@ -30,8 +30,9 @@ coefficient sets (N, NE/NW, E/W, SE/SW, S). from __future__ import annotations from dataclasses import dataclass +from decimal import Decimal, ROUND_HALF_UP from enum import Enum -from math import cos, floor, radians, sin +from math import cos, radians, sin from typing import Final from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapWindow @@ -43,12 +44,15 @@ from domain.sap10_calculator.climate.appendix_u import ( from domain.sap10_calculator.worksheet.internal_gains import OvershadingCategory -def _round_area_2dp(value: float) -> float: - """Half-away-from-zero rounding to 2 d.p. matching heat_transmission. - RdSAP 10 §15 "Rounding of data" (p.66): "All element areas (gross) - including window areas: 2 d.p." Inlined rather than imported so this - module doesn't reach into heat_transmission's private helpers.""" - return floor(value * 100.0 + 0.5) / 100.0 +def _decimal_window_area_2dp(width: float, height: float) -> float: + """W × H in Decimal arithmetic, HALF_UP-quantised at 2 d.p. Mirrors + `_round_area_2dp(W × H)` but lands on the exact .005 spec boundary + that float multiplication drops (e.g. 0.65 × 0.70 = 0.4550 exact / + 0.45499... in float — float rounding snaps to 0.45 vs spec 0.46). + Matches `heat_transmission._decimal_round_half_up_product` so solar + gains' per-window areas agree with the fabric cascade.""" + d = Decimal(str(width)) * Decimal(str(height)) + return float(d.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)) # Table 6d first column — winter solar access factor Z for heating gains. @@ -331,7 +335,7 @@ def _vertical_window_gain_monthly_w( # RdSAP 10 §15 "Rounding of data" (p.66): "All element areas (gross) # including window areas: 2 d.p." — matches heat_transmission's per- # window area rounding so solar gains and conduction agree on area. - area = _round_area_2dp(float(w.window_width) * float(w.window_height)) + area = _decimal_window_area_2dp(float(w.window_width), float(w.window_height)) g_perp = _g_perpendicular(w) ff = _frame_factor(w) return tuple(