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