Model/docs/migrations/property-baseline-performance-table.md
Khalim Conn-Kowlessar 09b3bc98ba Document the nullable lodged_* migration for the FE/Drizzle owner
Specifies the ALTER ... DROP NOT NULL on the four lodged_* columns (#1361) and
the ordering constraint: it must land before the backend writes NULL or runs the
backfill, else the predicted-Property INSERT aborts the batch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 22:19:54 +00:00

93 lines
6.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# `property_baseline_performance` table — FE-owned migration
**Context:** Slice 6 (Hestia-Homes/Model#1135) of the `ara_first_run` rebuild. The
`PropertyBaselineOrchestrator` establishes a Property's **Baseline Performance** (ADR-0004) and persists it
via a new `PropertyBaselineRepository` port. This is a brand-new table — no predecessor.
Per ADR-0004's amendment, the lodged/effective pair does **not** land on `property_details_epc`
(which is being retired as too coupled to the legacy EPC-API schema). It lands here, as its own
aggregate's table.
The SQLModel row is defined in `infrastructure/postgres/` so the ephemeral-Postgres tests build it
via `SQLModel.metadata.create_all`. The **production migration is FE-owned (Drizzle ORM)** — a
straight lift-and-shift of the columns below.
## `property_baseline_performance` — one row per Property
| Column | Type | Notes |
|---|---|---|
| `id` | serial PK | |
| `property_id` | int, FK → `property.id`, **unique** | one Baseline Performance per Property |
| `lodged_sap_score` | int, **nullable** | Lodged Performance — gov register, off the Effective EPC. **NULL for a predicted Property** (no lodged cert — #1361, see below) |
| `lodged_epc_band` | text, **nullable** | the `Epc` enum, stored as its string value (e.g. `"C"`) |
| `lodged_co2_emissions_t_per_yr` | float, **nullable** | tonnes CO₂/yr (whole dwelling) |
| `lodged_primary_energy_intensity_kwh_per_m2_yr` | int, **nullable** | PEUI (kWh/m²/yr); **not** "heat demand" — see CONTEXT.md |
| `effective_sap_score` | int | Effective Performance — what modelling scored against |
| `effective_epc_band` | text | |
| `effective_co2_emissions_t_per_yr` | float | tonnes CO₂/yr (whole dwelling) |
| `effective_primary_energy_intensity_kwh_per_m2_yr` | int | kWh/m²/yr |
| `rebaseline_reason` | text | `none` \| `pre_sap10` \| `physical_state_changed` \| `both` |
| `space_heating_kwh` | float | EPC `renewable_heat_incentive` recorded demand. **Superseded** by `heating_kwh` (delivered) when the bill block populates; kept until then to avoid an empty-kWh gap, dropped in the population slice. |
| `water_heating_kwh` | float | EPC `renewable_heat_incentive`; **superseded** by `hot_water_kwh`. |
### Lodged half is nullable (#1361 Class B, 2026-06-30)
The four `lodged_*` columns are **nullable**. A **predicted** Property (EPC Prediction, ADR-0029/0031:
no lodged cert, its `EpcPropertyData` synthesised from neighbours) has **no Lodged Performance** — the
backend now writes `lodged_* = NULL` for it and persists only the Effective half (ADR-0004 amendment).
Reading the synthesised EPC's recorded fields as a lodged figure had been manufacturing a phantom (a
neighbour's SAP), which the migration + a one-time backfill remove.
**FE-owned Drizzle migration required:** `ALTER TABLE property_baseline_performance ALTER COLUMN
lodged_sap_score DROP NOT NULL` (and the same for `lodged_epc_band`, `lodged_co2_emissions_t_per_yr`,
`lodged_primary_energy_intensity_kwh_per_m2_yr`). This **must land before** the backend deploys the
orchestrator change or runs `scripts/null_predicted_lodged_performance.py` — a `NULL` write against a
`NOT NULL` column aborts the batch (ADR-0012). The backfill NULLs the four columns on the ~12,236
existing predicted-source rows; Effective, the bill block, and `rebaseline_reason` are left intact.
### Bill block (ADR-0014) — the energy bill, composed per section
Produced by **Bill Derivation**: the calculator's **delivered** kWh per end use priced at current
**Fuel Rates** (a committed snapshot, not SAP's standardised prices), per section + the total.
Per-section kWh is *delivered fuel* (demand ÷ efficiency — what the household pays for), distinct
from the recorded-demand `space_heating_kwh`/`water_heating_kwh` above which it supersedes.
All columns below are **nullable** (every one is `Optional[float]`, default `None`) and **FE-owned
(Drizzle)**. The `bill_` prefix is deliberate: it keeps the per-section columns from clashing with
the recorded-demand `space_heating_kwh` / `water_heating_kwh` above. The whole block is `None` for
one row together when no calculator ran (the stub path produced no `SapResult` to price); a section
absent from the bill leaves its two columns `None` (not `0` — it was not billed). `to_domain` uses
`bill_total_annual_bill_gbp IS NOT NULL` as the discriminator for "a bill was persisted".
| Column | Type | Notes |
|---|---|---|
| `bill_heating_kwh` | float, nullable | delivered fuel kWh (main + main-2 + secondary heating) |
| `bill_heating_cost_gbp` | float, nullable | priced at the heating fuel's current rate |
| `bill_hot_water_kwh` | float, nullable | |
| `bill_hot_water_cost_gbp` | float, nullable | |
| `bill_lighting_kwh` | float, nullable | |
| `bill_lighting_cost_gbp` | float, nullable | |
| `bill_appliances_kwh` | float, nullable | unregulated load — `None` until the appliances field lands on `SapResult` |
| `bill_appliances_cost_gbp` | float, nullable | |
| `bill_cooking_kwh` | float, nullable | unregulated load — `None` until `SapResult` carries it |
| `bill_cooking_cost_gbp` | float, nullable | |
| `bill_pumps_fans_kwh` | float, nullable | |
| `bill_pumps_fans_cost_gbp` | float, nullable | |
| `bill_cooling_kwh` | float, nullable | mostly absent in UK homes; carried for completeness as it affects the bill |
| `bill_cooling_cost_gbp` | float, nullable | |
| `bill_standing_charges_gbp` | float, nullable | daily standing charge × 365, once per distinct metered fuel (off-gas fuels have none) |
| `bill_seg_credit_gbp` | float, nullable | SEG export credit on PV (subtracted) |
| `bill_total_annual_bill_gbp` | float, nullable | Σ section costs + standing charges SEG; the not-null discriminator for a persisted bill |
The calculator is **load-bearing** (ADR-0013 amendment): for `sap_version < 10.2` the `effective_*`
columns hold the calculator's output (so `effective_* != lodged_*` legitimately); at/above 10.2 they
mirror the lodged figures and divergence is logged. A cert the calculator cannot score aborts the
batch rather than persisting a wrong row.
### Population timing
The bill columns are now **populated**: the `PropertyBaselineOrchestrator` reads the current Fuel
Rates snapshot, builds a `BillDerivation`, and prices every scored property's `SapResult`
`EnergyBreakdown` into a `Bill` that `from_domain` flattens onto these columns. They stay `None`
together only on the stub (no-calculator) path. The appliances / cooking sections remain `None`
until those fields land on `SapResult`. The Drizzle migration creates all `bill_*` columns nullable.