slice S-B27: Table 19 footnote (2) — floor "NI" + insulated description

The cert's `floor_insulation_thickness` field carries "NI" (Not
Indicated) on 58% of corpus certs — by far the most common value. For
~2 413 of those (12% of corpus) the description also says "Solid,
insulated (assumed)" or "Suspended, insulated (assumed)" — the
assessor saw insulation but didn't measure the thickness. Our
`_parse_thickness_mm("NI")` returns 0, which feeds `u_floor` as an
explicit "0 mm" → r_f=0 → uninsulated-floor U-value. Wrong.

RdSAP 10 §5.12 Table 19 footnote (2) (page 46): "For floors which
have retrofitted insulation, use the greater of 50 mm and the
thickness according to the age band". `u_floor` now accepts a
`description` kwarg; when `_described_as_insulated(description)` is
true and the lodged thickness is missing/zero, ins_mm =
max(50, age-band default).

Geometry sanity-check, 100 m² × 40 m perimeter, w=0.3 (B=5):
- Uninsulated solid floor: d_t = 0.615, U = 0.60 W/m²K
- 50 mm assumption:        d_t = 2.758, U = 0.31 W/m²K

Parity probe at 300 certs, seed=7:
  PE MAE  45.37 → 44.19 (-1.18)
  PE bias 39.75 → 38.56 (-1.19)
  Band J bias +41.2 → +29.7 (-11.5)
  Band K bias +34.1 → +22.4 (-11.7)
  Band L bias +19.6 → +11.3 (-8.3)
  Band M bias +86.3 → +55.1 (-31.2)
  Bands A-H mostly unchanged (max(50, 0) = 50 either way; description
    overrides on older stock are rarer in this sample)

The K-L-M dwellings improved most because for them the age-band
default insulation (100-140 mm) is now applied instead of 0 mm.

Cumulative across S-B23 → S-B27:
  PE MAE  57.28 → 44.19 (-13.09)
  PE bias 51.56 → 38.56 (-13.00)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-18 21:40:18 +00:00
parent 361f91546b
commit 1f49fa03cd
4 changed files with 120 additions and 2 deletions

View file

