From 0890b59b56c84608d3f994e843993ffe5cd265bc Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 23 Jul 2026 08:31:43 +0000 Subject: [PATCH] =?UTF-8?q?Persist=20alternative-wall=20u=5Fvalue=20/=20th?= =?UTF-8?q?ickness=20/=20is=5Fbasement=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- infrastructure/postgres/epc_property_table.py | 15 +++++++++++++++ repositories/epc/epc_postgres_repository.py | 10 ++++++++++ .../epc/test_epc_persistence_field_coverage.py | 4 ---- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/infrastructure/postgres/epc_property_table.py b/infrastructure/postgres/epc_property_table.py index f77128799..c5f20269f 100644 --- a/infrastructure/postgres/epc_property_table.py +++ b/infrastructure/postgres/epc_property_table.py @@ -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, ) diff --git a/repositories/epc/epc_postgres_repository.py b/repositories/epc/epc_postgres_repository.py index 81620b069..c633cc542 100644 --- a/repositories/epc/epc_postgres_repository.py +++ b/repositories/epc/epc_postgres_repository.py @@ -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 diff --git a/tests/repositories/epc/test_epc_persistence_field_coverage.py b/tests/repositories/epc/test_epc_persistence_field_coverage.py index af416983a..631d69707 100644 --- a/tests/repositories/epc/test_epc_persistence_field_coverage.py +++ b/tests/repositories/epc/test_epc_persistence_field_coverage.py @@ -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