S0380.217: capture wall_insulation_thermal_conductivity (was dropped)

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-04 11:57:00 +00:00
parent 6b4f1aec44
commit f895dd3ab7
7 changed files with 140 additions and 2 deletions

View file

@ -477,6 +477,10 @@ class SapBuildingPart:
# separate measured field), else the lodged string ("NI", a numeric
# string, etc.). Mirrors `roof_insulation_thickness`.
wall_insulation_thickness: Optional[Union[str, int]] = None
# RdSAP 10 §5.8 thermal-conductivity code for measured wall insulation
# (λ = 0.04 / 0.03 / 0.025 W/m·K). Used by the documentary-evidence
# R-value path when a measured wall thickness is lodged alongside it.
wall_insulation_thermal_conductivity: Optional[Union[str, int]] = None
sap_alternative_wall_1: Optional[SapAlternativeWall] = None
sap_alternative_wall_2: Optional[SapAlternativeWall] = None

View file

@ -564,6 +564,9 @@ class EpcPropertyDataMapper:
bp.wall_insulation_thickness,
getattr(bp, "wall_insulation_thickness_measured", None),
),
wall_insulation_thermal_conductivity=getattr(
bp, "wall_insulation_thermal_conductivity", None
),
floor_heat_loss=bp.floor_heat_loss,
floor_insulation_thickness=None,
roof_construction=bp.roof_construction,
@ -700,6 +703,9 @@ class EpcPropertyDataMapper:
bp.wall_insulation_thickness,
getattr(bp, "wall_insulation_thickness_measured", None),
),
wall_insulation_thermal_conductivity=getattr(
bp, "wall_insulation_thermal_conductivity", None
),
floor_heat_loss=bp.floor_heat_loss,
floor_insulation_thickness=None,
roof_construction=bp.roof_construction,
@ -836,6 +842,9 @@ class EpcPropertyDataMapper:
bp.wall_insulation_thickness,
getattr(bp, "wall_insulation_thickness_measured", None),
),
wall_insulation_thermal_conductivity=getattr(
bp, "wall_insulation_thermal_conductivity", None
),
floor_heat_loss=bp.floor_heat_loss,
# API certs commonly lodge "NI" (no measured
# thickness) on floors that aren't actually
@ -998,6 +1007,9 @@ class EpcPropertyDataMapper:
bp.wall_insulation_thickness,
getattr(bp, "wall_insulation_thickness_measured", None),
),
wall_insulation_thermal_conductivity=getattr(
bp, "wall_insulation_thermal_conductivity", None
),
floor_heat_loss=bp.floor_heat_loss,
# API certs commonly lodge "NI" (no measured
# thickness) on floors that aren't actually
@ -1177,6 +1189,9 @@ class EpcPropertyDataMapper:
bp.wall_insulation_thickness,
getattr(bp, "wall_insulation_thickness_measured", None),
),
wall_insulation_thermal_conductivity=getattr(
bp, "wall_insulation_thermal_conductivity", None
),
floor_heat_loss=bp.floor_heat_loss,
# API certs commonly lodge "NI" (no measured
# thickness) on floors that aren't actually
@ -1397,6 +1412,9 @@ class EpcPropertyDataMapper:
bp.wall_insulation_thickness,
getattr(bp, "wall_insulation_thickness_measured", None),
),
wall_insulation_thermal_conductivity=getattr(
bp, "wall_insulation_thermal_conductivity", None
),
floor_heat_loss=bp.floor_heat_loss,
# API certs commonly lodge "NI" (no measured
# thickness) on floors that aren't actually
@ -1662,6 +1680,9 @@ class EpcPropertyDataMapper:
bp.wall_insulation_thickness,
getattr(bp, "wall_insulation_thickness_measured", None),
),
wall_insulation_thermal_conductivity=getattr(
bp, "wall_insulation_thermal_conductivity", None
),
floor_heat_loss=bp.floor_heat_loss,
# API certs commonly lodge "NI" (no measured
# thickness) on floors that aren't actually

View file

@ -246,6 +246,11 @@ class SapBuildingPart:
# undeclared, so `from_dict` silently dropped it and the cascade fell
# back to the 50 mm "insulation present, unknown thickness" default.
wall_insulation_thickness_measured: Optional[Union[str, int]] = None
# Lodged thermal-conductivity code for measured wall insulation
# (RdSAP 10 §5.8: λ = 0.04 / 0.03 / 0.025 W/m·K). Previously undeclared
# → dropped by `from_dict`. Consumed by `u_wall`'s documentary-evidence
# R-value path when a measured wall thickness is also lodged.
wall_insulation_thermal_conductivity: Optional[Union[str, int]] = None
floor_insulation_thickness: Optional[str] = None
flat_roof_insulation_thickness: Optional[Union[str, int]] = None

