mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Add epc_renewable_heat_incentive table (space_heating_kwh, water_heating_kwh + the three insulation-impact kWh fields), wired into EpcPostgresRepository save/get. This is the P0 gap: RenewableHeatIncentive carries the baseline space-heating/hot-water kWh that EPC Energy Derivation consumes. The round-trip test now asserts full deep-equality (dropped the renewable_heat_incentive exclusion) and passes for RdSAP 21.0.0 + 21.0.1. DB migration for the new table documented in docs/migrations/epc-property-round-trip-fidelity.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
170 lines
9.7 KiB
Markdown
170 lines
9.7 KiB
Markdown
# EPC persistence schema gaps — migrations for round-trip fidelity
|
|
|
|
**Context:** Slice 1 (Hestia-Homes/Model#1129) of the `ara_first_run` rebuild. The round-trip
|
|
fidelity test (`EpcPropertyData → epc_property tables → reload → EpcPropertyData`, deep-equality)
|
|
surfaced that the current `epc_property` schema stores only a **partial, partly type-lossy
|
|
projection** of the `EpcPropertyData` domain object. This document lists every gap and the
|
|
migration needed to close it, so the schema (FE-owned for some tables) can be updated.
|
|
|
|
We can make the column/table changes on the **SQLModel definitions** in
|
|
`infrastructure/postgres/epc_property_table.py` directly — tests build their schema from those
|
|
models via `SQLModel.metadata.create_all`, so they don't need the live DB. The live migrations
|
|
listed here are what must be applied wherever the physical tables are owned.
|
|
|
|
**`epc_cache` relationship:** the raw gov-API JSON response is retained in the `epc_cache` table,
|
|
so the *source* is always recoverable even where the structured `epc_property` projection is
|
|
lossy. That makes these gaps "the structured store is incomplete" rather than "data is lost
|
|
forever" — but the modelling pipeline reads the structured `epc_property`, not the raw cache, so
|
|
the gaps below still block faithful modelling and must be closed.
|
|
|
|
Priority key: **P0** modelling needs it now · **P1** needed soon · **P2** completeness.
|
|
|
|
---
|
|
|
|
## Status after Slice 1 (#1129)
|
|
|
|
The round-trip test passes over the persisted projection for RdSAP-Schema-21.0.0 and 21.0.1.
|
|
The following were **applied on the SQLModel** (`infrastructure/postgres/epc_property_table.py`)
|
|
and **still require the matching DB migration** wherever the physical tables live:
|
|
|
|
- **§1 JSONB** — all `Union` code columns converted (`epc_property`: `heating_cylinder_size`,
|
|
`heating_immersion_heating_type`, `heating_cylinder_insulation_type`,
|
|
`heating_secondary_heating_type`, `heating_shower_outlet_type`, `energy_pv_connection`;
|
|
`epc_main_heating_detail`: `main_fuel_type`, `heat_emitter_type`, `emitter_temperature`,
|
|
`main_heating_control`; `epc_building_part`: `wall_construction`, `wall_insulation_type`,
|
|
`party_wall_construction`, `flat_roof_insulation_thickness`, `roof_insulation_location`,
|
|
`roof_insulation_thickness`; `epc_window`: `glazing_gap`, `orientation`, `window_type`,
|
|
`glazing_type`, `window_location`, `window_wall_type`, `draught_proofed`,
|
|
`permanent_shutters_present`, `transmission_data_source`).
|
|
- **New scalar columns** — `epc_property`: `heating_number_baths`, `heating_number_baths_wwhrs`,
|
|
`heating_electric_shower_count`, `heating_mixer_shower_count`,
|
|
`mechanical_vent_duct_insulation_level`, `addendum_stone_walls`, `addendum_system_build`,
|
|
`addendum_numbers` (JSONB), `ventilation_present`, `ventilation_sheltered_sides`,
|
|
`ventilation_has_suspended_timber_floor`, `ventilation_suspended_timber_floor_sealed`,
|
|
`ventilation_has_draught_lobby`, `ventilation_air_permeability_ap4_m3_h_m2`,
|
|
`ventilation_mechanical_ventilation_kind`; `epc_building_part`: `roof_construction_type`,
|
|
`curtain_wall_age`.
|
|
- **§2.1 `epc_renewable_heat_incentive` table** (#1137) — now created on the SQLModel and wired
|
|
into save/get; the round-trip test asserts **full deep-equality** (no exclusion). DB migration
|
|
still required.
|
|
|
|
**Still open (follow-up issues):** the remaining §2 structural tables (room-in-roof detail, PV
|
|
arrays, roof windows) + §3 nested-wall fields (`SapAlternativeWall.u_value`/`wall_thickness_mm`) +
|
|
`SapFloorDimension` exposed-floor flags — none populated in the 21.0.0/21.0.1 fixtures, so latent
|
|
until a richer fixture exercises them.
|
|
|
|
---
|
|
|
|
## 1. Type fidelity — convert `Union[int, str]` code columns to JSONB
|
|
|
|
These columns hold SAP/RdSAP categorical codes that are **`int` from the gov API** and **`str`
|
|
from Site Notes** (`Union[int, str]` in the domain). The forward mapper currently coerces them
|
|
with `str(...)` (and `bool(...)` for two window flags), so an API `int` of `26` is stored as
|
|
`"26"` and cannot be recovered. Convert each to **JSONB** and drop the `str()`/`bool()` coercion
|
|
in the forward mapper so the Python type round-trips exactly (JSON scalars preserve `int` vs
|
|
`str` vs `bool` vs `null`). **P0** — these feed the SAP10 calculator's int-keyed dispatch.
|
|
|
|
| Table | Columns |
|
|
|---|---|
|
|
| `epc_property` | `heating_cylinder_size`, `heating_immersion_heating_type`, `heating_cylinder_insulation_type`, `heating_secondary_heating_type`, `heating_shower_outlet_type`, `energy_pv_connection` |
|
|
| `epc_main_heating_detail` | `main_fuel_type`, `heat_emitter_type`, `emitter_temperature`, `main_heating_control` |
|
|
| `epc_building_part` | `wall_construction`, `wall_insulation_type`, `party_wall_construction`, `flat_roof_insulation_thickness`, `roof_insulation_location`, `roof_insulation_thickness` |
|
|
| `epc_window` | `glazing_gap`, `orientation`, `window_type`, `glazing_type`, `window_location`, `window_wall_type`, `draught_proofed`, `permanent_shutters_present` |
|
|
|
|
(`energy_meter_type` and `energy_wind_turbines_terrain_type` are `str` in the domain — leave as
|
|
`TEXT`.)
|
|
|
|
---
|
|
|
|
## 2. Not stored at all — new tables
|
|
|
|
### 2.1 `epc_renewable_heat_incentive` — **P0**
|
|
Maps `EpcPropertyData.renewable_heat_incentive` (`RenewableHeatIncentive`). Carries the **baseline
|
|
space-heating and hot-water kWh** that EPC Energy Derivation consumes — the single most important
|
|
gap. One row per `epc_property`.
|
|
|
|
| Column | Type | Source |
|
|
|---|---|---|
|
|
| `epc_property_id` | FK → `epc_property.id`, unique | |
|
|
| `space_heating_kwh` | float | `space_heating_kwh` |
|
|
| `water_heating_kwh` | float | `water_heating_kwh` |
|
|
| `impact_of_loft_insulation_kwh` | float, null | `impact_of_loft_insulation_kwh` |
|
|
| `impact_of_cavity_insulation_kwh` | float, null | `impact_of_cavity_insulation_kwh` |
|
|
| `impact_of_solid_wall_insulation_kwh` | float, null | `impact_of_solid_wall_insulation_kwh` |
|
|
|
|
### 2.2 `epc_room_in_roof` (+ `epc_room_in_roof_surface`) — **P1**
|
|
`SapBuildingPart.sap_room_in_roof` (`SapRoomInRoof`) is currently flattened to just
|
|
`room_in_roof_floor_area` + `room_in_roof_construction_age_band` on `epc_building_part`, dropping
|
|
the Type-2 geometry and the Detailed-measurement surfaces. Replace with a child table of
|
|
`epc_building_part`:
|
|
|
|
`epc_room_in_roof`: `epc_building_part_id` (FK, unique), `floor_area`, `construction_age_band`,
|
|
`common_wall_length_m`, `common_wall_height_m`, `gable_1_length_m`, `gable_1_height_m`,
|
|
`gable_2_length_m`, `gable_2_height_m`.
|
|
|
|
`epc_room_in_roof_surface` (0..n per RIR, from `detailed_surfaces: List[SapRoomInRoofSurface]`):
|
|
`epc_room_in_roof_id` (FK), `kind`, `area_m2`, `insulation_thickness_mm` (null),
|
|
`insulation_type` (null), `u_value` (null).
|
|
|
|
### 2.3 `epc_photovoltaic_array` — **P1**
|
|
`SapEnergySource.photovoltaic_arrays: List[PhotovoltaicArray]` (measured PV) is not stored at all
|
|
— only the `percent_roof_area` fallback is. One row per array: `epc_property_id` (FK),
|
|
`peak_power`, `pitch`, `orientation`, `overshading`.
|
|
|
|
### 2.4 `epc_roof_window` — **P2**
|
|
`EpcPropertyData.sap_roof_windows: List[SapRoofWindow]` not stored. One row per roof window:
|
|
`epc_property_id` (FK), `area_m2`, `u_value_raw`, `orientation`, `pitch_deg`, `g_perpendicular`,
|
|
`frame_factor`.
|
|
|
|
---
|
|
|
|
## 3. Not stored at all — new columns
|
|
|
|
### 3.1 `epc_property` additions
|
|
| Column | Type | Source | Pri |
|
|
|---|---|---|---|
|
|
| `addendum_stone_walls` | bool, null | `addendum.stone_walls` | P2 |
|
|
| `addendum_system_build` | bool, null | `addendum.system_build` | P2 |
|
|
| `addendum_numbers` | JSONB, null | `addendum.addendum_numbers` (`List[int]`) | P2 |
|
|
| `lzc_energy_sources` | JSONB, null | `lzc_energy_sources` (`List[int]`) | P2 |
|
|
| `solar_hw_collector_orientation` | text, null | `solar_hw_collector_orientation` | P1 |
|
|
| `solar_hw_collector_pitch_deg` | int, null | `solar_hw_collector_pitch_deg` | P1 |
|
|
| `solar_hw_overshading` | text, null | `solar_hw_overshading` | P1 |
|
|
| `extract_fans_count` | int, null | top-level `extract_fans_count` (distinct from the `ventilation_*` one) | P2 |
|
|
| `mechanical_vent_duct_insulation_level` | int, null | `mechanical_vent_duct_insulation_level` | P2 |
|
|
|
|
### 3.2 `epc_building_part` additions
|
|
| Column | Type | Source | Pri |
|
|
|---|---|---|---|
|
|
| `roof_construction_type` | text, null | `roof_construction_type` (Site-Notes str) | P1 |
|
|
| `curtain_wall_age` | text, null | `curtain_wall_age` (RdSAP §5.18) | P1 |
|
|
| `alt_wall_1_u_value` | float, null | `sap_alternative_wall_1.u_value` | P1 |
|
|
| `alt_wall_1_thickness_mm` | int, null | `sap_alternative_wall_1.wall_thickness_mm` | P1 |
|
|
| `alt_wall_2_u_value` | float, null | `sap_alternative_wall_2.u_value` | P1 |
|
|
| `alt_wall_2_thickness_mm` | int, null | `sap_alternative_wall_2.wall_thickness_mm` | P1 |
|
|
|
|
### 3.3 `epc_floor_dimension` additions
|
|
| Column | Type | Source | Pri |
|
|
|---|---|---|---|
|
|
| `is_exposed_floor` | bool, default false | `SapFloorDimension.is_exposed_floor` | P1 |
|
|
| `is_above_partially_heated_space` | bool, default false | `SapFloorDimension.is_above_partially_heated_space` | P1 |
|
|
|
|
---
|
|
|
|
## 4. Mapper-only gaps (no schema change required)
|
|
|
|
The table can already hold these; the **save mapper** simply doesn't write them. Fix in the
|
|
forward mapper, not the DB:
|
|
|
|
- **`air_tightness`** (`EnergyElement`) — `epc_energy_element.element_type` is a free string, so add
|
|
an `"air_tightness"` element type to the save loop. **P1.**
|
|
|
|
---
|
|
|
|
## 5. Scope note
|
|
|
|
Slice 1 (#1129) asserts faithful round-trip over the **projection the schema is meant to store**,
|
|
after applying §1 (JSONB) and the straightforward §3/§4 additions on the SQLModel. The structural
|
|
new tables in §2 (RHI, room-in-roof, PV arrays, roof windows) are tracked as their own follow-up
|
|
issues — `epc_renewable_heat_incentive` (§2.1) first, as it unblocks EPC Energy Derivation. Each
|
|
gap above should become a checkbox on the relevant issue so nothing is silently dropped.
|