Persist alternative-wall u_value / thickness / is_basement 🟩

SapAlternativeWall.u_value (heat_transmission.py:1616), .wall_thickness_mm
(:1645) and .is_basement (:1618) were read by the calculator but had no column,
so they dropped on save. Latent today (0 gov-API certs lodge them — live only on
the Elmhurst/site-notes path), but is_basement is the nastiest shape: it does not
null out, it FLIPS meaning (True -> None -> is_basement_wall reads False),
silently switching off the RdSAP §5.17 / Table 23 basement-wall U path.

Add alt_wall_{1,2}_{u_value,thickness_mm,is_basement} to EpcBuildingPartModel,
write in from_domain, reconstruct in _to_alt_wall, and drop the three
_UNPERSISTED_ALLOWLIST entries so the structural guard enforces them. is_basement
is nullable (None "not stated" != False "not a basement"; a NOT NULL DEFAULT
false re-enables the code-6 heuristic — same reasoning as #1661's wall_is_basement).

Companion assessment-model migration: six nullable columns on epc_building_part
(deploy gate). Round-trip + field-coverage guard green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-23 08:31:43 +00:00
parent 9d2ac59ed2
commit 0890b59b56
3 changed files with 25 additions and 4 deletions

View file

@ -686,6 +686,12 @@ class EpcBuildingPartModel(SQLModel, table=True):
alt_wall_1_thickness_measured: Optional[str] = Field(default=None)
alt_wall_1_insulation_thickness: Optional[str] = Field(default=None)
alt_wall_1_is_sheltered: Optional[bool] = Field(default=None)
# #1665: calculator-read alt-wall fields (heat_transmission.py) with no column.
alt_wall_1_u_value: Optional[float] = Field(default=None)
alt_wall_1_thickness_mm: Optional[int] = Field(default=None)
# Nullable is load-bearing: None ("not stated") != False ("not a basement").
# A NOT NULL DEFAULT false re-enables the code-6 basement heuristic (cf #1661).
alt_wall_1_is_basement: Optional[bool] = Field(default=None)
alt_wall_2_area: Optional[float] = Field(default=None)
alt_wall_2_dry_lined: Optional[str] = Field(default=None)
alt_wall_2_construction: Optional[int] = Field(default=None)
@ -693,6 +699,9 @@ class EpcBuildingPartModel(SQLModel, table=True):
alt_wall_2_thickness_measured: Optional[str] = Field(default=None)
alt_wall_2_insulation_thickness: Optional[str] = Field(default=None)
alt_wall_2_is_sheltered: Optional[bool] = Field(default=None)
alt_wall_2_u_value: Optional[float] = Field(default=None)
alt_wall_2_thickness_mm: Optional[int] = Field(default=None)
alt_wall_2_is_basement: Optional[bool] = Field(default=None)
@classmethod
def from_domain(
@ -741,6 +750,9 @@ class EpcBuildingPartModel(SQLModel, table=True):
aw1.wall_insulation_thickness if aw1 else None
),
alt_wall_1_is_sheltered=aw1.is_sheltered if aw1 else None,
alt_wall_1_u_value=aw1.u_value if aw1 else None,
alt_wall_1_thickness_mm=aw1.wall_thickness_mm if aw1 else None,
alt_wall_1_is_basement=aw1.is_basement if aw1 else None,
alt_wall_2_area=aw2.wall_area if aw2 else None,
alt_wall_2_dry_lined=aw2.wall_dry_lined if aw2 else None,
alt_wall_2_construction=aw2.wall_construction if aw2 else None,
@ -750,6 +762,9 @@ class EpcBuildingPartModel(SQLModel, table=True):
aw2.wall_insulation_thickness if aw2 else None
),
alt_wall_2_is_sheltered=aw2.is_sheltered if aw2 else None,
alt_wall_2_u_value=aw2.u_value if aw2 else None,
alt_wall_2_thickness_mm=aw2.wall_thickness_mm if aw2 else None,
alt_wall_2_is_basement=aw2.is_basement if aw2 else None,
)

View file

@ -1044,6 +1044,13 @@ class EpcPostgresRepository(EpcRepository):
else bp.alt_wall_2_insulation_thickness
)
sheltered = bp.alt_wall_1_is_sheltered if n == 1 else bp.alt_wall_2_is_sheltered
u_value = bp.alt_wall_1_u_value if n == 1 else bp.alt_wall_2_u_value
thickness_mm = (
bp.alt_wall_1_thickness_mm if n == 1 else bp.alt_wall_2_thickness_mm
)
is_basement = (
bp.alt_wall_1_is_basement if n == 1 else bp.alt_wall_2_is_basement
)
return SapAlternativeWall(
wall_area=area,
wall_dry_lined=_require(dry_lined, f"alt_wall_{n}_dry_lined"),
@ -1058,6 +1065,9 @@ class EpcPostgresRepository(EpcRepository):
# Nullable column (added later than the alt wall itself); a row from
# before the column existed reads None → the domain default False.
is_sheltered=sheltered if sheltered is not None else False,
u_value=u_value,
wall_thickness_mm=thickness_mm,
is_basement=is_basement,
)
@private

View file

@ -45,8 +45,6 @@ _UNPERSISTED_ALLOWLIST: dict[str, str] = {
"EpcPropertyData.solar_hw_collector_orientation": "FE column pending — tracked round-trip gap",
"EpcPropertyData.solar_hw_collector_pitch_deg": "FE column pending — tracked round-trip gap",
"EpcPropertyData.solar_hw_overshading": "FE column pending — tracked round-trip gap",
"SapAlternativeWall.u_value": "FE column pending — tracked round-trip gap",
"SapAlternativeWall.wall_thickness_mm": "FE column pending — tracked round-trip gap",
# Room-in-roof detail: only floor_area + age_band reconstruct from the
# inlined columns; the geometry / surface detail awaits an FE child table
# (tracked round-trip gap, docs/migrations §2).
@ -58,8 +56,6 @@ _UNPERSISTED_ALLOWLIST: dict[str, str] = {
"SapRoomInRoof.gable_2_height_m": "FE child table pending — room-in-roof detail",
"SapRoomInRoof.gable_2_length_m": "FE child table pending — room-in-roof detail",
"SapRoomInRoofSurface": "FE child table pending — room-in-roof surface detail",
# --- Nested gaps the calculator does NOT read (dormant); no FE column.
"SapAlternativeWall.is_basement": "dormant — not read by the calculator; no FE column",
# --- DELIBERATELY not persisted (#1656). These four ARE read by the
# calculator (`heat_transmission.py:994` wall U, `:1068` roof U, `:1118`
# floor U, `:955` basement wall) — do NOT re-label them "not read by the