View file

@ -284,6 +284,11 @@ class SapBuildingPart:
# undeclared, so `from_dict` silently dropped it and the cascade fell
# back to the 50 mm "insulation present, unknown thickness" default.
wall_insulation_thickness_measured: Optional[Union[str, int]] = None
# Lodged thermal-conductivity code for measured wall insulation
# (RdSAP 10 §5.8: λ = 0.04 / 0.03 / 0.025 W/m·K). Previously undeclared
# → dropped by `from_dict`. Consumed by `u_wall`'s documentary-evidence
# R-value path when a measured wall thickness is also lodged.
wall_insulation_thermal_conductivity: Optional[Union[str, int]] = None
floor_insulation_thickness: Optional[str] = None
flat_roof_insulation_thickness: Optional[Union[str, int]] = None

View file

@ -750,6 +750,10 @@ def heat_transmission_from_cert(
# insulation_type combination doesn't match the formula
# path's preconditions.
wall_thickness_mm=part.wall_thickness_mm,
# RdSAP 10 §5.8 — lodged insulation thermal-conductivity
# code feeds the documentary-evidence R-value calc when a
# measured wall thickness is also present (else ignored).
wall_insulation_thermal_conductivity=part.wall_insulation_thermal_conductivity,
)
# When the per-bp `roof_insulation_thickness` is explicitly lodged
# as 0 (uninsulated — e.g. cert 001479 Ext2 PS sloping ceiling

View file

@ -177,6 +177,43 @@ WALL_INSULATION_CAVITY_PLUS_INTERNAL: Final[int] = 7
# (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
@ -489,6 +526,7 @@ def u_wall(
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.
@ -601,7 +639,10 @@ def u_wall(
):
u0 = _u_brick_thin_wall_age_a_to_e(wall_thickness_mm)
r_ins = _r_insulation_table_14(
insulation_thickness_mm, _WALL_INSULATION_LAMBDA_W_PER_MK,
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(
@ -623,7 +664,9 @@ def u_wall(
# 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
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).

View file

@ -1870,3 +1870,59 @@ def test_u_floor_matches_section_5_12_formula_for_cohort_geometry(
# Assert
assert abs(u - expected_u) < 1e-4
def test_resolve_wall_insulation_lambda_absent_uses_default() -> None:
# Arrange — no lodged conductivity → RdSAP 10 §5.8 default 0.04 W/m·K.
from domain.sap10_ml.rdsap_uvalues import (
_resolve_wall_insulation_lambda_w_per_mk,
)
# Act
lam = _resolve_wall_insulation_lambda_w_per_mk(None)
# Assert
assert abs(lam - 0.04) <= 1e-9
def test_resolve_wall_insulation_lambda_unknown_string_uses_default() -> None:
# Arrange — a non-numeric "Unknown" lodgement defers to the default.
from domain.sap10_ml.rdsap_uvalues import (
_resolve_wall_insulation_lambda_w_per_mk,
)
# Act
lam = _resolve_wall_insulation_lambda_w_per_mk("Unknown")
# Assert
assert abs(lam - 0.04) <= 1e-9
def test_resolve_wall_insulation_lambda_code_1_is_default_mineral_wool() -> None:
# Arrange — code 1 = the §5.8 default λ=0.04 (mineral wool / EPS);
# cert 2130 Ext1 lodges this. Numeric-string form resolves identically.
from domain.sap10_ml.rdsap_uvalues import (
_resolve_wall_insulation_lambda_w_per_mk,
)
# Act
lam_int = _resolve_wall_insulation_lambda_w_per_mk(1)
lam_str = _resolve_wall_insulation_lambda_w_per_mk("1")
# Assert
assert abs(lam_int - 0.04) <= 1e-9
assert abs(lam_str - 0.04) <= 1e-9
def test_resolve_wall_insulation_lambda_unmapped_code_raises() -> None:
# Arrange — an unmapped code must raise (incremental-coverage gate)
# rather than silently mis-factor the R-value.
import pytest as _pytest
from domain.sap10_ml.rdsap_uvalues import (
_resolve_wall_insulation_lambda_w_per_mk,
)
# Act / Assert
with _pytest.raises(ValueError):
_resolve_wall_insulation_lambda_w_per_mk(2)