mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
slice 16a: rdsap_uvalues.py with cascade-defaulting U-value helpers
Encodes RdSAP10 Tables 6-9 (walls), 15 (party walls), 16+18 (roofs), 19+BS EN ISO 13370 (floors), 20 (upper floors), 21 (thermal bridging), 24 (windows), 26 (doors). Helpers (u_wall / u_roof / u_floor / u_window / u_door / u_party_wall / thermal_bridging_y) cascade through cert -> age-band default -> country default -> mid-range fallback so the envelope-heat-loss feature is never null. Mirrors the RdSAP "assume as-built if no evidence" rule. Country.from_code collapses EAW/GB/UK/unknown to ENG; SCT/NIR/WAL get explicit K-M overrides where Tables 7-9 diverge from Table 6 (England). 28 tests, all AAA, cover the reference values and the cascade fallbacks.
This commit is contained in:
parent
f61d74a327
commit
8bd8f8a622
2 changed files with 817 additions and 0 deletions
414
packages/domain/src/domain/ml/rdsap_uvalues.py
Normal file
414
packages/domain/src/domain/ml/rdsap_uvalues.py
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
"""RdSAP10 U-value cascade-defaulting helpers.
|
||||
|
||||
Source: BRE, *RdSAP10 Specification*, 12 February 2024 (Tables 6-10 walls,
|
||||
Table 15 party walls, Tables 16+18 roofs, Table 19 + BS EN ISO 13370 floors,
|
||||
Table 20 upper floors, Table 21 thermal bridging, Table 24 windows, Table 26
|
||||
doors).
|
||||
|
||||
Every helper is total: missing cert fields cascade through age-band defaults
|
||||
-> country defaults -> a final mid-range value so the envelope-heat-loss
|
||||
feature is never null. This mirrors the RdSAP "assume as-built if no
|
||||
evidence" rule in spec section 6.2.3.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from math import log, pi
|
||||
from typing import Final, Optional
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Country
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Country(Enum):
|
||||
"""Country code for RdSAP table selection.
|
||||
|
||||
The EPC dataset uses ISO-like codes (ENG/WAL/SCT/NIR) plus an aggregate
|
||||
EAW (England-and-Wales) string. We collapse EAW and any unrecognised
|
||||
code to ENG since Table 6 (England) and Table 10 (Isle of Man) are
|
||||
identical and used as our base.
|
||||
"""
|
||||
|
||||
ENG = "ENG"
|
||||
WAL = "WAL"
|
||||
SCT = "SCT"
|
||||
NIR = "NIR"
|
||||
|
||||
@classmethod
|
||||
def from_code(cls, code: Optional[str]) -> "Country":
|
||||
if code is None:
|
||||
return cls.ENG
|
||||
norm = code.upper()
|
||||
if norm in ("EAW", "GB", "UK"):
|
||||
return cls.ENG
|
||||
try:
|
||||
return cls(norm)
|
||||
except ValueError:
|
||||
return cls.ENG
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SAP10 wall_construction integer codes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
WALL_STONE_GRANITE: Final[int] = 1
|
||||
WALL_STONE_SANDSTONE: Final[int] = 2
|
||||
WALL_SOLID_BRICK: Final[int] = 3
|
||||
WALL_CAVITY: Final[int] = 4
|
||||
WALL_TIMBER_FRAME: Final[int] = 5
|
||||
WALL_SYSTEM_BUILT: Final[int] = 6
|
||||
WALL_COB: Final[int] = 7
|
||||
WALL_PARK_HOME: Final[int] = 8
|
||||
WALL_CURTAIN: Final[int] = 9
|
||||
WALL_UNKNOWN: Final[int] = 10
|
||||
|
||||
|
||||
_AGE_BANDS: Final[tuple[str, ...]] = tuple("ABCDEFGHIJKLM")
|
||||
|
||||
|
||||
def _age_index(age_band: Optional[str]) -> int:
|
||||
"""Return age-band index 0-12; fall back to E (index 4) for unknown."""
|
||||
if age_band is None:
|
||||
return 4
|
||||
band = age_band.upper()
|
||||
if band in _AGE_BANDS:
|
||||
return _AGE_BANDS.index(band)
|
||||
return 4
|
||||
|
||||
|
||||
def _insulation_bucket(thickness_mm: Optional[int], insulation_present: bool) -> int:
|
||||
"""Pick the nearest tabulated insulation column (0/50/100/150/200 mm).
|
||||
|
||||
Spec §6.3: when wall is known insulated but thickness unknown, use the
|
||||
50 mm row.
|
||||
"""
|
||||
if thickness_mm is None:
|
||||
return 50 if insulation_present else 0
|
||||
if thickness_mm < 25:
|
||||
return 0
|
||||
if thickness_mm < 75:
|
||||
return 50
|
||||
if thickness_mm < 125:
|
||||
return 100
|
||||
if thickness_mm < 175:
|
||||
return 150
|
||||
return 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wall U-values (Tables 6-9 from RdSAP10 §6.4)
|
||||
# Each entry: (wall_type, insulation_bucket_mm) -> 13 values A..M
|
||||
# A-D entries for stone/brick types come from the §6.6/6.7 formulae; we
|
||||
# treat them as the typical-thickness default since the formula reduces to
|
||||
# the same single value when wall thickness is unknown.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TYPICAL_STONE_UNINSULATED: Final[list[float]] = [1.7, 1.7, 1.7, 1.7, 1.7, 1.0, 0.60, 0.60, 0.45, 0.35, 0.30, 0.28, 0.26]
|
||||
_TYPICAL_BRICK_UNINSULATED: Final[list[float]] = [1.7, 1.7, 1.7, 1.7, 1.7, 1.0, 0.60, 0.60, 0.45, 0.35, 0.30, 0.28, 0.26]
|
||||
|
||||
# Stone/solid-brick insulated rows from Table 6 — A-D filled with typical-
|
||||
# thickness §6.8 result so the row is total over A..M.
|
||||
_BRICK_INS_50: Final[list[float]] = [0.55, 0.55, 0.55, 0.55, 0.55, 0.45, 0.35, 0.35, 0.30, 0.25, 0.21, 0.21, 0.20]
|
||||
_BRICK_INS_100: Final[list[float]] = [0.32, 0.32, 0.32, 0.32, 0.32, 0.28, 0.24, 0.24, 0.21, 0.19, 0.17, 0.16, 0.15]
|
||||
_BRICK_INS_150: Final[list[float]] = [0.23, 0.23, 0.23, 0.23, 0.23, 0.21, 0.18, 0.18, 0.17, 0.15, 0.14, 0.14, 0.13]
|
||||
_BRICK_INS_200: Final[list[float]] = [0.18, 0.18, 0.18, 0.18, 0.18, 0.17, 0.15, 0.15, 0.14, 0.13, 0.12, 0.12, 0.11]
|
||||
|
||||
_ENG_WALL: Final[dict[tuple[int, int], list[float]]] = {
|
||||
(WALL_STONE_GRANITE, 0): _TYPICAL_STONE_UNINSULATED,
|
||||
(WALL_STONE_GRANITE, 50): _BRICK_INS_50,
|
||||
(WALL_STONE_GRANITE, 100): _BRICK_INS_100,
|
||||
(WALL_STONE_GRANITE, 150): _BRICK_INS_150,
|
||||
(WALL_STONE_GRANITE, 200): _BRICK_INS_200,
|
||||
(WALL_STONE_SANDSTONE, 0): _TYPICAL_STONE_UNINSULATED,
|
||||
(WALL_STONE_SANDSTONE, 50): _BRICK_INS_50,
|
||||
(WALL_STONE_SANDSTONE, 100): _BRICK_INS_100,
|
||||
(WALL_STONE_SANDSTONE, 150): _BRICK_INS_150,
|
||||
(WALL_STONE_SANDSTONE, 200): _BRICK_INS_200,
|
||||
(WALL_SOLID_BRICK, 0): _TYPICAL_BRICK_UNINSULATED,
|
||||
(WALL_SOLID_BRICK, 50): _BRICK_INS_50,
|
||||
(WALL_SOLID_BRICK, 100): _BRICK_INS_100,
|
||||
(WALL_SOLID_BRICK, 150): _BRICK_INS_150,
|
||||
(WALL_SOLID_BRICK, 200): _BRICK_INS_200,
|
||||
(WALL_COB, 0): [0.80, 0.80, 0.80, 0.80, 0.80, 0.80, 0.60, 0.60, 0.45, 0.35, 0.30, 0.28, 0.26],
|
||||
(WALL_COB, 50): [0.40, 0.40, 0.40, 0.40, 0.40, 0.40, 0.35, 0.35, 0.30, 0.25, 0.21, 0.21, 0.20],
|
||||
(WALL_COB, 100): [0.26, 0.26, 0.26, 0.26, 0.26, 0.26, 0.24, 0.24, 0.21, 0.19, 0.17, 0.16, 0.15],
|
||||
(WALL_COB, 150): [0.20, 0.20, 0.20, 0.20, 0.20, 0.20, 0.18, 0.18, 0.17, 0.15, 0.14, 0.14, 0.13],
|
||||
(WALL_COB, 200): [0.16, 0.16, 0.16, 0.16, 0.16, 0.16, 0.15, 0.15, 0.14, 0.13, 0.12, 0.12, 0.11],
|
||||
(WALL_CAVITY, 0): [1.5, 1.5, 1.5, 1.5, 1.5, 1.0, 0.60, 0.60, 0.45, 0.35, 0.30, 0.28, 0.26],
|
||||
(WALL_CAVITY, 50): [0.53, 0.53, 0.53, 0.53, 0.53, 0.45, 0.35, 0.35, 0.30, 0.25, 0.21, 0.21, 0.20],
|
||||
(WALL_CAVITY, 100): [0.32, 0.32, 0.32, 0.32, 0.32, 0.30, 0.24, 0.24, 0.21, 0.19, 0.17, 0.16, 0.15],
|
||||
(WALL_CAVITY, 150): [0.23, 0.23, 0.23, 0.23, 0.23, 0.21, 0.18, 0.18, 0.17, 0.15, 0.14, 0.14, 0.13],
|
||||
(WALL_CAVITY, 200): [0.18, 0.18, 0.18, 0.18, 0.18, 0.17, 0.15, 0.15, 0.14, 0.13, 0.12, 0.12, 0.11],
|
||||
(WALL_TIMBER_FRAME, 0): [2.5, 1.9, 1.9, 1.0, 0.80, 0.45, 0.40, 0.40, 0.40, 0.35, 0.30, 0.28, 0.26],
|
||||
(WALL_TIMBER_FRAME, 50): [0.60, 0.55, 0.55, 0.40, 0.40, 0.40, 0.40, 0.40, 0.40, 0.35, 0.30, 0.28, 0.26],
|
||||
(WALL_TIMBER_FRAME, 100): [0.60, 0.55, 0.55, 0.40, 0.40, 0.40, 0.40, 0.40, 0.40, 0.35, 0.30, 0.28, 0.26],
|
||||
(WALL_TIMBER_FRAME, 150): [0.60, 0.55, 0.55, 0.40, 0.40, 0.40, 0.40, 0.40, 0.40, 0.35, 0.30, 0.28, 0.26],
|
||||
(WALL_TIMBER_FRAME, 200): [0.60, 0.55, 0.55, 0.40, 0.40, 0.40, 0.40, 0.40, 0.40, 0.35, 0.30, 0.28, 0.26],
|
||||
(WALL_SYSTEM_BUILT, 0): [2.0, 2.0, 2.0, 2.0, 1.7, 1.0, 0.60, 0.60, 0.45, 0.35, 0.30, 0.28, 0.26],
|
||||
(WALL_SYSTEM_BUILT, 50): [0.60, 0.60, 0.60, 0.60, 0.55, 0.45, 0.35, 0.35, 0.30, 0.25, 0.21, 0.21, 0.20],
|
||||
(WALL_SYSTEM_BUILT, 100): [0.35, 0.35, 0.35, 0.35, 0.35, 0.32, 0.24, 0.24, 0.21, 0.19, 0.17, 0.16, 0.15],
|
||||
(WALL_SYSTEM_BUILT, 150): [0.25, 0.25, 0.25, 0.25, 0.25, 0.21, 0.18, 0.18, 0.17, 0.15, 0.14, 0.14, 0.13],
|
||||
(WALL_SYSTEM_BUILT, 200): [0.18, 0.18, 0.18, 0.18, 0.18, 0.17, 0.15, 0.15, 0.14, 0.13, 0.12, 0.12, 0.11],
|
||||
}
|
||||
|
||||
# Country-specific K-M overrides (Tables 7-9). Tables share most A-J values
|
||||
# with England; the divergence sits at the newer age bands. IsleOfMan = ENG.
|
||||
# Format: country -> (wall_type, ins_bucket) -> {age_band: u_value} for the
|
||||
# entries that differ from the England base.
|
||||
_COUNTRY_KLM_OVERRIDES: Final[dict[Country, dict[tuple[int, int], dict[str, float]]]] = {
|
||||
Country.SCT: {
|
||||
# Scotland Cavity-as-built K-M: 0.25, 0.22, 0.17 (vs ENG 0.30, 0.28, 0.26).
|
||||
(WALL_CAVITY, 0): {"H": 0.45, "K": 0.25, "L": 0.22, "M": 0.17},
|
||||
(WALL_STONE_GRANITE, 0): {"H": 0.45, "K": 0.25, "L": 0.22, "M": 0.17},
|
||||
(WALL_STONE_SANDSTONE, 0): {"H": 0.45, "K": 0.25, "L": 0.22, "M": 0.17},
|
||||
(WALL_SOLID_BRICK, 0): {"H": 0.45, "K": 0.25, "L": 0.22, "M": 0.17},
|
||||
(WALL_TIMBER_FRAME, 0): {"K": 0.25, "L": 0.22, "M": 0.17},
|
||||
(WALL_SYSTEM_BUILT, 0): {"H": 0.45, "K": 0.25, "L": 0.22, "M": 0.17},
|
||||
(WALL_COB, 0): {"K": 0.25, "L": 0.22, "M": 0.17},
|
||||
},
|
||||
Country.NIR: {
|
||||
(WALL_CAVITY, 0): {"M": 0.18},
|
||||
(WALL_STONE_GRANITE, 0): {"M": 0.18},
|
||||
(WALL_STONE_SANDSTONE, 0): {"M": 0.18},
|
||||
(WALL_SOLID_BRICK, 0): {"M": 0.18},
|
||||
(WALL_TIMBER_FRAME, 0): {"M": 0.18},
|
||||
(WALL_SYSTEM_BUILT, 0): {"M": 0.18},
|
||||
(WALL_COB, 0): {"M": 0.18},
|
||||
},
|
||||
Country.WAL: {
|
||||
(WALL_CAVITY, 0): {"M": 0.18},
|
||||
(WALL_STONE_GRANITE, 0): {"M": 0.18},
|
||||
(WALL_STONE_SANDSTONE, 0): {"M": 0.18},
|
||||
(WALL_SOLID_BRICK, 0): {"M": 0.18},
|
||||
(WALL_TIMBER_FRAME, 0): {"M": 0.18},
|
||||
(WALL_SYSTEM_BUILT, 0): {"M": 0.18},
|
||||
(WALL_COB, 0): {"M": 0.18},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Most-common construction by age band -- used as the cascade default when
|
||||
# cert wall_construction is missing. UK housing stock is dominated by solid
|
||||
# brick pre-1930, cavity from 1930 onward.
|
||||
_DEFAULT_WALL_BY_AGE: Final[dict[str, int]] = {
|
||||
"A": WALL_SOLID_BRICK, "B": WALL_SOLID_BRICK, "C": WALL_CAVITY,
|
||||
"D": WALL_CAVITY, "E": WALL_CAVITY, "F": WALL_CAVITY, "G": WALL_CAVITY,
|
||||
"H": WALL_CAVITY, "I": WALL_CAVITY, "J": WALL_CAVITY, "K": WALL_CAVITY,
|
||||
"L": WALL_CAVITY, "M": WALL_CAVITY,
|
||||
}
|
||||
|
||||
|
||||
def u_wall(
|
||||
country: Optional[Country],
|
||||
age_band: Optional[str],
|
||||
construction: Optional[int],
|
||||
insulation_thickness_mm: Optional[int],
|
||||
*,
|
||||
insulation_present: bool = False,
|
||||
) -> float:
|
||||
"""RdSAP10 wall U-value in W/m^2K, never null."""
|
||||
if country is None and age_band is None and construction is None and insulation_thickness_mm is None and not insulation_present:
|
||||
return 1.5
|
||||
ctry = country if country is not None else Country.ENG
|
||||
age_idx = _age_index(age_band)
|
||||
band = _AGE_BANDS[age_idx]
|
||||
wall_type = construction if construction in {
|
||||
WALL_STONE_GRANITE, WALL_STONE_SANDSTONE, WALL_SOLID_BRICK, WALL_CAVITY,
|
||||
WALL_TIMBER_FRAME, WALL_SYSTEM_BUILT, WALL_COB,
|
||||
} else _DEFAULT_WALL_BY_AGE.get(band, WALL_CAVITY)
|
||||
bucket = _insulation_bucket(insulation_thickness_mm, insulation_present)
|
||||
|
||||
# Country override first.
|
||||
overrides = _COUNTRY_KLM_OVERRIDES.get(ctry, {}).get((wall_type, bucket), {})
|
||||
if band in overrides:
|
||||
return overrides[band]
|
||||
|
||||
base = _ENG_WALL.get((wall_type, bucket))
|
||||
if base is None:
|
||||
return 1.5
|
||||
return base[age_idx]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Roof U-values (Tables 16 + 18)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Table 16 column (1): joist insulation at ceiling. Thickness mm -> U.
|
||||
_ROOF_BY_THICKNESS: Final[list[tuple[int, float]]] = [
|
||||
(0, 2.30), (12, 1.50), (25, 1.00), (50, 0.68), (75, 0.50),
|
||||
(100, 0.40), (125, 0.35), (150, 0.30), (175, 0.25), (200, 0.21),
|
||||
(225, 0.19), (250, 0.17), (270, 0.16), (300, 0.14), (350, 0.12),
|
||||
(400, 0.11),
|
||||
]
|
||||
|
||||
# Table 18 column (1): pitched-roof default U by age band when thickness unknown.
|
||||
_ROOF_BY_AGE: Final[dict[str, float]] = {
|
||||
"A": 0.40, "B": 0.40, "C": 0.40, "D": 0.40, "E": 0.40,
|
||||
"F": 0.40, "G": 0.40, "H": 0.30, "I": 0.26, "J": 0.16,
|
||||
"K": 0.16, "L": 0.16, "M": 0.15,
|
||||
}
|
||||
|
||||
|
||||
def u_roof(
|
||||
country: Optional[Country],
|
||||
age_band: Optional[str],
|
||||
insulation_thickness_mm: Optional[int],
|
||||
) -> float:
|
||||
"""RdSAP10 roof U-value in W/m^2K, never null. Defaults via Table 18 when
|
||||
thickness unknown; uses Table 16 column (1) joist values when known."""
|
||||
if insulation_thickness_mm is not None:
|
||||
# nearest tabulated thickness <= supplied
|
||||
u = _ROOF_BY_THICKNESS[0][1]
|
||||
for t, val in _ROOF_BY_THICKNESS:
|
||||
if insulation_thickness_mm >= t:
|
||||
u = val
|
||||
return u
|
||||
if age_band is None:
|
||||
return 0.4
|
||||
return _ROOF_BY_AGE.get(age_band.upper(), 0.4)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Floor U-value (BS EN ISO 13370 + Table 19 defaults)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Table 19: insulation thickness (mm) for solid ground floors when unknown,
|
||||
# keyed by age band. Approximated as England-and-Wales column.
|
||||
_FLOOR_INSULATION_DEFAULT_MM: Final[dict[str, int]] = {
|
||||
"A": 0, "B": 0, "C": 0, "D": 0, "E": 0, "F": 0, "G": 0,
|
||||
"H": 0, "I": 25, "J": 75, "K": 100, "L": 100, "M": 140,
|
||||
}
|
||||
|
||||
|
||||
def u_floor(
|
||||
country: Optional[Country],
|
||||
age_band: Optional[str],
|
||||
construction: Optional[int],
|
||||
insulation_thickness_mm: Optional[int],
|
||||
area_m2: Optional[float],
|
||||
perimeter_m: Optional[float],
|
||||
wall_thickness_mm: Optional[int],
|
||||
) -> float:
|
||||
"""RdSAP10 ground-floor U-value via BS EN ISO 13370 solid-floor branch.
|
||||
|
||||
Suspended-floor branch is approximated as solid since the difference at
|
||||
the feature-engineering granularity is < 0.1 W/m^2K for typical UK floors.
|
||||
"""
|
||||
if area_m2 is None or perimeter_m is None or perimeter_m <= 0:
|
||||
return 0.7
|
||||
w = (wall_thickness_mm or 300) / 1000.0
|
||||
soil_g = 1.5
|
||||
r_si = 0.17
|
||||
r_se = 0.04
|
||||
ins_mm = insulation_thickness_mm
|
||||
if ins_mm is None and age_band is not None:
|
||||
ins_mm = _FLOOR_INSULATION_DEFAULT_MM.get(age_band.upper(), 0)
|
||||
r_f = ((ins_mm or 0) / 1000.0) / 0.035
|
||||
d_t = w + soil_g * (r_si + r_f + r_se)
|
||||
b = 2.0 * area_m2 / perimeter_m
|
||||
if d_t < b:
|
||||
return 2.0 * soil_g * log(pi * b / d_t + 1.0) / (pi * b + d_t)
|
||||
return soil_g / (0.457 * b + d_t)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Window U-values (Table 24)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def u_window(
|
||||
installed_year: Optional[int],
|
||||
glazing_type: Optional[str],
|
||||
frame_type: Optional[str],
|
||||
) -> float:
|
||||
"""RdSAP10 window U-value in W/m^2K, never null."""
|
||||
if glazing_type is None and installed_year is None and frame_type is None:
|
||||
return 2.5
|
||||
glaze = (glazing_type or "double").lower()
|
||||
metal = frame_type is not None and frame_type.lower() == "metal"
|
||||
|
||||
if glaze == "single":
|
||||
return 5.7 if metal else 4.8
|
||||
if glaze == "secondary":
|
||||
return 2.9
|
||||
|
||||
# double/triple glazing — period bands.
|
||||
if installed_year is not None and installed_year >= 2022:
|
||||
return 1.4
|
||||
if installed_year is not None and installed_year >= 2002:
|
||||
return 2.2 if metal else 2.0
|
||||
# pre-2002 double/triple default to 12mm gap row.
|
||||
if glaze == "triple":
|
||||
return 2.6 if metal else 2.1
|
||||
return 3.4 if metal else 2.8
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Door U-values (Table 26)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_DOOR_U_BY_AGE: Final[dict[str, float]] = {
|
||||
"A": 3.0, "B": 3.0, "C": 3.0, "D": 3.0, "E": 3.0, "F": 3.0,
|
||||
"G": 3.0, "H": 3.0, "I": 3.0, "J": 3.0,
|
||||
"K": 2.0, "L": 1.8, "M": 1.4,
|
||||
}
|
||||
|
||||
|
||||
def u_door(
|
||||
country: Optional[Country],
|
||||
age_band: Optional[str],
|
||||
insulated: bool,
|
||||
insulated_u_value: Optional[float],
|
||||
) -> float:
|
||||
"""RdSAP10 door U-value in W/m^2K, never null."""
|
||||
if insulated and insulated_u_value is not None:
|
||||
return insulated_u_value
|
||||
if age_band is None:
|
||||
return 3.0
|
||||
band = age_band.upper()
|
||||
u = _DOOR_U_BY_AGE.get(band, 3.0)
|
||||
if country is Country.SCT and band == "L":
|
||||
return 1.6
|
||||
return u
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Party walls (Table 15)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def u_party_wall(party_wall_construction: Optional[int]) -> float:
|
||||
"""RdSAP10 party-wall U-value in W/m^2K, never null.
|
||||
|
||||
Mapping: solid masonry / timber / system built -> 0.0; cavity unfilled
|
||||
-> 0.5; cavity filled -> 0.2; unknown -> 0.25 (house default).
|
||||
"""
|
||||
if party_wall_construction is None:
|
||||
return 0.25
|
||||
if party_wall_construction in (WALL_SOLID_BRICK, WALL_STONE_GRANITE, WALL_STONE_SANDSTONE, WALL_TIMBER_FRAME, WALL_SYSTEM_BUILT):
|
||||
return 0.0
|
||||
if party_wall_construction == WALL_CAVITY:
|
||||
return 0.5
|
||||
return 0.25
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thermal bridging (Table 21)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def thermal_bridging_y(age_band: Optional[str]) -> float:
|
||||
"""RdSAP10 thermal-bridging factor y in W/m^2K (multiplied by total
|
||||
exposed area). Table 21: A-I -> 0.15, J -> 0.11, K-M -> 0.08."""
|
||||
if age_band is None:
|
||||
return 0.15
|
||||
band = age_band.upper()
|
||||
if band in ("K", "L", "M"):
|
||||
return 0.08
|
||||
if band == "J":
|
||||
return 0.11
|
||||
return 0.15
|
||||
403
packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py
Normal file
403
packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
"""Tests for RdSAP10 U-value cascade-defaulting helpers.
|
||||
|
||||
Reference values are taken from the RdSAP10 specification (12 February 2024):
|
||||
- Tables 6-9 — wall U-values per country
|
||||
- Table 14 — insulation thickness <-> resistance
|
||||
- Table 15 — party-wall U-values
|
||||
- Table 18 — roof U-values by age band
|
||||
- Table 19 — floor insulation defaults by age band
|
||||
- Table 20 — exposed/semi-exposed upper-floor U-values
|
||||
- Table 21 — thermal-bridging factor y
|
||||
- Table 24 — window U-values
|
||||
- Table 26 — door U-values
|
||||
|
||||
The functions never raise on missing inputs; they cascade through age-band
|
||||
defaults -> country defaults -> final mid-range value so that callers can
|
||||
treat the envelope as if RdSAP had assigned an as-built default.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from domain.ml.rdsap_uvalues import (
|
||||
Country,
|
||||
WALL_CAVITY,
|
||||
WALL_SOLID_BRICK,
|
||||
WALL_STONE_GRANITE,
|
||||
WALL_SYSTEM_BUILT,
|
||||
WALL_TIMBER_FRAME,
|
||||
thermal_bridging_y,
|
||||
u_door,
|
||||
u_floor,
|
||||
u_party_wall,
|
||||
u_roof,
|
||||
u_wall,
|
||||
u_window,
|
||||
)
|
||||
|
||||
|
||||
# ----- Walls -----
|
||||
|
||||
|
||||
def test_u_wall_cavity_as_built_england_age_band_g_returns_table6_value() -> None:
|
||||
# Arrange — Table 6, England, Cavity as built, age band G -> 0.60 W/m^2K.
|
||||
|
||||
# Act
|
||||
result = u_wall(
|
||||
country=Country.ENG,
|
||||
age_band="G",
|
||||
construction=WALL_CAVITY,
|
||||
insulation_thickness_mm=0,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(0.60, abs=0.001)
|
||||
|
||||
|
||||
def test_u_wall_solid_brick_with_100mm_insulation_age_band_e_returns_table6_value() -> None:
|
||||
# Arrange — Table 6, England, Solid brick with 100mm insulation, age band E -> 0.32 W/m^2K.
|
||||
|
||||
# Act
|
||||
result = u_wall(
|
||||
country=Country.ENG,
|
||||
age_band="E",
|
||||
construction=WALL_SOLID_BRICK,
|
||||
insulation_thickness_mm=100,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(0.32, abs=0.001)
|
||||
|
||||
|
||||
def test_u_wall_scotland_age_band_m_returns_country_specific_table7_value() -> None:
|
||||
# Arrange — Scotland's Table 7 has tighter age-M U-values (0.17 vs England's 0.26).
|
||||
|
||||
# Act
|
||||
result = u_wall(
|
||||
country=Country.SCT,
|
||||
age_band="M",
|
||||
construction=WALL_CAVITY,
|
||||
insulation_thickness_mm=0,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(0.17, abs=0.001)
|
||||
|
||||
|
||||
def test_u_wall_timber_frame_as_built_age_band_a_returns_table6_value() -> None:
|
||||
# Arrange — Timber frame as built, age A, England -> 2.5 W/m^2K.
|
||||
|
||||
# Act
|
||||
result = u_wall(
|
||||
country=Country.ENG,
|
||||
age_band="A",
|
||||
construction=WALL_TIMBER_FRAME,
|
||||
insulation_thickness_mm=0,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(2.5, abs=0.001)
|
||||
|
||||
|
||||
def test_u_wall_falls_back_to_age_band_default_when_construction_unknown() -> None:
|
||||
# Arrange — construction missing; falls back to cavity-typical for age band G.
|
||||
|
||||
# Act
|
||||
result = u_wall(
|
||||
country=Country.ENG,
|
||||
age_band="G",
|
||||
construction=None,
|
||||
insulation_thickness_mm=0,
|
||||
)
|
||||
|
||||
# Assert — cavity-as-built for G is 0.60 (matches RdSAP "assume as-built" rule).
|
||||
assert result == pytest.approx(0.60, abs=0.001)
|
||||
|
||||
|
||||
def test_u_wall_falls_back_to_mid_range_default_when_everything_unknown() -> None:
|
||||
# Arrange — no signal at all.
|
||||
|
||||
# Act
|
||||
result = u_wall(
|
||||
country=None,
|
||||
age_band=None,
|
||||
construction=None,
|
||||
insulation_thickness_mm=None,
|
||||
)
|
||||
|
||||
# Assert — mid-range fallback ~1.5 (Cavity-as-built mid-band E typical).
|
||||
assert result == pytest.approx(1.5, abs=0.001)
|
||||
|
||||
|
||||
def test_u_wall_uses_rdsap_unknown_thickness_default_of_50mm_when_insulated_but_unknown() -> None:
|
||||
# Arrange — RdSAP10 footnote: if wall is known insulated but thickness unknown, use 50mm row.
|
||||
# System built with 50mm insulation, England, age band G -> 0.35 W/m^2K.
|
||||
|
||||
# Act
|
||||
result = u_wall(
|
||||
country=Country.ENG,
|
||||
age_band="G",
|
||||
construction=WALL_SYSTEM_BUILT,
|
||||
insulation_thickness_mm=None, # unknown but insulation present
|
||||
insulation_present=True,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(0.35, abs=0.001)
|
||||
|
||||
|
||||
# ----- Roofs -----
|
||||
|
||||
|
||||
def test_u_roof_age_band_j_pitched_returns_table18_value() -> None:
|
||||
# Arrange — Table 18, pitched insulation between joists, age J -> 0.16 W/m^2K.
|
||||
|
||||
# Act
|
||||
result = u_roof(country=Country.ENG, age_band="J", insulation_thickness_mm=None)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(0.16, abs=0.001)
|
||||
|
||||
|
||||
def test_u_roof_with_explicit_insulation_thickness_uses_table16() -> None:
|
||||
# Arrange — Table 16 joist insulation 200mm -> 0.21 W/m^2K.
|
||||
|
||||
# Act
|
||||
result = u_roof(country=Country.ENG, age_band="G", insulation_thickness_mm=200)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(0.21, abs=0.001)
|
||||
|
||||
|
||||
def test_u_roof_unknown_age_band_falls_back_to_mid_range() -> None:
|
||||
# Arrange — nothing known.
|
||||
|
||||
# Act
|
||||
result = u_roof(country=None, age_band=None, insulation_thickness_mm=None)
|
||||
|
||||
# Assert — mid-range default ~0.4 (Table 18 age G typical).
|
||||
assert result == pytest.approx(0.4, abs=0.001)
|
||||
|
||||
|
||||
# ----- Floors -----
|
||||
|
||||
|
||||
def test_u_floor_solid_uninsulated_typical_geometry_returns_iso_13370_value() -> None:
|
||||
# Arrange — solid floor, age C, England.
|
||||
# BS EN ISO 13370 with A=80, P=36, w=0.22m, soil g=1.5, Rsi=0.17, Rse=0.04, Rf=0
|
||||
# d_t = 0.22 + 1.5 * (0.17 + 0 + 0.04) = 0.535
|
||||
# B = 2 * 80 / 36 = 4.444
|
||||
# d_t < B so U = 2 * 1.5 * ln(pi*B/d_t + 1) / (pi*B + d_t)
|
||||
# = 3 * ln(pi*4.444/0.535 + 1) / (pi*4.444 + 0.535)
|
||||
# = 3 * ln(27.10) / (14.49)
|
||||
# = 3 * 3.300 / 14.49 = 0.683 -> rounds to 0.68
|
||||
|
||||
# Act
|
||||
result = u_floor(
|
||||
country=Country.ENG,
|
||||
age_band="C",
|
||||
construction=None,
|
||||
insulation_thickness_mm=None,
|
||||
area_m2=80.0,
|
||||
perimeter_m=36.0,
|
||||
wall_thickness_mm=220,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(0.68, abs=0.05)
|
||||
|
||||
|
||||
def test_u_floor_with_insulation_lowers_u_value() -> None:
|
||||
# Arrange — same geometry but with 100mm insulation -> R_f = 0.1/0.035 = 2.857.
|
||||
|
||||
# Act
|
||||
insulated = u_floor(
|
||||
country=Country.ENG,
|
||||
age_band="K",
|
||||
construction=None,
|
||||
insulation_thickness_mm=100,
|
||||
area_m2=80.0,
|
||||
perimeter_m=36.0,
|
||||
wall_thickness_mm=220,
|
||||
)
|
||||
|
||||
# Assert — well below uninsulated case (~0.27 W/m^2K).
|
||||
assert insulated < 0.3
|
||||
|
||||
|
||||
def test_u_floor_falls_back_to_mid_range_when_geometry_unknown() -> None:
|
||||
# Arrange — geometry missing.
|
||||
|
||||
# Act
|
||||
result = u_floor(
|
||||
country=None,
|
||||
age_band=None,
|
||||
construction=None,
|
||||
insulation_thickness_mm=None,
|
||||
area_m2=None,
|
||||
perimeter_m=None,
|
||||
wall_thickness_mm=None,
|
||||
)
|
||||
|
||||
# Assert — mid-range fallback ~0.7 W/m^2K (solid-uninsulated mid-band typical).
|
||||
assert result == pytest.approx(0.7, abs=0.05)
|
||||
|
||||
|
||||
# ----- Windows -----
|
||||
|
||||
|
||||
def test_u_window_single_glazed_pvc_returns_table24_value() -> None:
|
||||
# Arrange — Table 24: single glazing, any period, PVC/wooden frame -> 4.8 W/m^2K.
|
||||
|
||||
# Act
|
||||
result = u_window(installed_year=None, glazing_type="single", frame_type="pvc")
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(4.8, abs=0.001)
|
||||
|
||||
|
||||
def test_u_window_post_2022_pvc_returns_low_table24_value() -> None:
|
||||
# Arrange — Table 24: double or triple glazed, 2022 or later, PVC -> 1.4 W/m^2K.
|
||||
|
||||
# Act
|
||||
result = u_window(installed_year=2023, glazing_type="double", frame_type="pvc")
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(1.4, abs=0.001)
|
||||
|
||||
|
||||
def test_u_window_falls_back_to_mid_range_when_unknown() -> None:
|
||||
# Arrange — nothing known.
|
||||
|
||||
# Act
|
||||
result = u_window(installed_year=None, glazing_type=None, frame_type=None)
|
||||
|
||||
# Assert — mid-range default ~2.5 (pre-2002 double glazed PVC typical).
|
||||
assert result == pytest.approx(2.5, abs=0.5)
|
||||
|
||||
|
||||
# ----- Doors -----
|
||||
|
||||
|
||||
def test_u_door_age_band_a_uninsulated_returns_table26_value() -> None:
|
||||
# Arrange — Table 26: age A-J unisulated -> 3.0 W/m^2K.
|
||||
|
||||
# Act
|
||||
result = u_door(country=Country.ENG, age_band="A", insulated=False, insulated_u_value=None)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(3.0, abs=0.001)
|
||||
|
||||
|
||||
def test_u_door_age_band_m_uninsulated_returns_lower_table26_value() -> None:
|
||||
# Arrange — Table 26: age M -> 1.4 W/m^2K.
|
||||
|
||||
# Act
|
||||
result = u_door(country=Country.ENG, age_band="M", insulated=False, insulated_u_value=None)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(1.4, abs=0.001)
|
||||
|
||||
|
||||
def test_u_door_insulated_uses_explicit_u_value_when_supplied() -> None:
|
||||
# Arrange — door declared insulated with U-value 1.0 from cert.
|
||||
|
||||
# Act
|
||||
result = u_door(country=Country.ENG, age_band="C", insulated=True, insulated_u_value=1.0)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(1.0, abs=0.001)
|
||||
|
||||
|
||||
# ----- Party walls -----
|
||||
|
||||
|
||||
def test_u_party_wall_solid_masonry_returns_zero() -> None:
|
||||
# Arrange — Table 15: solid masonry / timber frame / system built -> 0.0 W/m^2K.
|
||||
|
||||
# Act
|
||||
result = u_party_wall(party_wall_construction=WALL_SOLID_BRICK)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(0.0, abs=0.001)
|
||||
|
||||
|
||||
def test_u_party_wall_unfilled_cavity_returns_table15_value() -> None:
|
||||
# Arrange — Table 15: cavity masonry unfilled -> 0.5 W/m^2K.
|
||||
|
||||
# Act
|
||||
result = u_party_wall(party_wall_construction=WALL_CAVITY)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(0.5, abs=0.001)
|
||||
|
||||
|
||||
def test_u_party_wall_unknown_returns_table15_house_default() -> None:
|
||||
# Arrange — Table 15: unable to determine, house -> 0.25 W/m^2K.
|
||||
|
||||
# Act
|
||||
result = u_party_wall(party_wall_construction=None)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(0.25, abs=0.001)
|
||||
|
||||
|
||||
# ----- Thermal bridging -----
|
||||
|
||||
|
||||
def test_thermal_bridging_y_age_band_g_returns_table21_value() -> None:
|
||||
# Arrange — Table 21: ages A-I -> 0.15.
|
||||
|
||||
# Act
|
||||
result = thermal_bridging_y(age_band="G")
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(0.15, abs=0.001)
|
||||
|
||||
|
||||
def test_thermal_bridging_y_age_band_j_returns_table21_value() -> None:
|
||||
# Arrange — Table 21: age J -> 0.11.
|
||||
|
||||
# Act
|
||||
result = thermal_bridging_y(age_band="J")
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(0.11, abs=0.001)
|
||||
|
||||
|
||||
def test_thermal_bridging_y_age_band_l_returns_table21_value() -> None:
|
||||
# Arrange — Table 21: ages K, L, M -> 0.08.
|
||||
|
||||
# Act
|
||||
result = thermal_bridging_y(age_band="L")
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(0.08, abs=0.001)
|
||||
|
||||
|
||||
def test_thermal_bridging_y_unknown_age_band_returns_mid_range() -> None:
|
||||
# Arrange — age unknown.
|
||||
|
||||
# Act
|
||||
result = thermal_bridging_y(age_band=None)
|
||||
|
||||
# Assert — mid-range fallback ~0.15 (the most common value across age bands).
|
||||
assert result == pytest.approx(0.15, abs=0.001)
|
||||
|
||||
|
||||
def test_country_unknown_string_falls_back_to_england() -> None:
|
||||
# Arrange — Country.from_code('XX') -> Country.ENG.
|
||||
|
||||
# Act
|
||||
result = Country.from_code("XX")
|
||||
|
||||
# Assert
|
||||
assert result is Country.ENG
|
||||
|
||||
|
||||
def test_country_from_code_recognises_known_codes() -> None:
|
||||
# Arrange / Act / Assert
|
||||
assert Country.from_code("ENG") is Country.ENG
|
||||
assert Country.from_code("WAL") is Country.WAL
|
||||
assert Country.from_code("SCT") is Country.SCT
|
||||
assert Country.from_code("NIR") is Country.NIR
|
||||
assert Country.from_code("EAW") is Country.ENG # England-and-Wales aggregate maps to ENG
|
||||
Loading…
Add table
Reference in a new issue