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