slice S-B26: NI thickness + assumed-insulated descriptions route to 50mm row

Two related bugs both produced U=1.7 for retrofit-insulated solid-brick
walls when the spec says U=0.55 (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"):

1. _insulation_bucket(0, True) returned 0 instead of 50. The "NI"
   sentinel parses to 0 via _parse_thickness_mm, then the bucket
   function's "< 25 -> 0" branch ignored the insulation_present signal.
   Affects 56 corpus certs lodging solid-brick with type=1 or type=3
   plus thickness="NI".

2. wall_ins_present was set False whenever wall_insulation_type == 4
   ("as-built / assumed"), even if the description said
   "...insulated (assumed)" or "...partial insulation (assumed)".
   Affects 128+51 = 179 corpus certs.

The same root pattern as S-B25 (cavity-wall description disambiguation),
extended to non-cavity constructions. `_cavity_described_as_filled`
generalised to `_described_as_insulated`; now used by:
- u_wall (cavity-wall dispatcher to the Filled-cavity row, S-B23/B25)
- heat_transmission_from_cert (override wall_ins_present for non-cavity
  walls so the 50 mm bucket routes per Table 6 footnote)

Parity probe at 300 certs, seed=7:
  PE MAE  45.74 → 45.37 (-0.37)
  PE bias 40.19 → 39.75 (-0.44)
  Band D bias +42.7 → +41.6 (-1.1)
  Band F bias +12.6 → +10.7 (-1.9)

Modest aggregate movement — the affected population is small (~0.6% of
corpus, ~2 certs in the 300 sample). The slice's correctness is proved
by 4 unit tests in test_rdsap_uvalues.py + 2 end-to-end tests in
test_heat_transmission.py.

Cumulative across S-B23 → S-B26:
  PE MAE  57.28 → 45.37 (-11.91)
  PE bias 51.56 → 39.75 (-11.81)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-18 21:19:33 +00:00
parent 6b934710d0
commit 361f91546b
4 changed files with 147 additions and 17 deletions

View file

@ -45,19 +45,20 @@ def _measured_u_from_description(description: Optional[str]) -> Optional[float]:
return None
def _cavity_described_as_filled(description: Optional[str]) -> bool:
"""RdSAP encodes "as-built / assumed" insulation as
`wall_insulation_type = 4`, which our cascade would otherwise treat
as uninsulated. The description disambiguates: "Cavity wall, as
built, insulated (assumed)" or "...partial insulation (assumed)"
means the assessor has determined the cavity is filled (or partly
filled), without lodging the thickness. Both route to the Filled-
cavity row of Table 6, matching the legacy production recommendation
engine's interpretation in `recommendations/rdsap_tables.py`.
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.
"no insulation" markers take precedence to avoid the substring
"insulated" matching "no insulation" (it doesn't here, but the
explicit negative check makes the contract clear).
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
@ -145,11 +146,18 @@ def _age_index(age_band: Optional[str]) -> int:
def _insulation_bucket(thickness_mm: Optional[int], insulation_present: bool) -> int:
"""Pick the nearest tabulated insulation column (0/50/100/150/200 mm).
Spec §6.3: when wall is known insulated but thickness unknown, use the
50 mm row.
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 50 if insulation_present else 0
return 0
if thickness_mm < 25:
return 0
if thickness_mm < 75:
@ -347,7 +355,7 @@ def u_wall(
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 == WALL_INSULATION_FILLED_CAVITY
or _cavity_described_as_filled(description)
or _described_as_insulated(description)
):
return _CAVITY_FILLED_ENG[age_idx]
bucket = _insulation_bucket(insulation_thickness_mm, insulation_present)

View file

@ -80,6 +80,29 @@ def test_u_wall_description_with_malformed_transmittance_falls_through_to_cascad
assert result == pytest.approx(0.60, abs=0.001)
def test_u_wall_solid_brick_with_ni_thickness_uses_50mm_row_per_table6_footnote() -> None:
# Arrange — 685 corpus certs lodge solid-brick walls with
# wall_insulation_type ∈ {1 external, 3 internal} and
# wall_insulation_thickness="NI" (Not Indicated). 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." Our `_parse_thickness_mm("NI")` returns 0, which
# combined with `insulation_present=True` must now route to the 50 mm
# bucket (U=0.55 at A-E), not the as-built bucket (U=1.7).
# Act
result = u_wall(
country=Country.ENG,
age_band="B",
construction=WALL_SOLID_BRICK,
insulation_thickness_mm=0,
insulation_present=True,
)
# Assert — Stone/solid brick with 50 mm row at band B = 0.55.
assert result == pytest.approx(0.55, abs=0.001)
def test_u_wall_cavity_as_built_insulated_assumed_routes_to_filled_cavity_row() -> None:
# Arrange — 1 171 corpus certs (~4% of scanned bulk) lodge
# wall_insulation_type=4 ("as-built / assumed") together with the

View file

@ -29,6 +29,7 @@ from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapBuildingP
from domain.ml.rdsap_uvalues import (
Country,
WALL_UNKNOWN,
_described_as_insulated,
thermal_bridging_y,
u_door,
u_floor,
@ -197,7 +198,15 @@ def heat_transmission_from_cert(
wall_construction = _int_or_none(part.wall_construction)
wall_ins_type = _int_or_none(part.wall_insulation_type)
wall_ins_thickness = _parse_thickness_mm(part.wall_insulation_thickness)
wall_ins_present = wall_ins_type is not None and wall_ins_type != _WALL_INSULATION_NONE
# Per RdSAP 10 Table 6 footnote, a wall with "insulated (assumed)"
# or "partial insulation (assumed)" in its description has retrofit
# insulation the assessor hasn't measured the thickness of — even
# when wall_insulation_type=4 ("as-built / assumed"). Treat as
# present so the 50 mm bucket routes correctly.
wall_ins_present = (
(wall_ins_type is not None and wall_ins_type != _WALL_INSULATION_NONE)
or _described_as_insulated(wall_description)
)
party_construction = _int_or_none(part.party_wall_construction)
roof_thickness = _parse_thickness_mm(getattr(part, "roof_insulation_thickness", None))
floor_ins_thickness = _parse_thickness_mm(getattr(part, "floor_insulation_thickness", None))

View file

@ -31,6 +31,96 @@ from domain.sap.worksheet.heat_transmission import (
)
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
# "Solid brick, as built, insulated (assumed)". The description
# signals retrofit insulation that the assessor hasn't measured the
# thickness of; RdSAP 10 Table 6 footnote routes this to the 50 mm
# row. Without the description signal, type=4 alone would set
# wall_ins_present=False and the cascade would return the as-built
# U=1.7. With it, U = 0.55 at band B.
# Geometry: 100 m² floor, 40 m perimeter, 2.5 m height, single
# storey → gross_wall = 100 m². walls_w_per_k expected = 0.55 × 100
# = 55 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.walls = [
EnergyElement(
description="Solid brick, as built, insulated (assumed)",
energy_efficiency_rating=3,
environmental_efficiency_rating=3,
),
]
# Act
result = heat_transmission_from_cert(epc)
# Assert
assert result.walls_w_per_k == pytest.approx(55.0, abs=1.0)
def test_solid_brick_as_built_no_insulation_assumed_stays_at_table6_as_built_row() -> None:
# Arrange — the dominant solid-brick population (4 911 corpus certs)
# lodges "Solid brick, as built, no insulation (assumed)" with
# wall_insulation_type=4. The description-based ins_present override
# must NOT false-positive on the "insulation" substring inside
# "no insulation". This regression test asserts the override
# respects the "no insulation" negation marker so this population
# continues to route to the Solid-brick-as-built row of Table 6
# (U=1.7 at band B).
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.walls = [
EnergyElement(
description="Solid brick, as built, no insulation (assumed)",
energy_efficiency_rating=2,
environmental_efficiency_rating=2,
),
]
# Act
result = heat_transmission_from_cert(epc)
# Assert — U=1.7 × 100 m² gross wall = 170 W/K.
assert result.walls_w_per_k == pytest.approx(170.0, abs=1.0)
def test_cavity_as_built_insulated_assumed_uses_filled_cavity_row() -> None:
# Arrange — the modal RdSAP encoding for a retrofitted-cavity dwelling:
# wall_construction=4 (cavity), wall_insulation_type=4 (as-built /