Slice S0380.35: round gross-wall and party-wall areas in Decimal arithmetic per RdSAP10 §15 — closes cohort-2 cert 2800 / 4800 +0.0007 SAP residuals

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-28 16:07:38 +00:00
parent a92a33a8d8
commit d61a27e0ff
2 changed files with 76 additions and 8 deletions

View file

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

View file

@ -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".