feat(db): persist rafter_insulation_thickness and wall_is_basement

The Model repo reads both fields from the gov-EPC API but neither had a
column here, so both were mapped and then silently discarded on save --
inert for any dwelling read back from Postgres.

rafter_insulation_thickness: property 749719 lodges "250mm", which bills
the roof on RdSAP 10 Table 16 col (2) at U 0.23. Dropped on the DB
round-trip it falls to the Table 18 col (2) unknown-thickness default of
2.30 -- the spec's *uninsulated* value, a 10x error:

  quantity                  | lodged      | dropped | with field
  SAP                       | 73 (band C) |   63.22 |      72.65
  primary energy kWh/m2/yr  | 212         |   327.9 |      212.3
  CO2 t/yr                  | 1.5         |    2.40 |       1.54

It is jsonb, not text, to match the sibling thickness columns: the value
is Union[str, int] -- "250mm" from the gov API, int mm from Site Notes --
and the Python round-trip test asserts int-vs-str survives exactly.

wall_is_basement: not a lodged U-value -- it selects WHICH RdSAP default
applies (RdSAP 10 5.17 / Table 23). Nullable is load-bearing, so it is
deliberately NOT notNull().default(false). Three states are distinct:
null "not stated" falls back to the gov-API code-6 heuristic, false
"explicitly system-built" suppresses it, true is a basement wall. Code 6
is canonically system-built and the Elmhurst site-notes mapper sets false
precisely to defeat the heuristic, so collapsing false to null INVERTS
the determination -- a system-built wall bills as a basement wall and,
via has_basement, drags the whole ground floor onto the Table 23
basement-floor U-value. Rare (54 of 67k building parts in the 2026
sweep) but silent and directional.

Deliberately NOT persisted: wall_u_value, roof_u_value, floor_u_value.
Those are full-SAP artefacts. We re-model every dwelling *as* an RdSAP
assessment, which derives U-values from the construction-default cascade
rather than honouring an assessor's measured value, so persisting them
would make the re-model a hybrid. Corpus check across 1,000
RdSAP-21.0.1 certs: roof_u_value 0, floor_u_value 0, wall_u_value
4 (0.4%).

Both columns nullable, additive, no backfill -- neither ever existed, so
there is nothing to restore. Affected properties must be re-ingested from
the gov-EPC API before their scores move.

Deploy ordering: this migration must land BEFORE the Model-side backend
deploys. The Python SQLModel now selects both columns, so without them
every EPC read fails outright with "column
epc_building_part.rafter_insulation_thickness does not exist" -- not a
graceful degradation.

Refs Hestia-Homes/Model#1661

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-21 17:51:32 +00:00
parent fa4f3f3ab0
commit 4f41570da4
4 changed files with 13156 additions and 0 deletions

View file

@ -0,0 +1,2 @@
ALTER TABLE "epc_building_part" ADD COLUMN "rafter_insulation_thickness" jsonb;--> statement-breakpoint
ALTER TABLE "epc_building_part" ADD COLUMN "wall_is_basement" boolean;

File diff suppressed because it is too large Load diff

View file

@ -1919,6 +1919,13 @@
"when": 1784631274000,
"tag": "0274_seed_project_type_and_workstream",
"breakpoints": true
},
{
"idx": 275,
"version": "7",
"when": 1784656804281,
"tag": "0275_opposite_ronan",
"breakpoints": true
}
]
}

View file

@ -778,10 +778,28 @@ export const epcBuildingPart = pgTable(
roofConstruction: integer("roof_construction"),
roofInsulationLocation: jsonb("roof_insulation_location"),
roofInsulationThickness: jsonb("roof_insulation_thickness"),
// RdSAP 10 §5.11.2 — a roof insulated AT RAFTERS lodges its thickness here,
// NOT in roof_insulation_thickness (which stays null for rafter roofs).
// jsonb (not text) to match the sibling thickness columns: the value is
// Union[str, int] — "250mm" from the gov API, int mm from Site Notes.
rafterInsulationThickness: jsonb("rafter_insulation_thickness"),
roofConstructionType: text("roof_construction_type"),
curtainWallAge: text("curtain_wall_age"),
// RdSAP 10 §5.17 / Table 23 — selects the basement-wall U-value column for the
// part's primary wall. NOT a lodged U-value: it picks WHICH RdSAP default
// applies. Nullable is load-bearing — do NOT add .notNull().default(false).
// Three distinct states:
// null → "not stated" → fall back to the gov-API code-6 heuristic
// false → "explicitly system-built" → do NOT apply the heuristic
// true → basement wall
// RdSAP code 6 is canonically system-built, and the Elmhurst site-notes mapper
// sets false precisely to defeat the heuristic. Collapsing false to null INVERTS
// the result: a system-built wall bills as a basement wall, and via has_basement
// drags the whole ground floor onto the Table 23 basement-floor U-value.
wallIsBasement: boolean("wall_is_basement"),
// Room in roof (inlined)
roomInRoofFloorArea: real("room_in_roof_floor_area"),
roomInRoofConstructionAgeBand: text("room_in_roof_construction_age_band"),