fix(wall-U): as-built "insulated (assumed)" cavity uses Cavity-as-built row, not Filled cavity

An as-built cavity wall (wall_insulation_type=4) lodged "Cavity wall, as
built, insulated (assumed)" was routed to RdSAP 10 Table 6's "Filled
cavity" row. Per Table 6 (England, PDF p.41) the Filled-cavity row carries
the "†" footnote ("assumed as built") only at age bands I-M, where it is
numerically identical to "Cavity as built"; at bands A-H the Filled-cavity
row represents a GENUINE fill, not the as-built assumption. So an as-built
cavity must use the "Cavity as built" row at all bands (band G/H = 0.60,
not the filled 0.35).

This is the same latent A-H bug slice S0380.210 fixed for the "partial
insulation (assumed)" variant but left in place for "insulated (assumed)"
by a legacy production convention. The API SAP-accuracy cohort over-rated
"Cavity wall, as built, insulated (assumed)" certs at bands G/H by a clean
+1.38 / +1.61 SAP median (n=37 / n=18); bands I-M were unaffected (rows
coincide), confirming the spec mechanism per-band.

Retires the `_cavity_described_as_filled` description sniffer — as-built
cavities now always use the as-built row regardless of the rendered
insulation adjective; a genuine retrofit fill is still caught by the
explicit wall_insulation_type=2 branch.

API SAP eval: 48.6% -> 52.1% within 0.5; <1.0 63.8% -> 67.2%; median |err|
0.548 -> 0.475; mean|err| 1.561 -> 1.497; 909 computed, 0 raises.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-08 18:51:17 +00:00
parent 152682d802
commit 2e466ed1e6
3 changed files with 77 additions and 68 deletions

View file

@ -66,39 +66,26 @@ def _described_as_insulated(description: Optional[str]) -> bool:
return "insulated" in desc or "partial insulation" in desc
def _cavity_described_as_filled(description: Optional[str]) -> bool:
"""True when an as-built cavity wall's description asserts the cavity is
insulated/filled, routing it to the Table 6 "Filled cavity" row.
Distinguishes the three as-built cavity states the EPC renders by age
band when wall_insulation_type=4 ("as-built / assumed"):
- "...insulated (assumed)" Filled cavity (assessor judges
the cavity filled but lodges no
thickness)
- "...partial insulation (assumed)" "Cavity as built" row (the
as-built partial fill of the age
band, NOT a retrofit cavity fill)
- "...no insulation (assumed)" "Cavity as built" row
Narrower than `_described_as_insulated`: it excludes the "partial
insulation" substring so a "partial insulation (assumed)" cavity stays on
the as-built row. RdSAP 10 Table 6 (England) "Cavity as built" band F =
1.0 vs "Filled cavity" band F = 0.40 for an as-built band-F cavity the
filled row understates heat loss by 2.5x. A genuine retrofit fill is
lodged distinctly as "Cavity wall, filled cavity"
(wall_insulation_type=2), handled by the explicit-code branch.
Real-cert evidence: golden cert 0390-2954-3640 (detached, band F, cavity
type 4, "partial insulation (assumed)") closes all four SAP metrics on
the as-built 1.0 row; the filled 0.40 row under-counts PE by ~28 kWh/.
"""
if description is None:
return False
desc = description.lower()
if "no insulation" in desc:
return False
return "insulated" in desc
# An AS-BUILT cavity wall (wall_insulation_type=4 / "as-built / assumed",
# however the EPC renders the insulation adjective — "insulated", "partial
# insulation" or "no insulation" "(assumed)") routes to Table 6's "Cavity
# as built" row via the bucketed cascade, NOT the "Filled cavity" row. Per
# RdSAP 10 Table 6 (England) the "Filled cavity" row's † footnote ("assumed
# as built") applies only at age bands I-M, where the two rows are
# numerically identical — so at bands A-H the Filled cavity row represents a
# GENUINE fill, not the as-built assumption. A genuine retrofit fill is
# lodged distinctly as "Cavity wall, filled cavity" (wall_insulation_type=2),
# caught by the explicit-code branch in `u_wall`.
#
# Slice S0380.210 first corrected this for "partial insulation (assumed)"
# (golden 0390-2954-3640, band F → as-built 1.0); the "insulated (assumed)"
# variant was left on the filled row by a legacy production convention. That
# was the SAME latent A-H bug: the API SAP-accuracy cohort over-rated
# "Cavity wall, as built, insulated (assumed)" certs at bands G/H by a clean
# +1.4 / +1.6 SAP median (filled 0.35 vs as-built 0.60), while bands I-M
# were unaffected (rows coincide). The `_cavity_described_as_filled`
# description sniffer is therefore retired — as-built cavities always use the
# as-built row regardless of the rendered insulation adjective.
# ---------------------------------------------------------------------------
@ -689,7 +676,6 @@ def u_wall(
)
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

