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:
Khalim Conn-Kowlessar 2026-05-17 11:36:39 +00:00
parent f61d74a327
commit 8bd8f8a622
2 changed files with 817 additions and 0 deletions

View 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

View 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