mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
a92a33a8d8
commit
d61a27e0ff
2 changed files with 76 additions and 8 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue