Model/domain/sap10_ml/rdsap_uvalues.py
Khalim Conn-Kowlessar a736db3f4a Slice 101b: HP cert 0380 — cavity+EWI wall U + Table 11 cat-4 secondary
Two HP-specific cascade gaps blocking cert 0380:

(a) Cavity wall + filled cavity + external insulation:
    Cert 0380's `walls[0].description="Cavity wall, filled cavity and
    external insulation"` with `wall_insulation_type=6` +
    `wall_insulation_thickness="100mm"`. RdSAP 10 §4-4 (page 73) lists
    "cavity plus external" as a distinct insulation type code (6 in
    the API schema; 7 is "cavity plus internal"). The U-value is the
    composite U = 1 / (1/U_filled + R_ins) per §5.8 page 40 + Table 14
    R-value lookup, with the cascade-2-d.p. round matching the dr87
    worksheet's column display.

    For cert 0380: U_filled (age D)=0.7 + R_ins (100mm @ λ=0.04)=2.5
    → U_unrounded=0.2545 → rounded 0.25 (worksheet exact). Walls HLC
    14.87 → 11.6150 (= worksheet 11.6150). (37) total fabric heat
    loss 99.34 → **96.0889** (= worksheet 96.0889 EXACT).

    Added `WALL_INSULATION_CAVITY_PLUS_EXTERNAL: Final[int] = 6` and
    `WALL_INSULATION_CAVITY_PLUS_INTERNAL: Final[int] = 7` constants
    + `_WALL_INSULATION_LAMBDA_W_PER_MK = 0.04` default thermal
    conductivity. New `u_wall` branch fires when cavity + composite
    insulation type + non-zero thickness.

(b) SAP 10.2 Table 11 secondary fraction — missing cat-4 entry:
    The dict `_SECONDARY_HEATING_FRACTION_BY_CATEGORY` had entries
    for cats 1/2/3/5/6/7/10 but DID NOT include cat 4 (heat pump),
    despite the inline comment explicitly noting "Cat 4 (heat pump):
    0.00 (HP eff includes any secondary)". Cert 0380 lodges
    `secondary_heating_type=691` + `main_heating_category=4` (HP,
    PCDB idx 104568), so the cascade fell through to the DEFAULT
    fraction 0.10 — billing 547 kWh × 13.19 p/kWh = £72 as
    "secondary heating" that the worksheet correctly shows as £0.

    Added `4: 0.00` to the dict.

Effect on cert 0380 API path:
- walls HLC 14.87 → 11.62 (worksheet exact)
- (37) total HLC 99.34 → 96.09 (worksheet exact)
- main_heating_cost £282 → £314 (worksheet £316)
- secondary_heating £72 → £0 (worksheet £0)
- sap_continuous 87.62 → 90.48 (Δ -0.89 → +1.97 — over-correcting
  because hot-water cascade is still cascade-£66 vs worksheet £204
  including electric shower; HP HW-COP + electric-shower cost are
  the next slices).

No golden cert residual shifts (cohort certs don't lodge HP cat 4
or composite cavity+EWI walls).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 16:28:45 +00:00

