From d61a27e0ff4c482e8fcff035cd1920c80bbbffbd Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 28 May 2026 16:07:38 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.35:=20round=20gross-wall=20and=20?= =?UTF-8?q?party-wall=20areas=20in=20Decimal=20arithmetic=20per=20RdSAP10?= =?UTF-8?q?=20=C2=A715=20=E2=80=94=20closes=20cohort-2=20cert=202800=20/?= =?UTF-8?q?=204800=20+0.0007=20SAP=20residuals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RdSAP10 §15 p.66 (Rounding of data): "All element areas (gross) including window areas and conservatory wall area: 2 d.p." Certs 2800 and 4800 lodge heat_loss_perimeter = 21.25 m and room_height = 2.30 m. The exact-decimal products 21.25 * 2.30 = 48.8750 (gross wall area) 6.25 * 2.30 = 14.3750 (party wall area) sit ON the HALF_UP rounding boundary and must round to 48.88 and 14.38 m^2. Float representation drops them BELOW the boundary: 21.25 (float) * 2.30 (float) ~= 48.87499... HALF_UP 2 d.p. = 48.87 6.25 (float) * 2.30 (float) ~= 14.37499... HALF_UP 2 d.p. = 14.37 The 0.01 m^2 area shortfall feeds into (29a) net wall area and (32) party wall area, and into (31) total external area for (36) thermal bridging — propagating a +0.0007 SAP residual via the U-weighted heat-loss sums. Adds `_decimal_round_half_up_sum` helper and routes both gross-wall and party-wall sums through it, mirroring the S0380.34 fix on `_living_area_fraction`. Certs that sit off the .005 boundary (i.e. nearly all) are unaffected; certs that land on it close from +0.0007 → <5e-5. Cohort-2 distribution after S0380.31..S0380.35: 38 exact (was 36 exact + 2 <=0.07). Cohort-1 ASHP cohort: 9/9 <1e-4 (unchanged). Co-Authored-By: Claude Opus 4.7 --- .../worksheet/heat_transmission.py | 47 +++++++++++++++---- .../worksheet/tests/test_heat_transmission.py | 37 +++++++++++++++ 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index eedb95c2..841210a1 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -40,6 +40,7 @@ sheet `NonRegionalWeather`, rows 121-207. from __future__ import annotations from dataclasses import dataclass +from decimal import ROUND_HALF_UP, Decimal from typing import Any, Final, Optional from datatypes.epc.domain.epc_property_data import ( @@ -72,6 +73,21 @@ from domain.sap10_ml.rdsap_uvalues import ( from math import cos, floor, radians, sqrt +def _decimal_round_half_up_sum( + pairs: Any, dp: int +) -> float: + """Σ (a × b) over Decimal arithmetic, then HALF_UP-quantised at + `dp` decimal places. Mirrors `_round_half_up(sum(a * b ...), dp)` + but lands on the exact .005 spec boundary that float arithmetic + drops (e.g. 21.25 × 2.30 = 48.875 exact / 48.87499... in float).""" + total = Decimal(0) + for a, b in pairs: + total += Decimal(str(a)) * Decimal(str(b)) + return float( + total.quantize(Decimal(10) ** -dp, rounding=ROUND_HALF_UP) + ) + + 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 @@ -303,14 +319,29 @@ def _part_geometry(part: SapBuildingPart) -> dict[str, float]: # 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_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_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) + # RdSAP10 §15 p.66 requires "All element areas (gross) ... 2 d.p." — + # the multiplication runs in Decimal so HALF_UP lands on the exact + # .005 decimal boundary the spec defines. Float arithmetic drops + # products such as 21.25 × 2.30 = 48.875 to 48.87499..., dropping + # them below the round-up threshold (cert 2800: 48.87 cascade vs + # 48.88 worksheet, a 0.01 m² gross-wall shift that propagates a + # +0.0007 SAP residual via the (29a) net-wall U×A cascade). + gross_wall = _decimal_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 = _decimal_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, + ) # 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 diff --git a/domain/sap10_calculator/worksheet/tests/test_heat_transmission.py b/domain/sap10_calculator/worksheet/tests/test_heat_transmission.py index d0da32a4..f3b1dee3 100644 --- a/domain/sap10_calculator/worksheet/tests/test_heat_transmission.py +++ b/domain/sap10_calculator/worksheet/tests/test_heat_transmission.py @@ -596,6 +596,43 @@ def test_walls_w_per_k_uses_sum_of_per_storey_perimeter_times_height_not_ground_ assert result.walls_w_per_k == pytest.approx(24.0, abs=0.5) +def test_gross_wall_area_rounds_half_up_at_decimal_boundary_per_rdsap10_section_15() -> None: + # Arrange — RdSAP10 §15 p.66 requires "All element areas (gross) + # … 2 d.p." Cert 2800's BP0 lodges heat_loss_perimeter = 21.25 m + # and room_height = 2.30 m. The exact-decimal product + # 21.25 × 2.30 = 48.8750 sits ON the HALF_UP rounding boundary and + # must round to 48.88 m². Float representation drops the product to + # 48.87499..., taking the boundary below 48.875 — without Decimal + # arithmetic the cascade gets 48.87 instead, propagating a +0.0007 + # SAP residual via (29a) net-wall area shifts and (31) thermal + # bridging. + main = make_building_part( + identifier=BuildingPartIdentifier.MAIN, + construction_age_band="G", + wall_construction=4, wall_insulation_type=4, + party_wall_construction=1, roof_construction=4, + floor_dimensions=[ + make_floor_dimension( + total_floor_area_m2=46.87, room_height_m=2.30, + heat_loss_perimeter_m=21.25, party_wall_length_m=6.25, + floor=0, + ), + ], + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=46.87, country_code="ENG", sap_building_parts=[main], + ) + + # Act + result = heat_transmission_from_cert(epc) + + # Assert — (31) external area = main wall NET 48.88 + roof 46.87 + # + floor 46.87 = 142.62. Float arithmetic would land on 142.61 + # (48.87 + 46.87 + 46.87). Worksheet cert 2800 dr87-0001-000898 + # line (31) = 142.62. + assert abs(result.total_external_element_area_m2 - 142.62) <= 1e-9 + + def test_window_bp_index_routes_bare_extension_to_first_extension_per_rdsap10_section_3() -> None: # Arrange — RdSAP10 §3 p.17: "for each building part, software will # deduct window/door areas contained in the relevant wall areas".