"""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 import re from decimal import ROUND_HALF_UP, Decimal from enum import Enum from math import log, pi from typing import Final, Optional # Full-SAP (not RdSAP) assessments lodge a measured/calculated wall # U-value per BS EN ISO 6946 in `walls[i].description`, e.g. # "Average thermal transmittance 0.18 W/m²K". When present, the measured # value supersedes any default-table cascade. _THERMAL_TRANSMITTANCE_RE: Final[re.Pattern[str]] = re.compile( r"thermal\s+transmittance\s+([\d.]+)\s*W", re.IGNORECASE ) def _measured_u_from_description(description: Optional[str]) -> Optional[float]: """Return the measured W/m²K value lodged in a wall description, or None if no "Average thermal transmittance X W/m²K" phrase is present (or if parsing fails). On full-SAP certs the assessor enters the BS EN ISO 6946 result directly here in lieu of using the cascade. """ if description is None: return None match = _THERMAL_TRANSMITTANCE_RE.search(description) if match is None: return None try: return float(match.group(1)) except ValueError: return None def _described_as_insulated(description: Optional[str]) -> bool: """True when the surveyor description asserts insulation despite the `wall_insulation_type=4` ("as-built / assumed") code saying otherwise. Looks for "insulated" or "partial insulation" substrings, with "no insulation" taking precedence as a hard negation. Two consumers: - `u_wall` uses this to route cavity walls to the Filled-cavity row of Table 6 (in lieu of the bucketed cascade). - `heat_transmission_from_cert` uses this to set `wall_ins_present` for non-cavity walls so the 50 mm bucket routing fires per the RdSAP 10 Table 6 footnote ("If a wall is known to have additional insulation but the insulation thickness is unknown, use the row in the table for 50 mm insulation"). """ if description is None: return False desc = description.lower() if "no insulation" in desc: return False return "insulated" in desc or "partial insulation" in desc # --------------------------------------------------------------------------- # 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 # Party-wall only: distinguishes "Cavity masonry filled" from "Cavity masonry # unfilled" per RdSAP 10 §5.10 Table 15 row 3 (PDF p.42) — the spec lists # them as separate party-wall types with U=0.2 vs U=0.5. Main wall U-value # cascade (`u_wall`) does not consume this code; cavity-wall insulation # state on a main wall flows through `wall_insulation_type` + Table 6. WALL_CAVITY_FILLED_PARTY: Final[int] = 11 # RdSAP schema `wall_insulation_type` codes (empirically confirmed across # 8 000 corpus certs against walls[0].description): # 1 = external wall insulation # 2 = filled cavity ("Cavity wall, filled cavity") # 3 = internal wall insulation # 4 = as-built / assumed (default cascade) # 5 = none specified (rare) # 6 = filled cavity + external insulation # 7 = filled cavity + internal insulation _WALL_INSULATION_EXTERNAL: Final[int] = 1 WALL_INSULATION_FILLED_CAVITY: Final[int] = 2 _WALL_INSULATION_INTERNAL: Final[int] = 3 WALL_INSULATION_CAVITY_PLUS_EXTERNAL: Final[int] = 6 WALL_INSULATION_CAVITY_PLUS_INTERNAL: Final[int] = 7 # RdSAP 10 §4-6 (page 73): default thermal conductivity of insulation when # no documentary evidence is available. Used to convert the lodged # `wall_insulation_thickness` (mm) into thermal resistance (m²K/W) via # R = (thickness_mm / 1000) / λ for composite wall U-value calc # (cavity + external/internal insulation). _WALL_INSULATION_LAMBDA_W_PER_MK: Final[float] = 0.04 # RdSAP10 §5.8 final note + Table 14 page 41: "For drylining including # laths and plaster use Rinsulation = 0.17 m²K/W." Applied additively to # the base U-value of an otherwise-uninsulated wall when the cert lodges # `wall_dry_lined = "Y"` — see `u_wall(dry_lined=True)`. _DRY_LINING_RESISTANCE_M2K_PER_W: Final[float] = 0.17 # RdSAP 10 §5.6 (PDF p.40) — uninsulated stone wall formula, age bands # A to E (Table 12): # # Sandstone or limestone: U = 54.876 × W^(-0.561) # Granite or whinstone: U = 45.315 × W^(-0.513) # # Where W is wall thickness in mm. Apply §5.8 + Table 14 (PDF p.41) on # top for dry-lining / lath-and-plaster: U_adj = 1/(1/U₀ + 0.17). The # formula only applies for age bands A-E per the §5.6 heading; for age # F+ Table 6 row values represent typical-thickness stone walls and # are the spec target. # # Empirical pin: cert 000565 BP[0] Main alt1 lodges Stone Granite, # age A, 120 mm wall, dry-lined → §5.6 + §5.8 → U=2.34 (worksheet # line (29a)). The §5.6 formula keys on a documentary wall-thickness # lodgement (RdSAP 10 §5.3 / §3.5); without it, fall back to Table 6. _STONE_AGE_A_TO_E: Final[frozenset[str]] = frozenset({"A", "B", "C", "D", "E"}) def _u_stone_thin_wall_age_a_to_e( construction: int, wall_thickness_mm: int, ) -> Optional[float]: """RdSAP 10 §5.6 Table 12 (PDF p.40) — formula U-value for an uninsulated stone wall of known thickness in age bands A-E. Returns None when the construction is not stone (granite / sandstone) — caller must fall through to the Table 6 cascade.""" if construction == WALL_STONE_GRANITE: return 45.315 * (wall_thickness_mm ** -0.513) if construction == WALL_STONE_SANDSTONE: return 54.876 * (wall_thickness_mm ** -0.561) return None def _u_brick_thin_wall_age_a_to_e(wall_thickness_mm: int) -> float: """RdSAP 10 §5.7 Table 13 (PDF p.41) — default U-value for an uninsulated solid brick wall by lodged thickness, age bands A-E. Wall thickness, mm U-value, W/m²K Up to 200 mm 2.5 200 to 280 mm 1.7 280 to 420 mm 1.4 More than 420 mm 1.1 """ if wall_thickness_mm <= 200: return 2.5 if wall_thickness_mm <= 280: return 1.7 if wall_thickness_mm <= 420: return 1.4 return 1.1 def _r_insulation_table_14( thickness_mm: int, lambda_w_per_mk: float = 0.04, ) -> float: """RdSAP 10 §5.8 Table 14 (PDF p.42) — thermal resistance of added insulation by lodged thickness and λ. Spec interpolation rule (PDF p.42): R = 0.025 × T + 0.25 when λ = 0.04 W/m·K R = 0.0333 × T + 0.248 when λ = 0.03 W/m·K R = 0.040 × T + 0.25 when λ = 0.025 W/m·K The exact Table-14 row values reproduce as the interpolation formula evaluated at the discrete thickness points (e.g. T=75 mm + λ=0.04 → R = 2.125; T=100 mm + λ=0.04 → R = 2.75). """ if lambda_w_per_mk <= 0.0275: # λ = 0.025 W/m·K (PUR / PIR / phenolic foam) return 0.040 * thickness_mm + 0.25 if lambda_w_per_mk <= 0.035: # λ = 0.03 W/m·K (XPS optional) return 0.0333 * thickness_mm + 0.248 # λ = 0.04 W/m·K (typical mineral wool / EPS / rock wool — spec # default per §5.8 final note). return 0.025 * thickness_mm + 0.25 # RdSAP 10 §5.18 (PDF p.48) — curtain-wall U-values. # # "If documentary evidence is available, use calculated U-value of the # whole curtain wall. Otherwise for the purpose of RdSAP, U= 2.0 W/m²K # for pre-2023 curtain walls, And for post-2023 (2024 in Scotland) # U-values as for windows given in Notes below Table 24." # # Table 24 row "Double or triple glazed, England/Wales: 2022 or later" # is the matching post-2023 row: U = 1.4 (PVC/wood) / 1.6 (metal). The # Frame Factor for a whole-wall curtain wall is 1 per the §5.18 closer. # # Empirical pin: cert 000565 BP[2] Ext2 lodges `Curtain Wall Age: Post # 2023` and the U985 worksheet uses U=1.40 for this BP — matching the # PVC/wood row (the §5.18 default since curtain-wall frame material is # not separately surfaced on the Elmhurst Summary). _CURTAIN_WALL_U_PRE_2023: Final[float] = 2.0 _CURTAIN_WALL_U_POST_2023: Final[float] = 1.4 _CURTAIN_WALL_POST_2023_LODGEMENTS: Final[frozenset[str]] = frozenset({ "Post 2023", "Post-2023", }) def _u_curtain_wall(curtain_wall_age: Optional[str]) -> float: """RdSAP 10 §5.18 curtain-wall U-value. Keyed on the per-BP `Curtain Wall Age` lodgement (Summary §7), NOT on the dwelling-wide `construction_age_band`. Unknown / absent → pre-2023 default per the spec's "U= 2.0 W/m²K for pre-2023 curtain walls" sentence.""" if curtain_wall_age is not None and curtain_wall_age.strip() in _CURTAIN_WALL_POST_2023_LODGEMENTS: return _CURTAIN_WALL_U_POST_2023 return _CURTAIN_WALL_U_PRE_2023 _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). RdSAP 10 Table 6 footnote: "If a wall is known to have additional insulation but the insulation thickness is unknown, use the row in the table for 50 mm insulation". The cert encodes "thickness unknown" as either a missing field (`thickness_mm=None`) or the "NI" sentinel which `_parse_thickness_mm` returns as 0. Both must route to the 50 mm bucket when `insulation_present=True`; when not present, the as-built (bucket 0) row applies regardless. """ if insulation_present and (thickness_mm is None or thickness_mm == 0): return 50 if thickness_mm is None: return 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], } # RdSAP 10 Table 6 (England) row "Filled cavity" — 13 values A..M. The # cert records this case as (wall_construction=4 cavity, # wall_insulation_type=2 filled, wall_insulation_thickness="NI"). It's a # distinct row from "Cavity as built" + bucketed retrofit insulation # because filled-cavity isn't an added-insulation thickness; it's the # original cavity-fill state. Bands I-M carry the "†" footnote in the # spec ("assumed as built") — post-1996 cavities are filled as-built per # Building Regs, so the row collapses to the Cavity-as-built values from # band I onward. _CAVITY_FILLED_ENG: Final[list[float]] = [ 0.7, 0.7, 0.7, 0.7, 0.7, 0.40, 0.35, 0.35, 0.45, 0.35, 0.30, 0.28, 0.26, ] # 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, } # Surveyor-text -> wall-construction code, evaluated in priority order so that # "sandstone" beats "stone", "solid brick" beats "brick", etc. Used only as a # fallback when the cert's `wall_construction` integer is missing or unknown. _WALL_DESCRIPTION_MARKERS: Final[tuple[tuple[str, int], ...]] = ( ("sandstone", WALL_STONE_SANDSTONE), ("limestone", WALL_STONE_SANDSTONE), ("granite", WALL_STONE_GRANITE), ("whinstone", WALL_STONE_GRANITE), ("cob", WALL_COB), ("system built", WALL_SYSTEM_BUILT), ("timber frame", WALL_TIMBER_FRAME), ("solid brick", WALL_SOLID_BRICK), ("cavity", WALL_CAVITY), ) def _wall_type_from_description(description: Optional[str]) -> Optional[int]: if description is None: return None desc = description.lower() for marker, code in _WALL_DESCRIPTION_MARKERS: if marker in desc: return code return None def u_wall( country: Optional[Country], age_band: Optional[str], construction: Optional[int], insulation_thickness_mm: Optional[int], *, insulation_present: bool = False, description: Optional[str] = None, wall_insulation_type: Optional[int] = None, dry_lined: bool = False, curtain_wall_age: Optional[str] = None, wall_thickness_mm: Optional[int] = None, ) -> float: """RdSAP10 wall U-value in W/m^2K, never null. When the cert's `construction` integer is missing or WALL_UNKNOWN, an optional surveyor `description` (top-level `walls[i].description`) is parsed for material keywords ("sandstone", "granite", "solid brick", ...) so the cascade picks the right table instead of falling through to the cavity-by-age default. Explicit construction codes always win. `wall_insulation_type` is the RdSAP-coded insulation kind on the cert's `sap_building_parts[i].wall_insulation_type` field. When it indicates a filled cavity (code 2) on a cavity-wall construction, the spec's dedicated "Filled cavity" row is used in preference to the thickness-bucketed cascade — the two encode different things (filled- cavity is a construction state, not an added-insulation thickness). `dry_lined` triggers the RdSAP10 §5.8 + Table 14 adjustment: U_adjusted = 1 / (1/U_base + R_dryline) with R_dryline = 0.17 m²K/W. The adjustment is applied only when the base U comes from the uninsulated bucket (no measured insulation thickness, no filled-cavity branch, no surveyor "described as insulated" override) — for those branches the dry-lining R is already absorbed into the assumed insulation stack. Cohort fixture: cert 7700 Alt 1 cavity-as-built age C with Dry-lining: Yes — base U=1.5 → adjusted U=1.20 (2 d.p., matching worksheet `CavityWallPlasterOnDabsDenseBlock`). `curtain_wall_age` keys the RdSAP 10 §5.18 (PDF p.48) curtain-wall dispatch. Applies only when `construction == WALL_CURTAIN`; ignored for all other constructions. The dwelling-wide `age_band` does NOT govern curtain walls per §5.18 — the installation age does. """ measured = _measured_u_from_description(description) if measured is not None: return measured 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 # RdSAP 10 §5.18 (PDF p.48) — curtain walls bypass the Table 6/7/8/9 # cascade entirely; their U-value keys solely on the per-BP # `Curtain Wall Age` lodging. Place the dispatch before `known_types` # so an explicit `construction=WALL_CURTAIN` always routes here. if construction == WALL_CURTAIN: return _u_curtain_wall(curtain_wall_age) ctry = country if country is not None else Country.ENG age_idx = _age_index(age_band) band = _AGE_BANDS[age_idx] # RdSAP 10 §5.6 (PDF p.40) — uninsulated stone wall thin-wall # formula, age bands A-E. Fires only when a documentary wall # thickness is lodged (per §5.3 documentary-evidence rule). # §5.8 + Table 14 dry-line adjustment applies on top. # # Table 6 footnote (a) (PDF p.34): "Or from equations in 5.6 if # the calculated U-value is less than 1.7." The cap applies only # to the AS-BUILT (no insulation, no dry-line) Table 6 row — for # thin walls where §5.6 gives U ≥ 1.7 (e.g. granite at W=50 mm # yields 6.09 → use Table 6 default 1.7 instead). When the wall # is dry-lined or insulated, the raw §5.6 result feeds the §5.8 # chain as the input U₀ — the Table 6 footnote doesn't cap that # path (verified empirically against cert 000565 Main alt_wall_1: # granite W=120 mm dry-lined → U₀=3.88 raw + dry-line → 2.34 # matches worksheet, NOT 1.7 + dry-line → 1.32). if ( wall_thickness_mm is not None and band in _STONE_AGE_A_TO_E and construction in (WALL_STONE_GRANITE, WALL_STONE_SANDSTONE) ): u0 = _u_stone_thin_wall_age_a_to_e(construction, wall_thickness_mm) if u0 is not None: if dry_lined: # Round to 2 d.p. — worksheet (29a) A×U product uses # the 2-d.p.-displayed U (cf. 000565 Main alt_wall_1: # 23 × 2.34 = 53.82 with U=2.34, not raw 2.3405). u_unrounded = 1.0 / (1.0 / u0 + _DRY_LINING_RESISTANCE_M2K_PER_W) return float( Decimal(str(u_unrounded)).quantize( Decimal("0.01"), rounding=ROUND_HALF_UP ) ) if u0 >= 1.7: return 1.7 # Table-6 row cap per footnote (a) return u0 known_types = { WALL_STONE_GRANITE, WALL_STONE_SANDSTONE, WALL_SOLID_BRICK, WALL_CAVITY, WALL_TIMBER_FRAME, WALL_SYSTEM_BUILT, WALL_COB, } if construction in known_types: wall_type = construction else: wall_type = _wall_type_from_description(description) or _DEFAULT_WALL_BY_AGE.get(band, WALL_CAVITY) # RdSAP 10 §5.7 Table 13 + §5.8 (PDF p.41-42) — uninsulated solid # brick wall U₀ by lodged wall thickness, then add §5.8 insulation # adjustment U = 1/(1/U₀ + R) where R comes from Table 14. Fires # only with the cert's documentary-evidence lodging: # - construction is solid brick (or stone — §5.6 path below) # - age band A-E (per the §5.6/§5.7/§5.8 explicit scope) # - wall thickness measured # - insulation type is External (1) or Internal (3) with a # lodged thickness > 0 # λ defaults to 0.04 W/m·K (typical mineral wool / EPS) per §5.8 # final note. Cert 000565 BP[0] Main: solid brick 300 mm + 75 mm # external @ λ=0.04 → U₀=1.4 + R=2.125 → U=0.35 (matches ws). if ( wall_type == WALL_SOLID_BRICK and band in _STONE_AGE_A_TO_E and wall_thickness_mm is not None and wall_insulation_type in ( _WALL_INSULATION_EXTERNAL, _WALL_INSULATION_INTERNAL, ) and insulation_thickness_mm is not None and insulation_thickness_mm > 0 ): u0 = _u_brick_thin_wall_age_a_to_e(wall_thickness_mm) r_ins = _r_insulation_table_14( insulation_thickness_mm, _WALL_INSULATION_LAMBDA_W_PER_MK, ) u_unrounded = 1.0 / (1.0 / u0 + r_ins) return float( Decimal(str(u_unrounded)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) ) if wall_type == WALL_CAVITY and wall_insulation_type in ( WALL_INSULATION_CAVITY_PLUS_EXTERNAL, WALL_INSULATION_CAVITY_PLUS_INTERNAL, ) and insulation_thickness_mm is not None and insulation_thickness_mm > 0: # RdSAP 10 §4-4 + §4-6 (page 73): composite "filled cavity plus # external/internal insulation" — U_total = 1 / (1/U_filled + # R_ins) where R_ins = (thickness_mm / 1000) / λ. λ defaults to # 0.04 W/m·K when no documentary evidence is lodged. Cert 0380 # lodges code 6 + 100mm → U_filled (age D)=0.7 + R=2.5 → # U_total ≈ 0.2545 → rounded to 2 d.p. = 0.25 (worksheet). # # The 2-d.p. round matches dr87 / Elmhurst tool behaviour # (worksheet displays "0.2500" = 2-d.p. value padded to 4 d.p. # for column alignment). Cascade-internal HLC then uses the # rounded U so net wall HLC matches `A × U_2dp` exactly. u_filled = _CAVITY_FILLED_ENG[age_idx] r_ins = (insulation_thickness_mm / 1000.0) / _WALL_INSULATION_LAMBDA_W_PER_MK u_unrounded = 1.0 / (1.0 / u_filled + r_ins) # Half-up 2-d.p. round so 0.2545 → 0.25, matching the dr87 # worksheet's column-display behaviour (used downstream in A×U). return float( Decimal(str(u_unrounded)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) ) if wall_type == WALL_CAVITY and ( wall_insulation_type == WALL_INSULATION_FILLED_CAVITY or _described_as_insulated(description) ): return _CAVITY_FILLED_ENG[age_idx] 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: u_base = overrides[band] else: base = _ENG_WALL.get((wall_type, bucket)) u_base = base[age_idx] if base is not None else 1.5 # RdSAP10 §5.8 + Table 14 page 41 — dry-lining (including lath and # plaster) adds R = 0.17 m²K/W to an otherwise-uninsulated wall: # U_adjusted = 1 / (1/U_base + 0.17), rounded to 2 d.p. half-up. # Only the as-built uninsulated bucket triggers the adjustment; # insulated buckets already incorporate the dry-lining R via Table 14. if dry_lined and bucket == 0: u_unrounded = 1.0 / (1.0 / u_base + _DRY_LINING_RESISTANCE_M2K_PER_W) return float( Decimal(str(u_unrounded)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) ) return u_base # --------------------------------------------------------------------------- # 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, } # Table 18 column (3): flat-roof default U by age band when thickness unknown. # RdSAP 10 §5.11 Table 18 page 45 — the pitched-roof column (1) defaults # bottom out at 0.40 because "between joists insulation" is the implicit # Table-16 reference; the flat-roof column (3) drops directly to the # Table 16 row-0 / "no insulation" value (2.30) for old age bands and # follows Table 16's thickness ladder for modern ones. A flat roof # without a measured insulation thickness lodgement therefore cannot # share the pitched-roof age-band fallback — for an age D dwelling the # spec value is 2.30, not 0.40 (5.75x understatement of heat loss). _FLAT_ROOF_BY_AGE: Final[dict[str, float]] = { "A": 2.30, "B": 2.30, "C": 2.30, "D": 2.30, "E": 1.50, "F": 0.68, "G": 0.40, "H": 0.35, "I": 0.35, "J": 0.25, "K": 0.25, "L": 0.18, "M": 0.15, } # Table 18 column (4): "Room-in-roof, all elements" default U by age band # when no detailed RR lodgement is available. Footnote (1) on each entry # confirms "value from the table applies for unknown and as built". # Scotland override per footnote (2): age band K → 0.20 W/m²K (other bands # unchanged). _RR_ALL_ELEMENTS_BY_AGE: Final[dict[str, float]] = { "A": 2.30, "B": 2.30, "C": 2.30, "D": 2.30, "E": 1.50, "F": 0.80, "G": 0.50, "H": 0.35, "I": 0.35, "J": 0.30, "K": 0.25, "L": 0.18, "M": 0.15, } _RR_ALL_ELEMENTS_SCOTLAND_OVERRIDES: Final[dict[str, float]] = {"K": 0.20} _RR_ALL_ELEMENTS_MID_RANGE_FALLBACK: Final[float] = 0.50 _ROOF_NO_INSULATION_MARKERS: Final[tuple[str, ...]] = ( "no insulation", "uninsulated", ) _ROOF_LIMITED_INSULATION_MARKERS: Final[tuple[str, ...]] = ( "limited insulation", ) def u_roof( country: Optional[Country], age_band: Optional[str], insulation_thickness_mm: Optional[int], description: Optional[str] = None, is_flat_roof: bool = False, ) -> float: """RdSAP10 roof U-value in W/m^2K, never null. Resolution order: 1. Explicit `insulation_thickness_mm` → Table 16 column (1) joist row. 2. Surveyor `description` text (top-level `roofs[i].description`) flagging uninsulated / limited-insulation roofs → Table 16 0mm / 12mm rows. Table 18 age-band defaults assume joist insulation ≥100 mm, which is wrong for catastrophic heritage roofs the EPC explicitly describes as uninsulated. 3. Table 18 age-band default — column (1) "Pitched, insulation between joists" by default; column (3) "Flat roof" when `is_flat_roof=True`. Spec §5.11 Table 18 page 45. """ measured = _measured_u_from_description(description) if measured is not None: # Full-SAP cert lodges a measured roof U-value in the description # ("Average thermal transmittance X W/m²K"); spec §5.11 opening # clause defers to the assessor's value when present. return measured if insulation_thickness_mm == 0 and _described_as_insulated(description): # Spec §5.11.4 (page 44 footnote): "If retrofit insulation # present of unknown thickness use 50 mm". The cert encodes # "thickness unknown but retrofit insulation present" as the # "NI" sentinel which `_parse_thickness_mm` parses to 0. Without # this override the Table 16 row-0 lookup below returns the # uninsulated 2.30 W/m²K. return 0.68 # Table 16 row 50, "Insulation at joists at ceiling level" 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 description is not None: desc = description.lower() if any(marker in desc for marker in _ROOF_NO_INSULATION_MARKERS): return _ROOF_BY_THICKNESS[0][1] # 2.30 W/m^2K if any(marker in desc for marker in _ROOF_LIMITED_INSULATION_MARKERS): return _ROOF_BY_THICKNESS[1][1] # 1.50 W/m^2K (12mm row) if age_band is None: return 0.4 if is_flat_roof: return _FLAT_ROOF_BY_AGE.get(age_band.upper(), 0.4) return _ROOF_BY_AGE.get(age_band.upper(), 0.4) # RdSAP10 Table 17 — U-values for rooms in roof where insulation thickness # is known. Each tuple row is (thickness_mm, col_1a, col_1b, col_2a, col_2b, # col_3a, col_3b) per spec page 44 (mineral wool/EPS for "a", PUR/PIR for # "b"). Row "none" represents thickness 0 mm. Row ">400" represents any # thickness ≥ 400 mm. _RR_TABLE_17_ROWS: Final[tuple[tuple[int, float, float, float, float, float, float], ...]] = ( (0, 2.30, 2.30, 2.30, 2.30, 2.30, 2.30), (12, 1.50, 1.25, 1.75, 1.50, 0.95, 0.85), (25, 1.00, 0.80, 1.25, 1.00, 0.70, 0.60), (50, 0.68, 0.52, 0.88, 0.69, 0.52, 0.45), (75, 0.50, 0.38, 0.67, 0.51, 0.43, 0.35), (100, 0.40, 0.30, 0.54, 0.41, 0.36, 0.29), (125, 0.35, 0.25, 0.45, 0.34, 0.31, 0.24), (150, 0.30, 0.21, 0.39, 0.29, 0.27, 0.21), (175, 0.25, 0.17, 0.32, 0.23, 0.24, 0.19), (200, 0.21, 0.15, 0.29, 0.20, 0.22, 0.17), (225, 0.19, 0.13, 0.25, 0.18, 0.20, 0.15), (250, 0.17, 0.11, 0.23, 0.15, 0.18, 0.14), (270, 0.16, 0.10, 0.21, 0.14, 0.17, 0.13), (300, 0.14, 0.09, 0.19, 0.13, 0.16, 0.12), (350, 0.12, 0.08, 0.16, 0.11, 0.14, 0.11), (400, 0.11, 0.07, 0.14, 0.09, 0.12, 0.10), ) # Aliases mapping (insulation_type, column) → tuple index above. The PDF # splits each Table 17 column into "(a) mineral wool or EPS slab" vs "(b) # PUR or PIR optional". Aliases collapse common synonyms — the canonical # mapper-side code for the PDF disjunction "PUR or PIR" is "rigid_foam" # (see datatypes/epc/domain/mapper.py:_RIR_INSULATION_TYPE_TO_SAP10). _RR_RIGID_FOAM_INSULATION_TYPES: Final[frozenset[str]] = frozenset( {"pur", "pir", "rigid", "rigid_foam"} ) def _is_rigid_foam(insulation_type: Optional[str]) -> bool: """True if the insulation type names rigid foam (PUR/PIR). Falls back to False (i.e. mineral wool / EPS slab column) on None or any other string — same convention as `u_roof`'s mineral-wool default.""" if insulation_type is None: return False return insulation_type.strip().lower() in _RR_RIGID_FOAM_INSULATION_TYPES def _u_rr_table_17( country: Optional[Country], age_band: Optional[str], insulation_thickness_mm: Optional[int], insulation_type: Optional[str], col_a_index: int, col_b_index: int, ) -> float: """Generic Table 17 row picker. Returns the U-value at the nearest tabulated thickness ≤ supplied. Falls back to `u_rr_default_all_ elements` (Table 18 col 4) when thickness is None — matches the spec text at §5.11.3 / §5.11.4.""" if insulation_thickness_mm is None: return u_rr_default_all_elements(country=country, age_band=age_band) col = col_b_index if _is_rigid_foam(insulation_type) else col_a_index u = _RR_TABLE_17_ROWS[0][col] for row in _RR_TABLE_17_ROWS: if insulation_thickness_mm >= row[0]: u = row[col] return u def u_rr_slope( *, country: Optional[Country], age_band: Optional[str], insulation_thickness_mm: Optional[int], insulation_type: Optional[str] = None, ) -> float: """RdSAP10 §5.11.3 + Table 17 column (1): U-value for an insulated sloping ceiling section of a room-in-roof. Column (1a) is mineral wool / EPS slab (default), (1b) is PUR/PIR rigid foam. Falls back to `u_rr_default_all_elements` (Table 18 col 4) when thickness is unknown. """ return _u_rr_table_17( country=country, age_band=age_band, insulation_thickness_mm=insulation_thickness_mm, insulation_type=insulation_type, col_a_index=1, col_b_index=2, ) def u_rr_flat_ceiling( *, country: Optional[Country], age_band: Optional[str], insulation_thickness_mm: Optional[int], insulation_type: Optional[str] = None, ) -> float: """RdSAP10 §5.11.3 + Table 17 column (2): U-value for the flat ceiling section of a room-in-roof (the "External roof" element in the U985 worksheet vocabulary).""" return _u_rr_table_17( country=country, age_band=age_band, insulation_thickness_mm=insulation_thickness_mm, insulation_type=insulation_type, col_a_index=3, col_b_index=4, ) def u_rr_stud_wall( *, country: Optional[Country], age_band: Optional[str], insulation_thickness_mm: Optional[int], insulation_type: Optional[str] = None, ) -> float: """RdSAP10 §5.11.3 + Table 17 column (3): U-value for a stud wall inside a room-in-roof (typically the short vertical wall between the RR floor and the slope).""" return _u_rr_table_17( country=country, age_band=age_band, insulation_thickness_mm=insulation_thickness_mm, insulation_type=insulation_type, col_a_index=5, col_b_index=6, ) def u_rr_default_all_elements( country: Optional[Country], age_band: Optional[str], ) -> float: """RdSAP10 Table 18 column (4) — "Room-in-roof, all elements" default U-value when no detailed RR lodgement is available. Used as the catch-all fallback for the §3.9 Simplified RR cascade when assessor didn't measure individual surfaces. The spec footnote (1) on the table confirms: "value from the table applies for unknown and as built". Footnote (2) overrides age K to 0.20 W/m²K in Scotland. The mid-range fallback (0.50, matching age G) fires when the cert lodges neither age band nor country — same robustness contract as `u_roof` and the other cascade helpers (never raise on missing input). """ if age_band is None: return _RR_ALL_ELEMENTS_MID_RANGE_FALLBACK band = age_band.upper() if country is Country.SCT and band in _RR_ALL_ELEMENTS_SCOTLAND_OVERRIDES: return _RR_ALL_ELEMENTS_SCOTLAND_OVERRIDES[band] return _RR_ALL_ELEMENTS_BY_AGE.get(band, _RR_ALL_ELEMENTS_MID_RANGE_FALLBACK) # --------------------------------------------------------------------------- # 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, } # Table 19 footnote (1): age bands whose default floor_construction is # suspended timber (when unknown). All other bands default to solid. _SUSPENDED_TIMBER_DEFAULT_BANDS: Final[frozenset[str]] = frozenset({"A", "B"}) def _floor_is_suspended_from_description(description: Optional[str]) -> Optional[bool]: """Parse the cert's floor description prefix ("Solid, ..." vs "Suspended, ...") into a tri-state: True if explicitly suspended, False if explicitly solid, None if the description carries no construction signal. `EpcFloorDescriptions` in `datatypes.epc.floor` enumerates the canonical prefixes.""" if description is None: return None desc = description.lower().lstrip() if desc.startswith("suspended"): return True if desc.startswith("solid"): return False return None def _u_floor_suspended( *, area_m2: float, perimeter_m: float, wall_thickness_mm: Optional[int], insulation_thickness_mm: int, ) -> float: """Suspended ground-floor U-value per RdSAP10 §5.12 (page 46). Uses BS EN ISO 13370 with the suspended-floor adjustments — underfloor ventilation Ux is added to the soil Ug term before inverting. Parameter defaults are pinned by the spec: thermal resistance of an uninsulated deck Rf=0.2 m²K/W (adds insulation R when present); underfloor height h=0.3 m; mean wind speed v=5 m/s; wind shielding fw=0.05; ventilation openings ε=0.003 m²/m; wall-to-underfloor U_w=1.5. """ w = (wall_thickness_mm or 300) / 1000.0 soil_g = 1.5 r_si = 0.17 r_se = 0.04 r_f = 0.2 + (insulation_thickness_mm / 1000.0) / 0.035 d_g = w + soil_g * (r_si + r_se) b = 2.0 * area_m2 / perimeter_m u_g = 2.0 * soil_g * log(pi * b / d_g + 1.0) / (pi * b + d_g) u_x = (2.0 * 0.3 * 1.5 / b) + (1450.0 * 0.003 * 5.0 * 0.05 / b) return 1.0 / (2.0 * r_si + r_f + 1.0 / (u_g + u_x)) 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], description: Optional[str] = None, ) -> float: """RdSAP10 ground-floor U-value via BS EN ISO 13370 (suspended or solid branch, per Table 19 footnote 1). Result is rounded to 2 d.p. per spec §5.12 ("Unless provided by the assessor the floor U-value is calculated according to BS EN ISO 13370 using its area (A) and exposed perimeter (P) and rounded to two decimal places."). `description` is the joined surveyor text from `floors[i].description`. When it asserts retrofit insulation ("Solid, insulated (assumed)" / "Suspended, insulated (assumed)" / similar) and the assessor hasn't lodged a numeric thickness, RdSAP 10 §5.12 Table 19 footnote (2) applies: "use the greater of 50 mm and the thickness according to the age band". The cert encodes "thickness unknown" as either a missing field (`insulation_thickness_mm=None`) or "NI" which `_parse_thickness_mm` returns as 0. Full-SAP assessments lodge a measured floor U-value directly in the description ("Average thermal transmittance X W/m²K"); when present this supersedes the BS EN ISO 13370 calculation per spec §5.12 opening clause. """ measured = _measured_u_from_description(description) if measured is not None: return measured 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 age_default_mm = ( _FLOOR_INSULATION_DEFAULT_MM.get(age_band.upper(), 0) if age_band is not None else 0 ) if ins_mm is None: ins_mm = age_default_mm if (ins_mm is None or ins_mm == 0) and _described_as_insulated(description): # Table 19 footnote (2): "use the greater of 50 mm and the # thickness according to the age band". ins_mm = max(50, age_default_mm) # Table 19 footnote (1): if floor_construction is unknown, age bands # A and B default to suspended timber (the rest default to solid). # A description prefix of "Solid, ..." or "Suspended, ..." takes # precedence over the age-band default since it's an explicit assessor # observation about the construction. band_upper = age_band.upper() if age_band else None described_suspended = _floor_is_suspended_from_description(description) use_suspended_branch = ( described_suspended if described_suspended is not None else (construction is None and band_upper in _SUSPENDED_TIMBER_DEFAULT_BANDS) ) if use_suspended_branch: return round(_u_floor_suspended( area_m2=area_m2, perimeter_m=perimeter_m, wall_thickness_mm=wall_thickness_mm, insulation_thickness_mm=ins_mm or 0, ), 2) 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 round(2.0 * soil_g * log(pi * b / d_t + 1.0) / (pi * b + d_t), 2) return round(soil_g / (0.457 * b + d_t), 2) # --------------------------------------------------------------------------- # Exposed / semi-exposed upper-floor U-values (Table 20, §5.13) # --------------------------------------------------------------------------- # # Table 20 (page 47): the spec collapses exposed (to outside air) and # semi-exposed (to enclosed unheated space) into the same lookup. Keyed # on age band × insulation thickness — no geometry input. This is the # floor of e.g. a single-storey extension that hangs off the main from # the first storey upward (000490 Extension 1 is exactly this shape). # # Country footnotes: # (1) Use the 50 mm row if known to be insulated but thickness unknown. # (2) Band L → 0.18 W/m²K in Scotland. # (3) Band M → 0.15 W/m²K in Scotland AND Wales. # These are England-and-Wales values; country overrides land later. _EXPOSED_FLOOR_BY_AGE_AND_INS: Final[dict[str, tuple[float, float, float, float]]] = { # (unknown/as-built, 50mm, 100mm, 150mm) "A": (1.20, 0.50, 0.30, 0.22), "B": (1.20, 0.50, 0.30, 0.22), "C": (1.20, 0.50, 0.30, 0.22), "D": (1.20, 0.50, 0.30, 0.22), "E": (1.20, 0.50, 0.30, 0.22), "F": (1.20, 0.50, 0.30, 0.22), "G": (1.20, 0.50, 0.30, 0.22), "H": (0.51, 0.50, 0.30, 0.22), "I": (0.51, 0.50, 0.30, 0.22), "J": (0.25, 0.25, 0.25, 0.22), "K": (0.22, 0.22, 0.22, 0.22), "L": (0.22, 0.22, 0.22, 0.22), "M": (0.18, 0.18, 0.18, 0.18), } def u_exposed_floor( age_band: Optional[str], insulation_thickness_mm: Optional[int] ) -> float: """RdSAP10 Table 20 exposed/semi-exposed upper-floor U-value in W/m²K. Used when the part's floor is open to outside air or sits over an unheated space (e.g. an over-passageway extension) rather than over soil. No geometry input — the lookup is age × insulation only.""" band = (age_band or "A").upper() row = _EXPOSED_FLOOR_BY_AGE_AND_INS.get(band, _EXPOSED_FLOOR_BY_AGE_AND_INS["A"]) if insulation_thickness_mm is None or insulation_thickness_mm < 25: return row[0] if insulation_thickness_mm < 75: return row[1] if insulation_thickness_mm < 125: return row[2] return row[3] # --------------------------------------------------------------------------- # 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) # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Basement U-values (RdSAP §5.17 / Table 23) # # Applied when a building part carries an alt wall with # wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE. Per the user- # confirmed convention, the WHOLE floor=0 of that part is the basement # floor — so `u_basement_floor` overrides the regular floor U-value for # the part's ground floor area, and `u_basement_wall` overrides the # cascade for the basement alt sub-area only. # --------------------------------------------------------------------------- _BASEMENT_WALL_BY_BAND: Final[dict[str, float]] = { "A": 0.7, "B": 0.7, "C": 0.7, "D": 0.7, "E": 0.7, "F": 0.7, "G": 0.6, "H": 0.6, "I": 0.45, "J": 0.35, "K": 0.3, "L": 0.28, "M": 0.26, } _BASEMENT_FLOOR_BY_BAND: Final[dict[str, float]] = { "A": 0.50, "B": 0.50, "C": 0.50, "D": 0.50, "E": 0.50, "F": 0.50, "G": 0.5, "H": 0.5, "I": 0.5, "J": 0.25, "K": 0.22, "L": 0.22, "M": 0.18, } # RdSAP 10 §5.14 (PDF p.47) — "U-value of floor above a partially # heated space": # "The U-value of a floor above partially heated premises is taken # as 0.7 W/m²K. This applies typically for a flat above non- # domestic premises that are not heated to the same extent or # duration as the flat." # Verbatim constant — no age band / insulation thickness inputs. # Distinct from `u_exposed_floor` (Table 20 for unheated below) and # from `u_floor` (BS EN ISO 13370 ground-floor formula). _PARTIALLY_HEATED_FLOOR_U_W_PER_M2K: Final[float] = 0.7 def u_floor_above_partially_heated_space() -> float: """RdSAP 10 §5.14 (PDF p.47) — U-value (W/m²K) of a floor above a partially heated premises. Verbatim 0.7 W/m²K from the spec; no geometry / age / insulation inputs.""" return _PARTIALLY_HEATED_FLOOR_U_W_PER_M2K def u_basement_wall(age_band: Optional[str]) -> float: """Basement-wall U-value (W/m²K), RdSAP10 Table 23. Defaults to the A-E value (0.7) when age band is missing — matches the worst-case cascade behaviour elsewhere in this module.""" if age_band is None: return 0.7 return _BASEMENT_WALL_BY_BAND.get(age_band.upper(), 0.7) def u_basement_floor(age_band: Optional[str]) -> float: """Basement-floor U-value (W/m²K), RdSAP10 Table 23. Applied to the WHOLE floor=0 of a part that has a basement (per user-confirmed convention: basement-wall presence ⇒ entire ground floor is basement floor). Defaults to the A-E value (0.50) when band is missing.""" if age_band is None: return 0.50 return _BASEMENT_FLOOR_BY_BAND.get(age_band.upper(), 0.50) def u_party_wall( party_wall_construction: Optional[int], *, is_flat: bool = False, ) -> float: """RdSAP10 party-wall U-value in W/m^2K, never null. Mapping per RdSAP 10 §5.10 Table 15 (PDF p.42): - Solid masonry / timber / system built -> 0.0 (row 1) - Cavity masonry unfilled -> 0.5 (row 2) - Cavity masonry filled -> 0.2 (row 3) - Unable to determine, house -> 0.25 (row 4) - Unable to determine, flat / maisonette -> 0.0 (row 5, footnote *) `None` and `0` are both treated as the unknown sentinel — the Elmhurst mapper lodges `0` for the "U Unable to determine" code per the cross-mapper-parity convention in `datatypes/epc/domain/mapper .py:_ELMHURST_PARTY_WALL_CODE_TO_SAP10` (the API mapper translates its own "Not applicable" code to None directly). """ if party_wall_construction is None or party_wall_construction == 0: return 0.0 if is_flat else 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 if party_wall_construction == WALL_CAVITY_FILLED_PARTY: return 0.2 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