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

6.5 KiB
Raw Blame History

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 SapResultEnergyBreakdown 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.