Model/domain/sap10_ml/rdsap_uvalues.py
Khalim Conn-Kowlessar f895dd3ab7 S0380.217: capture wall_insulation_thermal_conductivity (was dropped)
Second silently-dropped field from the 2130 audit: the schema-21
SapBuildingPart never declared `wall_insulation_thermal_conductivity`, so
`from_dict` discarded it. Captured it through schema 21.0.0/21.0.1 → domain
SapBuildingPart → API mapper, and wired it into u_wall's RdSAP 10 §5.8
documentary-evidence R-value calc (both the solid-brick §5.7/§5.8 path and
the cavity-composite path), replacing the bare 0.04 λ constant with a
resolved λ.

Resolver: absent / "Unknown" → the §5.8 default 0.04 W/m·K (mineral wool /
EPS); a mapped code → its λ; an unmapped integer code RAISES so the enum is
confirmed against a worksheet rather than silently mis-factored (same
incremental-coverage discipline as the glazing-type map). Only code 1
(= the default 0.04) is mapped — the sole observed value (cert 2130 Ext1).

Zero cascade effect today: the λ path fires only for solid-brick/cavity
walls with a *measured* wall thickness, and 2130 Ext1 lodges no wall
thickness, so its conductivity is captured-but-unused; all existing §5.8
certs lodge no conductivity → 0.04 default unchanged. The point is to stop
dropping lodged data and make λ correct when a future cert exercises it.

Suite: 2523 passed (1 pre-existing TFA fail); sap10_ml 237 passed (2
pre-existing stone-formula fails). Zero new pyright errors (46=46).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 11:57:00 +00:00