@ -114,20 +114,26 @@ def test_u_wall_solid_brick_with_ni_thickness_uses_50mm_row_per_table6_footnote(
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
# 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.
def test_u_wall_cavity_as_built_insulated_assumed_routes_to_as_built_row() -> None:
# Arrange — a cavity lodged "Cavity wall, as built, insulated (assumed)"
# with wall_insulation_type=4 is in its AS-BUILT state, NOT a retrofit
# cavity fill. Per RdSAP 10 Table 6 (England) the "Filled cavity" row's
# † footnote ("assumed as built") applies only at bands I-M, where it
# coincides with "Cavity as built"; at bands A-H the filled row is for a
# GENUINE fill. So an as-built cavity uses the "Cavity as built" row:
# band E = 1.5, NOT the filled 0.7.
#
# Slice S0380.210 corrected this for the "partial insulation (assumed)"
# variant but left "insulated (assumed)" on the filled row by a legacy
# production convention — the SAME latent A-H bug. The API SAP-accuracy
# cohort over-rated band-G/H "insulated (assumed)" cavities by a clean
# +1.4 / +1.6 SAP median (filled 0.35 vs as-built 0.60); bands I-M were
# unaffected (rows coincide). A genuine fill lodges the distinct "Cavity
# wall, filled cavity" (wall_insulation_type=2), caught by the
# explicit-code branch.
# Act
result = u_wall(
result_e = u_wall(
country=Country.ENG,
age_band="E",
construction=WALL_CAVITY,
@ -136,19 +142,30 @@ def test_u_wall_cavity_as_built_insulated_assumed_routes_to_filled_cavity_row()
wall_insulation_type=4,
description="Cavity wall, as built, insulated (assumed)",
)
# Band I: "Cavity as built" and "Filled cavity" rows coincide (0.45),
# so the routing change is a no-op there — the corpus-confirmed pivot.
result_i = u_wall(
country=Country.ENG,
age_band="I",
construction=WALL_CAVITY,
insulation_thickness_mm=None,
insulation_present=False,
wall_insulation_type=4,
description="Cavity wall, as built, insulated (assumed)",
)
# Assert
assert result == pytest.approx(0.7, abs=0.001)
# Assert — band E → as-built 1.5 (not filled 0.7); band I → 0.45 (rows coincide).
assert abs(result_e - 1.5) <= 0.001
assert abs(result_i - 0.45) <= 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)".
# insulation (assumed)" entries which route to the Cavity-as-built row
# of Table 6 (U=1.5 at band E) — as do ALL as-built cavity variants
# ("insulated" / "partial insulation" / "no insulation") now that the
# as-built path no longer special-cases the insulation adjective.
# Act
result = u_wall(
@ -180,8 +197,8 @@ def test_u_wall_cavity_as_built_partial_insulation_routes_to_as_built_row() -> N
# four SAP metrics on the as-built row (band F = 1.0) and under-counts
# PE by ~28 kWh/m² on the filled row — the legacy parity was a latent
# bug at bands A-H (bands I-M coincide per the Table 6 † footnote).
# The "insulated (assumed)" variant still routes to filled (see the
# heat_transmission `_cavity_described_as_filled` sibling test).
# A later slice extended the same fix to the "insulated (assumed)"
# variant (see the as-built-insulated sibling test above).
# Act
result = u_wall(

View file

@ -327,16 +327,22 @@ def test_solid_brick_as_built_no_insulation_assumed_stays_at_table6_as_built_row
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 /
# 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.
def test_cavity_as_built_insulated_assumed_uses_as_built_row() -> None:
# Arrange — wall_construction=4 (cavity), wall_insulation_type=4
# (as-built / assumed), walls[0].description = "Cavity wall, as built,
# insulated (assumed)". This is the AS-BUILT state, not a retrofit fill:
# per RdSAP 10 Table 6 (England) the "Filled cavity" row's † footnote
# ("assumed as built") applies only at bands I-M, where it coincides
# with "Cavity as built"; at bands A-H the filled row is for a genuine
# fill. So band E uses the "Cavity as built" row U=1.5, NOT filled 0.7.
#
# Prior code special-cased the "insulated" adjective to the filled row
# (legacy convention); the API SAP-accuracy cohort over-rated band-G/H
# "insulated (assumed)" cavities by +1.4 / +1.6 SAP median (filled 0.35
# vs as-built 0.60). A genuine fill renders the distinct "Cavity wall,
# filled cavity" (wall_insulation_type=2), caught separately.
# 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.
# → gross_wall = 100 m². walls_w_per_k expected = 1.5 × 100 = 150 W/K.
main = make_building_part(
identifier="Main Dwelling",
construction_age_band="E",
@ -367,8 +373,8 @@ def test_cavity_as_built_insulated_assumed_uses_filled_cavity_row() -> None:
# Act
result = heat_transmission_from_cert(epc)
# Assert
assert result.walls_w_per_k == pytest.approx(70.0, abs=1.0)
# Assert — Cavity-as-built row at band E = 1.5 W/m²K (not filled 0.7).
assert result.walls_w_per_k == pytest.approx(150.0, abs=1.0)
def test_cavity_as_built_partial_insulation_assumed_uses_as_built_row() -> None:
@ -381,8 +387,8 @@ def test_cavity_as_built_partial_insulation_assumed_uses_as_built_row() -> None:
# RdSAP 10 Table 6 (England) "Cavity as built" band F = 1.0 vs
# "Filled cavity" band F = 0.40. A genuine fill renders the distinct
# "Cavity wall, filled cavity" description (wall_insulation_type=2),
# caught separately. Contrast the "insulated (assumed)" variant above,
# which the assessor judges as filled.
# caught separately. The "insulated (assumed)" variant above now routes
# to the same as-built row (all as-built adjectives coincide at A-H).
#
# Real-cert evidence: golden cert 0390-2954-3640 (detached, band F,
# cavity type 4, "partial insulation (assumed)") closes all four SAP