mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
152682d802
commit
2e466ed1e6
3 changed files with 77 additions and 68 deletions
|
|
@ -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/m².
|
||||
"""
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue