From 6d2982f826d694ae32f0090837f71ffc5a5c55dd Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 22 Jul 2026 15:51:31 +0000 Subject: [PATCH] =?UTF-8?q?Persist=20cylinder=5Fheat=5Floss=20so=20it=20su?= =?UTF-8?q?rvives=20the=20DB=20round-trip=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SapHeating.cylinder_heat_loss is read by the calculator (cert_to_inputs §4 cylinder loss) but had no column, so it was silently dropped on save: a dwelling reloaded from Postgres lost its lodged cylinder loss and fell back to the Table 2b age-band default — worst case -15.53 SAP over the corpus round-trip (#1665). Add heating_cylinder_heat_loss (nullable double precision) to EpcPropertyModel, write it in from_domain and reconstruct it in the SapHeating compose path, mirroring heating_cylinder_volume_measured_l. Remove the now-false _UNPERSISTED_ALLOWLIST entry ("dormant — not read by the calculator") so the ADR structural guard enforces it. Verified: worst cylinder cert 10091630692 round-trip -15.53 -> 0.0000. Companion assessment-model migration (heating_cylinder_heat_loss column) must land + be applied per-environment before the backend deploys (deploy gate). Co-Authored-By: Claude Opus 4.8 (1M context) --- infrastructure/postgres/epc_property_table.py | 4 ++++ repositories/epc/epc_postgres_repository.py | 1 + tests/repositories/epc/test_epc_persistence_field_coverage.py | 1 - 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/infrastructure/postgres/epc_property_table.py b/infrastructure/postgres/epc_property_table.py index d2c2de654..512621d6c 100644 --- a/infrastructure/postgres/epc_property_table.py +++ b/infrastructure/postgres/epc_property_table.py @@ -168,6 +168,9 @@ class EpcPropertyModel(SQLModel, table=True): ) heating_cylinder_insulation_thickness_mm: Optional[int] = Field(default=None) heating_cylinder_volume_measured_l: Optional[int] = Field(default=None) + # #1665: read by cert_to_inputs (§4 cylinder loss); dropping it billed the + # cylinder at the Table 2b age-band default (worst case -15.53 SAP). + heating_cylinder_heat_loss: Optional[float] = Field(default=None) heating_wwhrs_index_number_1: Optional[int] = Field(default=None) heating_wwhrs_index_number_2: Optional[int] = Field(default=None) heating_shower_outlet_type: Optional[Union[int, str]] = Field( @@ -359,6 +362,7 @@ class EpcPropertyModel(SQLModel, table=True): heating_secondary_heating_type=h.secondary_heating_type, heating_cylinder_insulation_thickness_mm=h.cylinder_insulation_thickness_mm, heating_cylinder_volume_measured_l=h.cylinder_volume_measured_l, + heating_cylinder_heat_loss=h.cylinder_heat_loss, heating_wwhrs_index_number_1=h.instantaneous_wwhrs.wwhrs_index_number1, heating_wwhrs_index_number_2=h.instantaneous_wwhrs.wwhrs_index_number2, heating_shower_outlet_type=shower.shower_outlet_type if shower else None, diff --git a/repositories/epc/epc_postgres_repository.py b/repositories/epc/epc_postgres_repository.py index caa038e4e..81620b069 100644 --- a/repositories/epc/epc_postgres_repository.py +++ b/repositories/epc/epc_postgres_repository.py @@ -909,6 +909,7 @@ class EpcPostgresRepository(EpcRepository): secondary_heating_type=p.heating_secondary_heating_type, cylinder_insulation_thickness_mm=p.heating_cylinder_insulation_thickness_mm, cylinder_volume_measured_l=p.heating_cylinder_volume_measured_l, + cylinder_heat_loss=p.heating_cylinder_heat_loss, number_baths=p.heating_number_baths, number_baths_wwhrs=p.heating_number_baths_wwhrs, electric_shower_count=p.heating_electric_shower_count, diff --git a/tests/repositories/epc/test_epc_persistence_field_coverage.py b/tests/repositories/epc/test_epc_persistence_field_coverage.py index a3f12bb12..af416983a 100644 --- a/tests/repositories/epc/test_epc_persistence_field_coverage.py +++ b/tests/repositories/epc/test_epc_persistence_field_coverage.py @@ -60,7 +60,6 @@ _UNPERSISTED_ALLOWLIST: dict[str, str] = { "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", - "SapHeating.cylinder_heat_loss": "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