From 8bd8f8a62275675252e23672cd74317d45c9c95c Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 17 May 2026 11:36:39 +0000 Subject: [PATCH] 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. --- .../domain/src/domain/ml/rdsap_uvalues.py | 414 ++++++++++++++++++ .../src/domain/ml/tests/test_rdsap_uvalues.py | 403 +++++++++++++++++ 2 files changed, 817 insertions(+) create mode 100644 packages/domain/src/domain/ml/rdsap_uvalues.py create mode 100644 packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py diff --git a/packages/domain/src/domain/ml/rdsap_uvalues.py b/packages/domain/src/domain/ml/rdsap_uvalues.py new file mode 100644 index 00000000..055d3100 --- /dev/null +++ b/packages/domain/src/domain/ml/rdsap_uvalues.py @@ -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 diff --git a/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py b/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py new file mode 100644 index 00000000..de997e6e --- /dev/null +++ b/packages/domain/src/domain/ml/tests/test_rdsap_uvalues.py @@ -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