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