slice 18c: description-aware u_wall material fallback (v2.5.0)

When wall_construction integer is missing or WALL_UNKNOWN, u_wall now
parses the top-level walls[i].description for material keywords
(sandstone/limestone/granite/whinstone/cob/system built/timber frame/
solid brick/cavity) before falling through to the cavity-by-age default.
Explicit construction codes still win. Threaded through
envelope_heat_loss_w_per_k via a joined wall description string off the
top-level walls list.
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-17 17:55:09 +00:00
parent 60eea0f52b
commit d11d4df3df
6 changed files with 168 additions and 5 deletions

View file

@ -104,6 +104,7 @@ def _part_heat_loss_w_per_k(
window_u_value: float,
door_u_value: float,
roof_description: Optional[str] = None,
wall_description: Optional[str] = None,
) -> float:
"""Heat loss coefficient (W/K) for a single building part: walls + roof +
floor + party walls + windows + doors + thermal bridging.
@ -141,6 +142,7 @@ def _part_heat_loss_w_per_k(
construction=wall_construction if wall_construction != WALL_UNKNOWN else None,
insulation_thickness_mm=wall_ins_thickness,
insulation_present=wall_ins_present,
description=wall_description,
)
ur = u_roof(
country=country,
@ -195,6 +197,7 @@ def envelope_heat_loss_w_per_k(
insulated_door_u_value: Optional[float],
age_band_for_door: Optional[str] = None,
roof_description: Optional[str] = None,
wall_description: Optional[str] = None,
) -> float:
"""Total envelope heat-loss coefficient (W/K) summed over all building parts.
@ -245,5 +248,6 @@ def envelope_heat_loss_w_per_k(
window_u_value=window_u,
door_u_value=door_u,
roof_description=roof_description,
wall_description=wall_description,
)
return total

View file