1386 lines
60 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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.
Consumer: `u_wall` uses this to route cavity walls to the Filled-
cavity row of Table 6 (in lieu of the bucketed cascade). For the
non-cavity `wall_ins_present` gate, `heat_transmission_from_cert`
further restricts this to genuine (non-assumed) retrofit via its
local `_described_as_retrofit_insulated`.
"""
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
def _cavity_described_as_filled(description: Optional[str]) -> bool:
"""True when an as-built cavity wall's description asserts the cavity is
insulated/filled, routing it to the Table 6 "Filled cavity" row.
Distinguishes the three as-built cavity states the EPC renders by age
band when wall_insulation_type=4 ("as-built / assumed"):
- "...insulated (assumed)" → Filled cavity (assessor judges
the cavity filled but lodges no
thickness)
- "...partial insulation (assumed)""Cavity as built" row (the
as-built partial fill of the age
band, NOT a retrofit cavity fill)
- "...no insulation (assumed)""Cavity as built" row
Narrower than `_described_as_insulated`: it excludes the "partial
insulation" substring so a "partial insulation (assumed)" cavity stays on
the as-built row. RdSAP 10 Table 6 (England) "Cavity as built" band F =
1.0 vs "Filled cavity" band F = 0.40 — for an as-built band-F cavity the
filled row understates heat loss by 2.5x. A genuine retrofit fill is
lodged distinctly as "Cavity wall, filled cavity"
(wall_insulation_type=2), handled by the explicit-code branch.
Real-cert evidence: golden cert 0390-2954-3640 (detached, band F, cavity
type 4, "partial insulation (assumed)") closes all four SAP metrics on
the as-built 1.0 row; the filled 0.40 row under-counts PE by ~28 kWh/m².
"""
if description is None:
return False
desc = description.lower()
if "no insulation" in desc:
return False
return "insulated" 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
# RdSAP 10 §5.8 (page 41) — when documentary evidence lodges the insulation
# thermal conductivity, the R-value calc uses it instead of the 0.04 default.
# The spec offers three λ: 0.04 (mineral wool / EPS, the default), 0.03 (XPS),
# 0.025 (PUR / PIR / phenolic). The GOV.UK API surfaces a coded value
# (`wall_insulation_thermal_conductivity`); code 1 = the default 0.04 (the
# only code observed — cert 2130 Ext1, whose documentary-evidence path does
# not fire as no wall thickness is lodged, so the value is captured but
# unused there). Other codes raise until a worksheet-backed fixture confirms
# their λ — the same incremental-coverage discipline as the glazing-type map.
_WALL_INSULATION_CONDUCTIVITY_CODE_TO_LAMBDA: Final[dict[int, float]] = {
1: 0.04,
}
def _resolve_wall_insulation_lambda_w_per_mk(
conductivity: "str | int | None",
) -> float:
"""Resolve the insulation λ (W/m·K) for the §5.8 documentary-evidence
R-value calc. Absent / "Unknown" → the 0.04 default; a mapped integer
code → its λ; an unmapped integer code raises so the enum is confirmed
against a worksheet rather than silently mis-factored."""
if conductivity is None:
return _WALL_INSULATION_LAMBDA_W_PER_MK
if isinstance(conductivity, str):
text = conductivity.strip()
if not text or text.lower() == "unknown" or not text.isdigit():
return _WALL_INSULATION_LAMBDA_W_PER_MK
conductivity = int(text)
lam = _WALL_INSULATION_CONDUCTIVITY_CODE_TO_LAMBDA.get(conductivity)
if lam is None:
raise ValueError(
"unmapped wall_insulation_thermal_conductivity code "
f"{conductivity!r}; add its RdSAP 10 §5.8 λ "
"(0.04 / 0.03 / 0.025 W/m·K) once a worksheet confirms it"
)
return lam
# 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,
wall_insulation_thermal_conductivity: "str | int | None" = 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,
_resolve_wall_insulation_lambda_w_per_mk(
wall_insulation_thermal_conductivity
),
)
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) / _resolve_wall_insulation_lambda_w_per_mk(
wall_insulation_thermal_conductivity
)
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 _cavity_described_as_filled(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,
is_sloping_ceiling: bool = False,
is_pitched_sloping_ceiling: 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.
`is_sloping_ceiling` flags a pitched roof whose ceiling follows the
slope (a "Pitched, sloping ceiling" or "Pitched (vaulted ceiling)"
construction — RdSAP roof_construction codes 8 and 5). Such a roof has
no loft / ceiling-joist void, so an "NI" lodgement (parsed to 0) +
"insulated (assumed)" description means unknown-thickness-with-insulation,
NOT the §5.11.4 retrofit-50 mm joist row (0.68) a normal pitched-with-
loft roof would take. It instead takes the Table 18 column (1) age-band
default (band J = 0.16) — the same value a vaulted roof lodged "ND"
(thickness None) already reaches by falling through. The 33 cohort-2
"ND" vaulted certs (code 5, band D → 0.40 = col 1) are the evidence.
`is_pitched_sloping_ceiling` is the narrower code-8 ("Pitched, sloping
ceiling") signal for the AS-BUILT case (insulation lodged "As Built",
parsed to thickness None — distinct from the "NI"/"ND" unknown case
above). Per RdSAP 10 roof-input item 5-5 ("Sloping ceiling insulation
... as built → Table 18") and Table 18 note (b) ("applies also to roof
with sloping ceiling"), an as-built sloping ceiling takes the column
(3) age-band default (band F = 0.68, band L = 0.18), NOT the column (1)
loft-joist default (band F = 0.40, band L = 0.16). Vaulted ceilings
(code 5) are deliberately excluded — they stay on column (1) per the
cohort evidence above. Worksheet-validated by simulated case 15 (the
7536 replica): Ext1 band L → 0.18, Ext2 band F → 0.68.
"""
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 (
is_sloping_ceiling
and age_band is not None
and insulation_thickness_mm == 0
and _described_as_insulated(description)
):
# RdSAP 10 §5.11 Table 18 page 45 — a vaulted/sloping ceiling has no
# ceiling-joist void, so the "NI" sentinel (parsed to 0) +
# "insulated (assumed)" is unknown-thickness-with-insulation, not
# 0 mm uninsulated. It must NOT fall to the §5.11.4 retrofit-50 mm
# joist row (0.68) below; it takes the column (1) age-band default
# (band J = 0.16), matching the cohort's "ND" (thickness None)
# vaulted roofs which already reach col (1) by falling through.
return _ROOF_BY_AGE.get(age_band.upper(), 0.4)
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_pitched_sloping_ceiling:
# RdSAP 10 §5.11 Table 18 page 45 column (3) + roof-input item 5-5:
# an as-built "Pitched, sloping ceiling" (code 8) with no measured
# thickness takes the column (3) age-band default, not the column
# (1) loft-joist default. Note (b): column (3) "applies also to
# roof with sloping ceiling". (Pre-1950 bands reach the same value
# via the mapper's thickness=0 → Table 16 row-0 2.30 override, so
# this branch carries the post-1950 bands where col 1 ≠ col 3.)
return _FLAT_ROOF_BY_AGE.get(age_band.upper(), 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