From 61c215bf1fa340c76a983564dd3e849b43db371a Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 28 May 2026 15:44:30 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20S0380.34:=20round=20living=20area=20in?= =?UTF-8?q?=20Decimal=20arithmetic=20per=20RdSAP10=20=C2=A715=20=E2=80=94?= =?UTF-8?q?=20closes=20cert=202536=20+0.0007=20SAP=20residual?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RdSAP10 §15 p.66 (Rounding of data): "All internal floor areas and living area: 2 d.p." Cert 2536 (3 habitable rooms → Table 27 fraction 0.30, TFA 45.65 m^2) sits ON the HALF_UP rounding boundary: 0.30 (exact) * 45.65 = 13.6950 HALF_UP 2 d.p. = 13.70 (worksheet fLA = 13.70 / 45.65 = 0.3001) Float arithmetic drops the spec product BELOW the boundary: 0.30 (binary) ~= 0.2999999... product ~= 13.69499... HALF_UP 2 d.p. = 13.69 (cascade fLA = 13.69 / 45.65 = 0.29989) The 0.00021 fLA shortfall feeds straight into the worksheet (91) -> (92) MIT blend, undershoots MIT by ~0.001 C, and shaves 0.29 kWh off (98c) useful space heating — a +0.0007 SAP residual via the (211) main heating fuel x p/kWh. Compute the product in Decimal so HALF_UP lands on the exact .005 decimal boundary the spec defines. Certs that sit off the boundary (e.g. 2800/4800: 0.30 x 46.87 = 14.0610 -> 14.06 in both Decimal and float) are unaffected. Cohort-2 distribution after S0380.31..S0380.34: 36 exact + 2 <=0.07 (was 35 exact + 3 <=0.07). Cert 2536: +0.000715 -> -9.2e-8. The remaining 2800 / 4800 +0.0007 residuals come from a different cause (off the HALF_UP boundary) — defer to a separate slice. Co-Authored-By: Claude Opus 4.7 --- .../sap10_calculator/rdsap/cert_to_inputs.py | 16 ++++++++-- .../rdsap/tests/test_cert_to_inputs.py | 29 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index df04c3b6..fdcca0e3 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -51,6 +51,7 @@ from __future__ import annotations import math from dataclasses import dataclass +from decimal import ROUND_HALF_UP, Decimal from typing import Callable, Final, Literal, Optional from datatypes.epc.domain.epc_property_data import ( @@ -120,7 +121,6 @@ from domain.sap10_calculator.worksheet.solar_gains import ( from domain.sap10_calculator.worksheet.heat_transmission import ( DwellingExposure, HeatTransmission, - _AREA_ROUND_DP, _round_half_up, heat_transmission_from_cert, ) @@ -509,11 +509,23 @@ def _living_area_fraction( 2 d.p. half-up, then divided back by TFA to yield the LINE_91 that feeds the §7 zone blend. This roundtrip is why fixtures lodge e.g. 0.3001 (= 17.04/56.79) rather than the raw 0.30 Table 27 entry. + + The multiplication runs in Decimal arithmetic so HALF_UP rounding + lands on the exact decimal boundary the spec defines. Float Table 27 + fractions (e.g. 0.30 → 0.2999999...) otherwise drop products that + sit on the .005 boundary below the round-up threshold, e.g. cert + 2536 (3 rooms, TFA 45.65): exact 0.30 × 45.65 = 13.6950 → 13.70; + float gives 13.69499... → 13.69, propagating a −0.0007 SAP residual + via the §7 MIT blend. """ fraction = _living_area_fraction_default(habitable_rooms_count) if total_floor_area_m2 <= 0.0: return fraction - living_area_m2 = _round_half_up(fraction * total_floor_area_m2, _AREA_ROUND_DP) + living_area_m2 = float( + (Decimal(str(fraction)) * Decimal(str(total_floor_area_m2))).quantize( + Decimal("0.01"), rounding=ROUND_HALF_UP + ) + ) return living_area_m2 / total_floor_area_m2 diff --git a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py index 6307bb9f..0c9a0e75 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -633,6 +633,35 @@ def test_living_area_fraction_uses_rdsap_table_27_by_habitable_rooms() -> None: assert inputs_four.living_area_fraction == 0.25 +def test_living_area_rounds_half_up_at_2_dp_decimal_boundary_per_rdsap_15() -> None: + # Arrange — RdSAP10 §15 p.66 ("Rounding of data") requires "All + # internal floor areas and living area: 2 d.p." Cert 2536 lodges + # 3 habitable rooms (Table 27 fraction 0.30) and TFA 45.65 m². The + # exact-decimal product 0.30 × 45.65 = 13.6950 sits ON the HALF_UP + # rounding boundary and must round to 13.70 (away from zero). Float + # representation drops 0.30 to 0.299999... and the product to + # 13.69499..., taking the boundary below 13.6950 — without Decimal + # arithmetic the cascade gets 13.69 instead and lodges fLA = 0.29989 + # instead of the worksheet's 0.30011, leaving a +0.0007 SAP residual + # via the §7 MIT blend. + from domain.sap10_calculator.rdsap.cert_to_inputs import ( + _living_area_fraction, # pyright: ignore[reportPrivateUsage] + ) + + # Act + fla_boundary = _living_area_fraction(habitable_rooms_count=3, total_floor_area_m2=45.65) + fla_off_boundary = _living_area_fraction(habitable_rooms_count=3, total_floor_area_m2=46.87) + + # Assert — worksheet cert 2536 dr87-0001-000889 line (91) = 0.3001: + # 0.30 × 45.65 = 13.6950 lands ON the half-up boundary and the spec- + # faithful living area is 13.70 → fLA = 13.70 / 45.65 = 0.30011. + assert abs(fla_boundary - (13.70 / 45.65)) <= 1e-12 + # Cert 2800 / 4800 (TFA 46.87) sit OFF the boundary: 0.30 × 46.87 + # = 14.0610 rounds down to 14.06 under HALF_UP, so the Decimal path + # matches the float path and fLA = 14.06 / 46.87. + assert abs(fla_off_boundary - (14.06 / 46.87)) <= 1e-12 + + def test_main_heating_efficiency_reads_sap_main_heating_code() -> None: # Arrange — Direction check: a gas combi (Table 4b code 102, 84% eff) # vs a non-condensing gas boiler (code 105, 70% eff) must show through