Slice S0380.34: round living area in Decimal arithmetic per RdSAP10 §15 — closes cert 2536 +0.0007 SAP residual

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-28 15:44:30 +00:00 committed by Jun-te Kim
parent 1b12f995f8
commit 61c215bf1f
2 changed files with 43 additions and 2 deletions

View file

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

View file

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