mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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>
1386 lines
60 KiB
Python
1386 lines
60 KiB
Python
"""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
|