mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
15613309df
commit
6b934710d0
3 changed files with 143 additions and 3 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue