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),
("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),
]

View file

@ -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):

View file

@ -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
)

View file

@ -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(