1001 lines
41 KiB
Python
Raw 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.
Two consumers:
- `u_wall` uses this to route cavity walls to the Filled-cavity row
of Table 6 (in lieu of the bucketed cascade).
- `heat_transmission_from_cert` uses this to set `wall_ins_present`
for non-cavity walls so the 50 mm bucket routing fires per the
RdSAP 10 Table 6 footnote ("If a wall is known to have additional
insulation but the insulation thickness is unknown, use the row
in the table for 50 mm insulation").
"""
if description is None:
return False
desc = description.lower()
if "no insulation" in desc:
return False
return "insulated" in desc or "partial insulation" in desc
# ---------------------------------------------------------------------------
# Country
# ---------------------------------------------------------------------------
class Country(Enum):
"""Country code for RdSAP table selection.
The EPC dataset uses ISO-like codes (ENG/WAL/SCT/NIR) plus an aggregate
EAW (England-and-Wales) string. We collapse EAW and any unrecognised
code to ENG since Table 6 (England) and Table 10 (Isle of Man) are
identical and used as our base.
"""
ENG = "ENG"
WAL = "WAL"
SCT = "SCT"
NIR = "NIR"
@classmethod
def from_code(cls, code: Optional[str]) -> "Country":
if code is None:
return cls.ENG
norm = code.upper()
if norm in ("EAW", "GB", "UK"):
return cls.ENG
try:
return cls(norm)
except ValueError:
return cls.ENG
# ---------------------------------------------------------------------------
# SAP10 wall_construction integer codes
# ---------------------------------------------------------------------------
WALL_STONE_GRANITE: Final[int] = 1
WALL_STONE_SANDSTONE: Final[int] = 2
WALL_SOLID_BRICK: Final[int] = 3
WALL_CAVITY: Final[int] = 4
WALL_TIMBER_FRAME: Final[int] = 5
WALL_SYSTEM_BUILT: Final[int] = 6
WALL_COB: Final[int] = 7
WALL_PARK_HOME: Final[int] = 8
WALL_CURTAIN: Final[int] = 9
WALL_UNKNOWN: Final[int] = 10
# 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_FILLED_CAVITY: Final[int] = 2
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
_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,
) -> 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).
"""
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
ctry = country if country is not None else Country.ENG
age_idx = _age_index(age_band)
band = _AGE_BANDS[age_idx]
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)
if wall_type == WALL_CAVITY and wall_insulation_type in (
WALL_INSULATION_CAVITY_PLUS_EXTERNAL,
WALL_INSULATION_CAVITY_PLUS_INTERNAL,
) and insulation_thickness_mm is not None and insulation_thickness_mm > 0:
# RdSAP 10 §4-4 + §4-6 (page 73): composite "filled cavity plus
# external/internal insulation" — U_total = 1 / (1/U_filled +
# R_ins) where R_ins = (thickness_mm / 1000) / λ. λ defaults to
# 0.04 W/m·K when no documentary evidence is lodged. Cert 0380
# lodges code 6 + 100mm → U_filled (age D)=0.7 + R=2.5 →
# U_total ≈ 0.2545 → rounded to 2 d.p. = 0.25 (worksheet).
#
# The 2-d.p. round matches dr87 / Elmhurst tool behaviour
# (worksheet displays "0.2500" = 2-d.p. value padded to 4 d.p.
# for column alignment). Cascade-internal HLC then uses the
# rounded U so net wall HLC matches `A × U_2dp` exactly.
u_filled = _CAVITY_FILLED_ENG[age_idx]
r_ins = (insulation_thickness_mm / 1000.0) / _WALL_INSULATION_LAMBDA_W_PER_MK
u_unrounded = 1.0 / (1.0 / u_filled + r_ins)
# Half-up 2-d.p. round so 0.2545 → 0.25, matching the dr87
# worksheet's column-display behaviour (used downstream in A×U).
return float(
Decimal(str(u_unrounded)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
)
if wall_type == WALL_CAVITY and (
wall_insulation_type == WALL_INSULATION_FILLED_CAVITY
or _described_as_insulated(description)
):
return _CAVITY_FILLED_ENG[age_idx]
bucket = _insulation_bucket(insulation_thickness_mm, insulation_present)
# Country override first.
overrides = _COUNTRY_KLM_OVERRIDES.get(ctry, {}).get((wall_type, bucket), {})
if band in overrides:
return overrides[band]
base = _ENG_WALL.get((wall_type, bucket))
if base is None:
return 1.5
return base[age_idx]
# ---------------------------------------------------------------------------
# Roof U-values (Tables 16 + 18)
# ---------------------------------------------------------------------------
# Table 16 column (1): joist insulation at ceiling. Thickness mm -> U.
_ROOF_BY_THICKNESS: Final[list[tuple[int, float]]] = [
(0, 2.30), (12, 1.50), (25, 1.00), (50, 0.68), (75, 0.50),
(100, 0.40), (125, 0.35), (150, 0.30), (175, 0.25), (200, 0.21),
(225, 0.19), (250, 0.17), (270, 0.16), (300, 0.14), (350, 0.12),
(400, 0.11),
]
# Table 18 column (1): pitched-roof default U by age band when thickness unknown.
_ROOF_BY_AGE: Final[dict[str, float]] = {
"A": 0.40, "B": 0.40, "C": 0.40, "D": 0.40, "E": 0.40,
"F": 0.40, "G": 0.40, "H": 0.30, "I": 0.26, "J": 0.16,
"K": 0.16, "L": 0.16, "M": 0.15,
}
# Table 18 column (3): flat-roof default U by age band when thickness unknown.
# RdSAP 10 §5.11 Table 18 page 45 — the pitched-roof column (1) defaults
# bottom out at 0.40 because "between joists insulation" is the implicit
# Table-16 reference; the flat-roof column (3) drops directly to the
# Table 16 row-0 / "no insulation" value (2.30) for old age bands and
# follows Table 16's thickness ladder for modern ones. A flat roof
# without a measured insulation thickness lodgement therefore cannot
# share the pitched-roof age-band fallback — for an age D dwelling the
# spec value is 2.30, not 0.40 (5.75x understatement of heat loss).
_FLAT_ROOF_BY_AGE: Final[dict[str, float]] = {
"A": 2.30, "B": 2.30, "C": 2.30, "D": 2.30, "E": 1.50,
"F": 0.68, "G": 0.40, "H": 0.35, "I": 0.35, "J": 0.25,
"K": 0.25, "L": 0.18, "M": 0.15,
}
# Table 18 column (4): "Room-in-roof, all elements" default U by age band
# when no detailed RR lodgement is available. Footnote (1) on each entry
# confirms "value from the table applies for unknown and as built".
# Scotland override per footnote (2): age band K → 0.20 W/m²K (other bands
# unchanged).
_RR_ALL_ELEMENTS_BY_AGE: Final[dict[str, float]] = {
"A": 2.30, "B": 2.30, "C": 2.30, "D": 2.30,
"E": 1.50, "F": 0.80, "G": 0.50, "H": 0.35,
"I": 0.35, "J": 0.30, "K": 0.25, "L": 0.18, "M": 0.15,
}
_RR_ALL_ELEMENTS_SCOTLAND_OVERRIDES: Final[dict[str, float]] = {"K": 0.20}
_RR_ALL_ELEMENTS_MID_RANGE_FALLBACK: Final[float] = 0.50
_ROOF_NO_INSULATION_MARKERS: Final[tuple[str, ...]] = (
"no insulation",
"uninsulated",
)
_ROOF_LIMITED_INSULATION_MARKERS: Final[tuple[str, ...]] = (
"limited insulation",
)
def u_roof(
country: Optional[Country],
age_band: Optional[str],
insulation_thickness_mm: Optional[int],
description: Optional[str] = None,
is_flat_roof: bool = False,
) -> float:
"""RdSAP10 roof U-value in W/m^2K, never null.
Resolution order:
1. Explicit `insulation_thickness_mm` → Table 16 column (1) joist row.
2. Surveyor `description` text (top-level `roofs[i].description`) flagging
uninsulated / limited-insulation roofs → Table 16 0mm / 12mm rows.
Table 18 age-band defaults assume joist insulation ≥100 mm, which is
wrong for catastrophic heritage roofs the EPC explicitly describes
as uninsulated.
3. Table 18 age-band default — column (1) "Pitched, insulation between
joists" by default; column (3) "Flat roof" when `is_flat_roof=True`.
Spec §5.11 Table 18 page 45.
"""
measured = _measured_u_from_description(description)
if measured is not None:
# Full-SAP cert lodges a measured roof U-value in the description
# ("Average thermal transmittance X W/m²K"); spec §5.11 opening
# clause defers to the assessor's value when present.
return measured
if insulation_thickness_mm == 0 and _described_as_insulated(description):
# Spec §5.11.4 (page 44 footnote): "If retrofit insulation
# present of unknown thickness use 50 mm". The cert encodes
# "thickness unknown but retrofit insulation present" as the
# "NI" sentinel which `_parse_thickness_mm` parses to 0. Without
# this override the Table 16 row-0 lookup below returns the
# uninsulated 2.30 W/m²K.
return 0.68 # Table 16 row 50, "Insulation at joists at ceiling level"
if insulation_thickness_mm is not None:
# nearest tabulated thickness <= supplied
u = _ROOF_BY_THICKNESS[0][1]
for t, val in _ROOF_BY_THICKNESS:
if insulation_thickness_mm >= t:
u = val
return u
if description is not None:
desc = description.lower()
if any(marker in desc for marker in _ROOF_NO_INSULATION_MARKERS):
return _ROOF_BY_THICKNESS[0][1] # 2.30 W/m^2K
if any(marker in desc for marker in _ROOF_LIMITED_INSULATION_MARKERS):
return _ROOF_BY_THICKNESS[1][1] # 1.50 W/m^2K (12mm row)
if age_band is None:
return 0.4
if is_flat_roof:
return _FLAT_ROOF_BY_AGE.get(age_band.upper(), 0.4)
return _ROOF_BY_AGE.get(age_band.upper(), 0.4)
# RdSAP10 Table 17 — U-values for rooms in roof where insulation thickness
# is known. Each tuple row is (thickness_mm, col_1a, col_1b, col_2a, col_2b,
# col_3a, col_3b) per spec page 44 (mineral wool/EPS for "a", PUR/PIR for
# "b"). Row "none" represents thickness 0 mm. Row ">400" represents any
# thickness ≥ 400 mm.
_RR_TABLE_17_ROWS: Final[tuple[tuple[int, float, float, float, float, float, float], ...]] = (
(0, 2.30, 2.30, 2.30, 2.30, 2.30, 2.30),
(12, 1.50, 1.25, 1.75, 1.50, 0.95, 0.85),
(25, 1.00, 0.80, 1.25, 1.00, 0.70, 0.60),
(50, 0.68, 0.52, 0.88, 0.69, 0.52, 0.45),
(75, 0.50, 0.38, 0.67, 0.51, 0.43, 0.35),
(100, 0.40, 0.30, 0.54, 0.41, 0.36, 0.29),
(125, 0.35, 0.25, 0.45, 0.34, 0.31, 0.24),
(150, 0.30, 0.21, 0.39, 0.29, 0.27, 0.21),
(175, 0.25, 0.17, 0.32, 0.23, 0.24, 0.19),
(200, 0.21, 0.15, 0.29, 0.20, 0.22, 0.17),
(225, 0.19, 0.13, 0.25, 0.18, 0.20, 0.15),
(250, 0.17, 0.11, 0.23, 0.15, 0.18, 0.14),
(270, 0.16, 0.10, 0.21, 0.14, 0.17, 0.13),
(300, 0.14, 0.09, 0.19, 0.13, 0.16, 0.12),
(350, 0.12, 0.08, 0.16, 0.11, 0.14, 0.11),
(400, 0.11, 0.07, 0.14, 0.09, 0.12, 0.10),
)
# Aliases mapping (insulation_type, column) → tuple index above. The PDF
# splits each Table 17 column into "(a) mineral wool or EPS slab" vs "(b)
# PUR or PIR optional". Aliases collapse common synonyms.
_RR_RIGID_FOAM_INSULATION_TYPES: Final[frozenset[str]] = frozenset({"pur", "pir", "rigid"})
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,
}
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]) -> float:
"""RdSAP10 party-wall U-value in W/m^2K, never null.
Mapping: solid masonry / timber / system built -> 0.0; cavity unfilled
-> 0.5; cavity filled -> 0.2; unknown -> 0.25 (house default).
"""
if party_wall_construction is None:
return 0.25
if party_wall_construction in (WALL_SOLID_BRICK, WALL_STONE_GRANITE, WALL_STONE_SANDSTONE, WALL_TIMBER_FRAME, WALL_SYSTEM_BUILT):
return 0.0
if party_wall_construction == WALL_CAVITY:
return 0.5
return 0.25
# ---------------------------------------------------------------------------
# Thermal bridging (Table 21)
# ---------------------------------------------------------------------------
def thermal_bridging_y(age_band: Optional[str]) -> float:
"""RdSAP10 thermal-bridging factor y in W/m^2K (multiplied by total
exposed area). Table 21: A-I -> 0.15, J -> 0.11, K-M -> 0.08."""
if age_band is None:
return 0.15
band = age_band.upper()
if band in ("K", "L", "M"):
return 0.08
if band == "J":
return 0.11
return 0.15