@ -201,6 +201,32 @@ _DEFAULT_WALL_BY_AGE: Final[dict[str, int]] = {
}
# Surveyor-text -> wall-construction code, evaluated in priority order so that
# "sandstone" beats "stone", "solid brick" beats "brick", etc. Used only as a
# fallback when the cert's `wall_construction` integer is missing or unknown.
_WALL_DESCRIPTION_MARKERS: Final[tuple[tuple[str, int], ...]] = (
("sandstone", WALL_STONE_SANDSTONE),
("limestone", WALL_STONE_SANDSTONE),
("granite", WALL_STONE_GRANITE),
("whinstone", WALL_STONE_GRANITE),
("cob", WALL_COB),
("system built", WALL_SYSTEM_BUILT),
("timber frame", WALL_TIMBER_FRAME),
("solid brick", WALL_SOLID_BRICK),
("cavity", WALL_CAVITY),
)
def _wall_type_from_description(description: Optional[str]) -> Optional[int]:
if description is None:
return None
desc = description.lower()
for marker, code in _WALL_DESCRIPTION_MARKERS:
if marker in desc:
return code
return None
def u_wall(
country: Optional[Country],
age_band: Optional[str],
@ -208,17 +234,29 @@ def u_wall(
insulation_thickness_mm: Optional[int],
*,
insulation_present: bool = False,
description: Optional[str] = None,
) -> float:
"""RdSAP10 wall U-value in W/m^2K, never null."""
"""RdSAP10 wall U-value in W/m^2K, never null.
When the cert's `construction` integer is missing or WALL_UNKNOWN, an
optional surveyor `description` (top-level `walls[i].description`) is
parsed for material keywords ("sandstone", "granite", "solid brick", ...)
so the cascade picks the right table instead of falling through to the
cavity-by-age default. Explicit construction codes always win.
"""
if country is None and age_band is None and construction is None and insulation_thickness_mm is None and not insulation_present:
return 1.5
ctry = country if country is not None else Country.ENG
age_idx = _age_index(age_band)
band = _AGE_BANDS[age_idx]
wall_type = construction if construction in {
known_types = {
WALL_STONE_GRANITE, WALL_STONE_SANDSTONE, WALL_SOLID_BRICK, WALL_CAVITY,
WALL_TIMBER_FRAME, WALL_SYSTEM_BUILT, WALL_COB,
} else _DEFAULT_WALL_BY_AGE.get(band, WALL_CAVITY)
}
if construction in known_types:
wall_type = construction
else:
wall_type = _wall_type_from_description(description) or _DEFAULT_WALL_BY_AGE.get(band, WALL_CAVITY)
bucket = _insulation_bucket(insulation_thickness_mm, insulation_present)
# Country override first.

View file

@ -285,6 +285,41 @@ def test_envelope_limited_roof_insulation_description_raises_heat_loss() -> None
assert limited_roof - default_roof == pytest.approx(110.0, abs=15.0)
def test_envelope_stone_wall_description_raises_heat_loss_vs_cavity_default() -> None:
# Arrange — construction integer missing on a Victorian (age D) cert.
# _DEFAULT_WALL_BY_AGE picks CAVITY (1.5 W/m^2K uninsulated for D) by
# default; a walls[i].description naming "Sandstone" should resolve to
# stone instead (1.7 W/m^2K). Delta on net wall area ~100 m^2 -> +20 W/K.
main = make_building_part(
identifier="Main Dwelling",
construction_age_band="D",
wall_construction=10, # WALL_UNKNOWN -> envelope passes None to u_wall
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=5.0, heat_loss_perimeter_m=40.0, floor=0,
)
],
)
# Act
default_wall = envelope_heat_loss_w_per_k(
sap_building_parts=[main], country_code="ENG", window_total_area_m2=0.0,
window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None,
)
stone_wall = envelope_heat_loss_w_per_k(
sap_building_parts=[main], country_code="ENG", window_total_area_m2=0.0,
window_avg_u_value=None, door_count=0, insulated_door_count=0, insulated_door_u_value=None,
wall_description="Sandstone or limestone, as built, no insulation (assumed)",
)
# Assert — heat loss rises by ~20 W/K (0.2 W/m^2K * 100 m^2 net wall area).
assert stone_wall - default_wall == pytest.approx(20.0, abs=5.0)
def test_envelope_never_null_even_with_missing_fields() -> None:
# Arrange — minimal building part with most fields unspecified.
main = make_building_part(

View file

@ -128,6 +128,91 @@ def test_u_wall_falls_back_to_mid_range_default_when_everything_unknown() -> Non
assert result == pytest.approx(1.5, abs=0.001)
def test_u_wall_description_sandstone_overrides_cavity_default_for_age_e() -> None:
# Arrange — construction integer is missing on the cert. _DEFAULT_WALL_BY_AGE
# would pick cavity for age E (1.0 W/m^2K uninsulated), but the surveyor's
# walls[i].description clearly identifies sandstone -> 1.7 W/m^2K.
# Act
result = u_wall(
country=Country.ENG,
age_band="E",
construction=None,
insulation_thickness_mm=None,
description="Sandstone or limestone, as built, no insulation (assumed)",
)
# Assert
assert result == pytest.approx(1.7, abs=0.001)
def test_u_wall_description_granite_or_whinstone_picks_stone_default() -> None:
# Arrange — Scotland whinstone (granite-family) walls, age D, construction
# null. Should resolve to STONE_GRANITE uninsulated -> 1.7 (age D index 3).
# Act
result = u_wall(
country=Country.ENG,
age_band="D",
construction=None,
insulation_thickness_mm=None,
description="Granite or whinstone, as built",
)
# Assert
assert result == pytest.approx(1.7, abs=0.001)
def test_u_wall_description_solid_brick_picks_solid_brick_default() -> None:
# Arrange — construction null, description names solid brick. For age E
# uninsulated solid brick -> 1.7 W/m^2K (vs cavity 1.0).
# Act
result = u_wall(
country=Country.ENG,
age_band="E",
construction=None,
insulation_thickness_mm=None,
description="Solid brick, as built, no insulation (assumed)",
)
# Assert
assert result == pytest.approx(1.7, abs=0.001)
def test_u_wall_explicit_construction_beats_description() -> None:
# Arrange — wall_construction integer is cavity (4); ignore any conflicting
# description text. Cavity-as-built age E -> 1.5 W/m^2K, NOT stone's 1.7.
# Act
result = u_wall(
country=Country.ENG,
age_band="E",
construction=WALL_CAVITY,
insulation_thickness_mm=None,
description="Granite, as built", # ignored
)
# Assert
assert result == pytest.approx(1.5, abs=0.001)
def test_u_wall_description_unmatched_falls_back_to_age_band_default() -> None:
# Arrange — construction null, description says nothing recognisable.
# Act
result = u_wall(
country=Country.ENG,
age_band="E",
construction=None,
insulation_thickness_mm=None,
description="something unparseable",
)
# Assert — cavity default for age E uninsulated -> 1.5 W/m^2K.
assert result == pytest.approx(1.5, abs=0.001)
def test_u_wall_uses_rdsap_unknown_thickness_default_of_50mm_when_insulated_but_unknown() -> None:
# Arrange — RdSAP10 footnote: if wall is known insulated but thickness unknown, use 50mm row.
# System built with 50mm insulation, England, age band G -> 0.35 W/m^2K.

View file

@ -36,7 +36,7 @@ def test_transform_advertises_version_and_target_columns() -> None:
# Assert
assert isinstance(schema, TransformSchema)
assert schema.transform_version == "2.4.0"
assert schema.transform_version == "2.5.0"
assert schema.transform_version == EpcMlTransform.VERSION
assert set(schema.target_columns.keys()) == set(_EXPECTED_TARGET_DTYPES.keys())
for target_name, expected_dtype in _EXPECTED_TARGET_DTYPES.items():

View file

@ -902,7 +902,7 @@ class EpcMlTransform:
Version 0.1.0 schema contract only; feature columns added in subsequent slices.
"""
VERSION: str = "2.4.0"
VERSION: str = "2.5.0"
def schema(self) -> TransformSchema:
"""The cross-repo ML data contract.
@ -958,6 +958,7 @@ class EpcMlTransform:
insulated_door_count=epc.insulated_door_count,
insulated_door_u_value=epc.insulated_door_u_value,
roof_description=_joined_descriptions(epc.roofs),
wall_description=_joined_descriptions(epc.walls),
)
main_heating_code = heating_aggregates.get("primary_sap_main_heating_code")
water_code = heating_aggregates.get("water_heating_code")