Slice S0380.42: Decimal HALF_UP per-window areas per RdSAP10 §15 — closes cert 1536

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-28 17:11:39 +00:00
parent a96e6765ab
commit e1b7b30c40
4 changed files with 44 additions and 28 deletions

View file

@ -1912,6 +1912,7 @@ _COHORT_2_API_CLOSED: list[tuple[str, float]] = [
("0390-2066-4250-2026-4555", 65.3253), ("0390-2066-4250-2026-4555", 65.3253),
("0464-3032-0205-4276-3204", 80.4533), ("0464-3032-0205-4276-3204", 80.4533),
("0652-3022-1205-2826-1200", 70.9577), ("0652-3022-1205-2826-1200", 70.9577),
("1536-9325-5100-0433-1226", 65.8928), # S0380.42 closure
("2007-3011-9205-8136-3204", 68.3914), ("2007-3011-9205-8136-3204", 68.3914),
("2031-3007-0205-1296-3204", 64.1734), ("2031-3007-0205-1296-3204", 64.1734),
("2130-3018-4205-4686-5204", 71.3158), ("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 # API mapper likely lodges the secondary fuel differently. Probe
# the API JSON's `secondary_heating` block first. # the API JSON's `secondary_heating` block first.
_COHORT_2_API_OPEN: list[tuple[str, float, float]] = [ _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), ("2102-3018-0205-7886-5204", 63.8732, 57.570156),
] ]

View file

@ -101,6 +101,19 @@ def _round_half_up(value: float, dp: int) -> float:
return -floor(-value * factor + 0.5) / factor 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 _WALL_INSULATION_NONE: Final[int] = 4
_DEFAULT_DOOR_AREA_M2: Final[float] = 1.85 _DEFAULT_DOOR_AREA_M2: Final[float] = 1.85
_DEFAULT_STOREY_HEIGHT_M: Final[float] = 2.5 _DEFAULT_STOREY_HEIGHT_M: Final[float] = 2.5
@ -476,8 +489,8 @@ def heat_transmission_from_cert(
if windows_have_per_window_u: if windows_have_per_window_u:
windows_w_per_k_total = 0.0 windows_w_per_k_total = 0.0
for w in epc.sap_windows or []: for w in epc.sap_windows or []:
a_w = _round_half_up( a_w = _decimal_round_half_up_product(
float(w.window_width) * float(w.window_height), _AREA_ROUND_DP 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_raw_w = float(w.window_transmission_details.u_value) # type: ignore[union-attr]
u_eff_w = ( u_eff_w = (
@ -583,9 +596,8 @@ def heat_transmission_from_cert(
# cascade-internal consistency. # cascade-internal consistency.
for w in epc.sap_windows: for w in epc.sap_windows:
idx = _window_bp_index(w.window_location, len(parts)) idx = _window_bp_index(w.window_location, len(parts))
area = _round_half_up( area = _decimal_round_half_up_product(
float(w.window_width) * float(w.window_height), float(w.window_width), float(w.window_height), _AREA_ROUND_DP,
_AREA_ROUND_DP,
) )
window_area_by_bp[idx] += area window_area_by_bp[idx] += area
if _window_on_alt_wall(w): if _window_on_alt_wall(w):

View file

@ -24,19 +24,25 @@ factor for L2a daylighting calc).
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
from enum import Enum from enum import Enum
from math import cos, exp, floor, pi from math import cos, exp, pi
from typing import Final, Optional from typing import Final, Optional
from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapWindow from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapWindow
def _round_area_2dp(value: float) -> float: def _decimal_window_area_2dp(width: float, height: float) -> float:
"""Half-away-from-zero rounding to 2 d.p. matching heat_transmission. """W × H in Decimal arithmetic, HALF_UP-quantised at 2 d.p. Per
RdSAP 10 §15 "Rounding of data" (p.66): "All element areas (gross) RdSAP10 §15 "Rounding of data" (p.66) "All element areas (gross)
including window areas: 2 d.p." Inlined rather than imported so this including window areas: 2 d.p." — uses Decimal so the lookup lands
module doesn't reach into heat_transmission's private helpers.""" on the exact .005 spec boundary that float multiplication drops
return floor(value * 100.0 + 0.5) / 100.0 (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 _DAYS_PER_YEAR: Final[float] = 365.0
_APPLIANCES_E_A_COEFF: Final[float] = 207.8 _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_ # including window areas: 2 d.p." — mirrors solar_gains and heat_
# transmission so G_L sees the same area as the fabric cascade. # transmission so G_L sees the same area as the fabric cascade.
wall_g_l_numerator = sum( 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 * _g_light(w) * _frame_factor(w) * z_l
for w in epc.sap_windows for w in epc.sap_windows
) )

View file

@ -30,8 +30,9 @@ coefficient sets (N, NE/NW, E/W, SE/SW, S).
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
from enum import Enum from enum import Enum
from math import cos, floor, radians, sin from math import cos, radians, sin
from typing import Final from typing import Final
from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapWindow 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 from domain.sap10_calculator.worksheet.internal_gains import OvershadingCategory
def _round_area_2dp(value: float) -> float: def _decimal_window_area_2dp(width: float, height: float) -> float:
"""Half-away-from-zero rounding to 2 d.p. matching heat_transmission. """W × H in Decimal arithmetic, HALF_UP-quantised at 2 d.p. Mirrors
RdSAP 10 §15 "Rounding of data" (p.66): "All element areas (gross) `_round_area_2dp(W × H)` but lands on the exact .005 spec boundary
including window areas: 2 d.p." Inlined rather than imported so this that float multiplication drops (e.g. 0.65 × 0.70 = 0.4550 exact /
module doesn't reach into heat_transmission's private helpers.""" 0.45499... in float float rounding snaps to 0.45 vs spec 0.46).
return floor(value * 100.0 + 0.5) / 100.0 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. # 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) # RdSAP 10 §15 "Rounding of data" (p.66): "All element areas (gross)
# including window areas: 2 d.p." — matches heat_transmission's per- # including window areas: 2 d.p." — matches heat_transmission's per-
# window area rounding so solar gains and conduction agree on area. # 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) g_perp = _g_perpendicular(w)
ff = _frame_factor(w) ff = _frame_factor(w)
return tuple( return tuple(