@ -455,11 +455,21 @@ def u_floor(
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 solid-floor branch.
Suspended-floor branch is approximated as solid since the difference at
the feature-engineering granularity is < 0.1 W/m^2K for typical UK floors.
`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.
"""
if area_m2 is None or perimeter_m is None or perimeter_m <= 0:
return 0.7
@ -468,8 +478,17 @@ def u_floor(
r_si = 0.17
r_se = 0.04
ins_mm = insulation_thickness_mm
if ins_mm is None and age_band is not None:
ins_mm = _FLOOR_INSULATION_DEFAULT_MM.get(age_band.upper(), 0)
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)
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

View file

@ -578,6 +578,60 @@ def test_u_roof_explicit_thickness_beats_description() -> None:
# ----- Floors -----
def test_u_floor_ni_thickness_with_insulated_description_applies_50mm_per_table19_footnote() -> None:
# Arrange — 2 413 corpus certs (~12%) lodge floors with
# floor_insulation_thickness="NI" (Not Indicated, which our
# _parse_thickness_mm returns as 0) AND a description "Solid,
# insulated (assumed)" or "Suspended, insulated (assumed)". The
# assessor sees insulation but hasn't measured the thickness.
# RdSAP 10 §5.12 Table 19 footnote (2):
# "For floors which have retrofitted insulation, use the greater
# of 50 mm and the thickness according to the age band."
# Band B's age-band default is 0 mm, so max(50, 0) = 50 mm applies.
# Geometry: 100 m² × 40 m perimeter, w=0.3, gives B=5, d_t=2.758
# (with R_f from 50 mm/0.035 = 1.429); U = 2 × 1.5 × ln(π×5/2.758 + 1)
# / (π×5 + 2.758) ≈ 0.31 W/m²K.
# Act
result = u_floor(
country=Country.ENG,
age_band="B",
construction=None,
insulation_thickness_mm=0, # parsed from "NI"
area_m2=100.0,
perimeter_m=40.0,
wall_thickness_mm=300,
description="Solid, insulated (assumed)",
)
# Assert
assert result == pytest.approx(0.31, abs=0.02)
def test_u_floor_ni_thickness_with_no_insulation_description_stays_uninsulated() -> None:
# Arrange — 8 221 corpus certs lodge "Solid, no insulation
# (assumed)" with thickness="NI". The Table 19 footnote (2) override
# must not fire on these: the "no insulation" substring takes
# precedence over the "insulated" substring per
# `_described_as_insulated`. Same geometry as the cycle-1 test;
# uninsulated U should be ~0.60 W/m²K (B=5, d_t=0.615 with R_f=0).
# Act
result = u_floor(
country=Country.ENG,
age_band="B",
construction=None,
insulation_thickness_mm=0, # parsed from "NI"
area_m2=100.0,
perimeter_m=40.0,
wall_thickness_mm=300,
description="Solid, no insulation (assumed)",
)
# Assert
assert result == pytest.approx(0.60, abs=0.02)
def test_u_floor_solid_uninsulated_typical_geometry_returns_iso_13370_value() -> None:
# Arrange — solid floor, age C, England.
# BS EN ISO 13370 with A=80, P=36, w=0.22m, soil g=1.5, Rsi=0.17, Rse=0.04, Rf=0

View file

@ -170,6 +170,7 @@ def heat_transmission_from_cert(
country = Country.from_code(epc.country_code)
roof_description = _joined_descriptions(epc.roofs)
wall_description = _joined_descriptions(epc.walls)
floor_description = _joined_descriptions(epc.floors)
door_area = max(0, door_count) * _DEFAULT_DOOR_AREA_M2
window_u = window_avg_u_value if (window_avg_u_value or 0) > 0 else u_window(
@ -233,6 +234,7 @@ def heat_transmission_from_cert(
insulation_thickness_mm=floor_ins_thickness,
area_m2=floor_area, perimeter_m=floor_perimeter,
wall_thickness_mm=part.wall_thickness_mm,
description=floor_description,
)
upw = u_party_wall(party_wall_construction=party_construction)
y = thermal_bridging_y(age_band=age_band)

View file

@ -31,6 +31,49 @@ from domain.sap.worksheet.heat_transmission import (
)
def test_floor_insulated_assumed_with_ni_thickness_uses_50mm_per_table19_footnote() -> None:
# Arrange — 2 413 corpus certs lodge floors with thickness="NI" and
# description "Solid, insulated (assumed)". The retrofit-insulation
# signal in the description must flow from epc.floors[0].description
# through heat_transmission_from_cert into u_floor so the 50 mm
# assumption fires per RdSAP 10 §5.12 Table 19 footnote (2).
# Geometry: 100 m² floor, 40 m perimeter, w=0.3 → B=5, U ≈ 0.31.
# floor_w_per_k expected = 0.31 × 100 ≈ 31 W/K (cf. uninsulated
# case which would give 0.60 × 100 = 60 W/K).
main = make_building_part(
identifier="Main Dwelling",
construction_age_band="B",
wall_construction=3,
wall_insulation_type=4,
party_wall_construction=1,
roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=100.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=40.0, floor=0,
),
],
)
epc = make_minimal_sap10_epc(
total_floor_area_m2=100.0,
country_code="ENG",
sap_building_parts=[main],
)
epc.floors = [
EnergyElement(
description="Solid, insulated (assumed)",
energy_efficiency_rating=3,
environmental_efficiency_rating=3,
),
]
# Act
result = heat_transmission_from_cert(epc)
# Assert
assert result.floor_w_per_k == pytest.approx(31.0, abs=2.0)
def test_solid_brick_as_built_insulated_assumed_uses_50mm_row_per_table6_footnote() -> None:
# Arrange — 128 corpus certs lodge solid-brick walls with
# wall_insulation_type=4 ("as-built / assumed") AND description