slice S-B25: description-based dispatch for as-built / assumed cavity

The RdSAP schema's `wall_insulation_type = 4` ("as-built / assumed")
covers two distinct cert populations that previously both routed to
the Cavity-as-built row (U=1.5 at band E):

  686 certs: "Cavity wall, as built, no insulation (assumed)" — U=1.5 ✓
 1171 certs: "Cavity wall, as built, insulated (assumed)" — should be 0.7
  147 certs: "Cavity wall, as built, partial insulation (assumed)" — 0.7

The description string disambiguates. The legacy production map at
recommendations/rdsap_tables.py:753 routes the latter two to "Filled
cavity" — we match that interpretation here for parity with the cert
assessor and the production recommendation engine.

`_cavity_described_as_filled` adds the description check; the existing
filled-cavity dispatcher in u_wall now fires on either signal:
- wall_insulation_type == 2 (S-B23 — explicit filled-cavity code)
- description contains "insulated" or "partial insulation" without
  the "no insulation" negation marker (S-B25 — assumed cavity-fill)

Parity probe at 300 certs, seed=7:
  PE MAE  46.78 → 45.74 (-1.04)
  PE bias 41.78 → 40.19 (-1.59)
  Band F bias +23.2 → +12.6 (-10.6)
  Band G bias +31.8 → +25.1 (-6.7)
  Band H bias +30.7 → +15.5 (-15.2)

Improvements localise to bands F-H (1976-1995), the era when Building
Regs mandated cavity insulation for new-builds — making "as built,
insulated (assumed)" the modal description. SAP MAE drifted up
+0.12 (cost-side residuals surfacing now that envelope is closer to
spec; tracked for follow-up).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-18 21:06:10 +00:00
parent 15613309df
commit 6b934710d0
3 changed files with 143 additions and 3 deletions

View file

@ -45,6 +45,28 @@ 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`.
"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).
"""
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
# ---------------------------------------------------------------------------
@ -323,9 +345,9 @@ def u_wall(
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 == WALL_INSULATION_FILLED_CAVITY
if wall_type == WALL_CAVITY and (
wall_insulation_type == WALL_INSULATION_FILLED_CAVITY
or _cavity_described_as_filled(description)
):
return _CAVITY_FILLED_ENG[age_idx]
bucket = _insulation_bucket(insulation_thickness_mm, insulation_present)

View file

@ -80,6 +80,80 @@ 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_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
# description "Cavity wall, as built, insulated (assumed)". The
# assessor is saying: this cavity is filled, but I haven't measured
# the thickness. Spec footnote on Table 6 covers this: "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" — but
# legacy convention (used by the production recommendation engine)
# is to route this to the Filled-cavity row, U = 0.7 at A-E. We
# follow the legacy convention here for parity with the cert assessor.
# Act
result = u_wall(
country=Country.ENG,
age_band="E",
construction=WALL_CAVITY,
insulation_thickness_mm=None,
insulation_present=False, # type=4 maps to wall_ins_present=False
wall_insulation_type=4,
description="Cavity wall, as built, insulated (assumed)",
)
# Assert
assert result == pytest.approx(0.7, abs=0.001)
def test_u_wall_cavity_as_built_no_insulation_stays_at_table6_cavity_as_built_row() -> None:
# Arrange — the same wall_insulation_type=4 ("as-built / assumed")
# cert population also contains 686 "Cavity wall, as built, no
# insulation (assumed)" entries which must continue to route to the
# Cavity-as-built row of Table 6 (U=1.5 at band E). The "no
# insulation" substring marker takes precedence over the
# "insulated"-substring filled-cavity rule, so this case is
# disambiguated from "Cavity wall, as built, insulated (assumed)".
# Act
result = u_wall(
country=Country.ENG,
age_band="E",
construction=WALL_CAVITY,
insulation_thickness_mm=None,
insulation_present=False,
wall_insulation_type=4,
description="Cavity wall, as built, no insulation (assumed)",
)
# Assert
assert result == pytest.approx(1.5, abs=0.001)
def test_u_wall_cavity_as_built_partial_insulation_routes_to_filled_cavity_row() -> None:
# Arrange — 147 corpus certs lodge "Cavity wall, as built, partial
# insulation (assumed)" with wall_insulation_type=4. The legacy
# production map (recommendations/rdsap_tables.py:753) routes these
# to "Filled cavity" — same destination as the "insulated (assumed)"
# case. We match that interpretation for parity with the cert
# assessor and the production recommendation engine.
# Act
result = u_wall(
country=Country.ENG,
age_band="D", # 1950-1966 — typical partial-fill retrofit cohort
construction=WALL_CAVITY,
insulation_thickness_mm=None,
insulation_present=False,
wall_insulation_type=4,
description="Cavity wall, as built, partial insulation (assumed)",
)
# Assert — Filled-cavity row at band D = 0.7 W/m²K.
assert result == pytest.approx(0.7, abs=0.001)
def test_u_wall_description_without_transmittance_phrase_routes_through_cascade() -> None:
# Arrange — the measured-U dispatcher must only fire when the
# description contains the "thermal transmittance" phrase. The

View file

@ -31,6 +31,50 @@ from domain.sap.worksheet.heat_transmission import (
)
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 /
# assumed), and walls[0].description = "Cavity wall, as built,
# insulated (assumed)". The assessor has determined the cavity is
# filled but hasn't lodged a thickness. Without the description-based
# dispatcher, the cascade would return U=1.5; with it, the Filled-
# cavity row of Table 6 applies: U=0.7 at band E.
# Geometry: 100 m² floor, 40 m perimeter, 2.5 m height, single storey
# → gross_wall = 100 m². walls_w_per_k expected = 0.7 × 100 = 70 W/K.
main = make_building_part(
identifier="Main Dwelling",
construction_age_band="E",
wall_construction=4,
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="Cavity wall, as built, insulated (assumed)",
energy_efficiency_rating=4,
environmental_efficiency_rating=4,
),
]
# Act
result = heat_transmission_from_cert(epc)
# Assert
assert result.walls_w_per_k == pytest.approx(70.0, abs=1.0)
def test_walls_description_measured_transmittance_overrides_construction_cascade() -> None:
# Arrange — a full-SAP (not RdSAP) cert lodges the wall U-value
# directly in walls[i].description ("Average thermal transmittance