diff --git a/datatypes/epc/domain/epc_property_data.py b/datatypes/epc/domain/epc_property_data.py index 39386781..63ef5f97 100644 --- a/datatypes/epc/domain/epc_property_data.py +++ b/datatypes/epc/domain/epc_property_data.py @@ -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 diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 41c07a6d..4f6abc04 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -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 diff --git a/datatypes/epc/schema/rdsap_schema_21_0_0.py b/datatypes/epc/schema/rdsap_schema_21_0_0.py index 71d2cbf8..dee7002d 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_0.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_0.py @@ -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 diff --git a/datatypes/epc/schema/rdsap_schema_21_0_1.py b/datatypes/epc/schema/rdsap_schema_21_0_1.py index 59ff41c9..87cbf91e 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -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 diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 2a4d7088..d21b2eb1 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -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 diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index 529b2fcb..8013e3d7 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -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). diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index 85dbd489..00cf7164 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -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)