From 0933f05173eb9eeb397e67a276ed9b7c58195ad0 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 14 Jul 2026 12:19:02 +0000 Subject: [PATCH] fix(reporting): EPC cards count certificates, not predictions (ADR-0014) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Homes Without an EPC" and "Expired EPCs" were both derived from estimatedSql, which asks "was this picture gap-filled?". That conflates two different questions: provenance (was it modelled?) and coverage (does a certificate exist?). They used to agree, because a gap-fill implied no certificate. Model ADR-0054 ends that: the gov EPC API only serves certs registered since 2012, so a pre-2012 dwelling is gap-filled even though a real, stale certificate exists — and gets the new source='expired'. On portfolio 824, 375 of the 403 "without an EPC" homes have exactly that: a real certificate, just an out-of-date one. A different, and far more actionable, problem than never having been certified. Two defects fell out of the same conflation: - Every query matched source='predicted' literally, and `source` is plain text with no enum or CHECK. An 'expired' row matches neither the lodged nor the predicted alias, so the home would have read as having a VALID CURRENT cert — silently gone from both cards, no Estimated badge, nothing to catch it. - isExpiredSql read epl.registration_date alone, but 714 of 824's 4,873 lodged certs carry only inspection_date. All 714 counted as current; 170 are in fact >10 years old. lodgementDateSql already coalesced the two; expiry didn't. The cards now partition on coverage: withoutEpcSql = no lodged AND no historical record; isExpiredSql = a cert of either kind, out of date. estimatedSql keeps its own meaning and spans the whole predicted slot, so an expired home is estimated AND has an EPC — both true. The `AND estimated = false` guard is gone from the Expired card: an expired home IS estimated, so it excluded the very homes being counted. Legacy portfolios are untouched (632: 8/1662 before and after). 824 moves to 913 → 1,083 expired immediately (the inspection_date fix), then to 25 / 1,461 once Model writes expired rows. Safe to deploy BEFORE Model — the predicates just find none. The reverse was not safe, which is why this goes first. Co-Authored-By: Claude Opus 4.8 (1M context) --- CONTEXT.md | 22 +++- ...ards-count-certificates-not-predictions.md | 105 ++++++++++++++++++ .../reporting/DashboardSummaryCards.tsx | 8 +- .../(portfolio)/reporting/EpcQualityCards.tsx | 12 +- .../reporting/ReportingClientArea.tsx | 4 +- .../reporting/databaseFunctions.ts | 46 +++++--- .../[slug]/(portfolio)/reporting/pdf/page.tsx | 4 +- .../[slug]/(portfolio)/reporting/types.ts | 13 ++- src/lib/services/epcSlot.test.ts | 104 +++++++++++++++++ src/lib/services/epcSources.ts | 77 +++++++++++-- 10 files changed, 351 insertions(+), 44 deletions(-) create mode 100644 docs/adr/0014-epc-cards-count-certificates-not-predictions.md create mode 100644 src/lib/services/epcSlot.test.ts diff --git a/CONTEXT.md b/CONTEXT.md index 4016fdcc..30eb39f0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -187,11 +187,29 @@ The act of substituting a corrected set of performance metrics in place of the L _Avoid_: override, adjustment, correction **EPC provenance** (`epc_property.source`): -Whether a property's EPC picture is a real certificate or a gap-fill. `lodged` = a real public/landlord EPC exists; `predicted` = no certificate, so the EPC was estimated from nearby properties (EPC Prediction gap-fill). Independent of Rebaseline: a `lodged` property may still be rebaselined, and a `predicted` property still carries Lodged-performance figures (mirrored estimates), so the presence of `lodged_*` columns does **not** imply a real certificate — only `source = lodged` does. +Whether a property's EPC picture is a real certificate or a gap-fill. Three values: +- `lodged` — a real, current public/landlord EPC exists. +- `predicted` — no certificate exists anywhere, so the EPC was gap-filled from nearby properties (EPC Prediction). +- `expired` — also gap-filled, but conditioned on **this dwelling's own expired certificate** from the historic register (Model ADR-0054). The gov EPC API only serves certificates registered since 2012, so a pre-2012 dwelling looks EPC-less to ingestion even though a real, stale certificate exists for it. + +Independent of Rebaseline: a `lodged` property may still be rebaselined, and a gap-filled property still carries Lodged-performance figures (mirrored estimates), so the presence of `lodged_*` columns does **not** imply a real certificate — only `source = lodged` does. +_Avoid_: estimated EPC (reserve "estimated" for the UI signal), source EPC + +**Predicted slot**: +`predicted` and `expired` are two flavours of one slot — a property holds at most one of them, and a re-ingestion may flip which. Every query must therefore address the **slot** (`source IN ('predicted','expired')`), never a single member: matching `source = 'predicted'` alone makes `expired` rows invisible, and since `epc_property.source` is plain `text` with no enum or CHECK constraint, nothing catches it. Mirrors `PREDICTED_SLOT_SOURCES` in the Model repo. See [ADR-0014](./docs/adr/0014-epc-cards-count-certificates-not-predictions.md). +_Avoid_: predicted source (it is two sources) + +**EPC coverage** — *does any certificate exist for this dwelling?* +`lodged` **or** `expired` ⇒ yes, one does (current, or out of date). `predicted`, or no `epc_property` rows at all ⇒ no, none does. + +This is a **different question** from provenance's "was the picture modelled?" — an `expired` property is gap-filled *and* has a certificate. Conflating the two is what made the reporting cards wrong: the "Homes Without an EPC" card counted every gap-fill, so homes whose only fault was an out-of-date certificate were reported as having no EPC at all. The cards partition on coverage, not on provenance. +_Avoid_: missing EPC (ambiguous — say "without an EPC" for coverage, "estimated" for provenance) _Avoid_: estimated EPC (reserve "estimated" for the UI signal), source EPC **Provenance signal** (UI): -What the user is told about an EPC's trustworthiness, derived from EPC provenance and Rebaseline together: **Estimated** (`source = predicted` — dominant; "estimated based on nearby homes") › **Re-modelled** (`source = lodged AND rebaseline_reason != none` — effective diverged from the lodged certificate under SAP 10) › **none** (`source = lodged AND rebaseline_reason = none` — effective equals lodged). Estimated always wins when both could apply. +What the user is told about an EPC's trustworthiness, derived from EPC provenance and Rebaseline together: **Estimated** (`source IN (predicted, expired)` — the whole predicted slot, dominant; "estimated based on nearby homes") › **Re-modelled** (`source = lodged AND rebaseline_reason != none` — effective diverged from the lodged certificate under SAP 10) › **none** (`source = lodged AND rebaseline_reason = none` — effective equals lodged). Estimated always wins when both could apply. + +An `expired` property is **Estimated**: its picture was modelled, not read off a current certificate. That it *also* has an (out-of-date) certificate is a matter of EPC coverage, which the reporting cards use — not of this signal. _Avoid_: estimation notification, banner (those are component names) ## Example dialogue diff --git a/docs/adr/0014-epc-cards-count-certificates-not-predictions.md b/docs/adr/0014-epc-cards-count-certificates-not-predictions.md new file mode 100644 index 00000000..c8a8b87c --- /dev/null +++ b/docs/adr/0014-epc-cards-count-certificates-not-predictions.md @@ -0,0 +1,105 @@ +# The EPC reporting cards count certificates, not predictions + +## Status + +accepted + +## Context + +The portfolio reporting page shows two EPC-quality cards: **Homes Without an EPC** +and **Expired EPCs**. Both were derived from `estimatedSql`, which asked *"is this +property's EPC picture a gap-fill?"*: + +```sql +-- "Homes Without an EPC" +SUM(CASE WHEN estimatedSql = true THEN 1 ELSE 0 END) +-- "Expired EPCs" +SUM(CASE WHEN isExpiredSql = true AND estimatedSql = false THEN 1 ELSE 0 END) +``` + +That conflates two genuinely different questions: + +- **Provenance** — was this picture *modelled*, or read off a real certificate? +- **Coverage** — does a certificate *exist* for this dwelling at all? + +They used to give the same answer, because `epc_property.source` had two values and +a gap-fill implied no certificate. That stopped being true. + +The gov EPC API only serves certificates registered since **1 January 2012**. A +dwelling whose only certificate predates that looks EPC-less to ingestion, so its +picture gets gap-filled — even though a real, if stale, certificate exists for it in +the historic register. Model ADR-0054 introduced a third `source` value, `expired`, +for exactly this case: a prediction *conditioned on the dwelling's own expired +certificate*. `predicted` and `expired` share one slot (`PREDICTED_SLOT_SOURCES`). + +Measured on portfolio 824 (5,276 homes): **403** homes were gap-filled, and **375** +of them have a real pre-2012 certificate in the historic backup. So the card claimed +403 homes had no EPC when 375 of them did — they just had an out-of-date one, which +is a different and far more actionable problem. It is also the *wrong* problem to +solve: you do not commission a new EPC survey for a home you believe has never been +certified in the same way you chase a lapsed one. + +Two further defects fell out of the same conflation: + +- `epc_property.source` is plain `text` with **no enum and no CHECK constraint**, and + every query matched `source = 'predicted'` literally. An `expired` row matches + neither the lodged nor the predicted alias, so the home would have read as though it + held a *valid current certificate* — silently absent from **both** cards, with no + Estimated badge. Nothing in the database would have caught this. +- `isExpiredSql` read `epl.registration_date` alone, but 714 of 824's 4,873 lodged + certificates carry only an `inspection_date`. All 714 were treated as current; 170 + of them are in fact over ten years old. (`lodgementDateSql` already coalesced the + two — the expiry predicate simply didn't.) + +## Decision + +**The two cards partition on coverage, not provenance.** A prediction is not a +certificate; an expired certificate is still a certificate. + +- `withoutEpcSql` — *no lodgement record and no historical record*: + `epl.id IS NULL AND epx.id IS NULL`. A blind `predicted` row is not evidence of an + EPC, so it is not consulted. A property with no `epc_property` rows at all also + lands here, correctly. +- `isExpiredSql` — *we found a certificate, of either kind, and it is out of date*: + an `expired` row (>10 years old by construction, since its conditioning certificate + predates 2012), **or** a lodged certificate older than ten years, dating it by + `COALESCE(registration_date, inspection_date)`. +- `estimatedSql` keeps its own, separate meaning — *was this modelled?* — and now + spans the whole predicted slot, so an `expired` home still carries the **Estimated** + provenance badge. It is estimated **and** has an EPC. Both are true. + +The `AND estimated = false` guard is removed from the Expired card and must stay +removed: an `expired` home *is* estimated, so the guard would exclude precisely the +homes the card exists to count. The rule it was really enforcing — *a gap-fill has no +certificate to expire* — now lives inside `isExpiredSql`, where a caller cannot forget +it. + +**Every query addresses the predicted slot (`epp` OR `epx`), never one member.** + +## Consequences + +Legacy properties (`updated_at` before the 2026-06-01 cutoff) are **unaffected** — +they have no `epc_property` rows and keep reading the flat `property_details_epc` +flags. Portfolio 632 reports 8 / 1,662 before and after. + +For new-approach properties the numbers move, in two steps. The `inspection_date` +fallback lands immediately and is a straight bug fix: 824's Expired count goes +**913 → 1,083**. Then, once Model starts writing `expired` rows, the cards settle at +**403 → 25** without an EPC and **1,083 → 1,461** expired. The portfolio has not +changed; we were miscounting it. + +This repo is safe to deploy **before** Model starts writing `expired` rows — the +predicates simply find none. The reverse is not true: had Model shipped first, every +`expired` home would have gone quietly missing from both cards. + +Two things we accept: + +- **The three-way reality is still squashed into booleans per card.** We do not + surface "has an expired certificate" and "has nothing at all" as distinct states + anywhere except by the two cards being disjoint. If a third state ever needs its own + treatment, this is where to add it. +- **Nothing enforces the slot at the database level.** `source` remains `text`. A + future query that matches `source = 'predicted'` alone would reintroduce the bug in + silence. `src/lib/services/epcSlot.test.ts` pins the invariant structurally; a CHECK + constraint or a Postgres enum would be stronger, and is worth doing when the schema + is next touched. diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/DashboardSummaryCards.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/DashboardSummaryCards.tsx index 439b54a4..8f2ab4a8 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/DashboardSummaryCards.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/DashboardSummaryCards.tsx @@ -12,7 +12,7 @@ import { Home, Zap, Leaf, LineChart, FileQuestionIcon } from "lucide-react"; import { formatNumber, sapToEpc } from "@/app/utils"; import type { AverageMetrics, - EstimatedCounts, + EpcCoverageCounts, TotalMetrics, ScenarioOverlayMetrics, MetricKey, @@ -66,18 +66,18 @@ export function DashboardSummaryCards({ total, totals, averages, - estimatedCounts, + epcCoverage, scenarioOverlay, loading = false, }: { total: number; totals: TotalMetrics; averages: AverageMetrics; - estimatedCounts: EstimatedCounts; + epcCoverage: EpcCoverageCounts; scenarioOverlay?: ScenarioOverlayMetrics | null; loading?: boolean; }) { - const missingEpcCount = estimatedCounts.estimated; + const missingEpcCount = epcCoverage.withoutEpc; const missingEpcPercent = total > 0 ? (missingEpcCount / total) * 100 : 0; const averageCurrentEpc = sapToEpc(averages.avg_sap || 0); diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/EpcQualityCards.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/EpcQualityCards.tsx index 89893137..6dc57604 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/EpcQualityCards.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/EpcQualityCards.tsx @@ -9,21 +9,21 @@ import { } from "@/app/shadcn_components/ui/card"; import { motion } from "framer-motion"; import { FileQuestion, AlertTriangle, TrendingDown } from "lucide-react"; -import type { EstimatedCounts } from "./types"; +import type { EpcCoverageCounts } from "./types"; export function EpcQualityCards({ - estimatedCounts, + epcCoverage, total, expiredEpcs, likelyDowngrades, }: { - estimatedCounts: EstimatedCounts; + epcCoverage: EpcCoverageCounts; total: number; expiredEpcs: number; likelyDowngrades: number; }) { - // Missing EPCs (estimated = true) - const missing = estimatedCounts.estimated; + // Homes with no certificate at all — neither lodged nor historical. + const missing = epcCoverage.withoutEpc; const pctMissing = total > 0 ? (missing / total) * 100 : 0; // Expired EPCs @@ -39,7 +39,7 @@ export function EpcQualityCards({ icon: FileQuestion, color: "text-red-600", value: missing, - subtitle: `${pctMissing.toFixed(1)}% missing EPC records`, + subtitle: `${pctMissing.toFixed(1)}% have no EPC on record`, barColor: "bg-red-500", barWidth: pctMissing, gradient: "bg-gradient-to-br from-white to-red-50/20", diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx index 8d89323b..8dd01dbe 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/ReportingClientArea.tsx @@ -315,7 +315,7 @@ export function ReportingClientArea({ total={activeMetrics.total} totals={activeMetrics.totals} averages={activeMetrics.averages} - estimatedCounts={activeMetrics.estimatedCounts} + epcCoverage={activeMetrics.epcCoverage} scenarioOverlay={scenarioOverlay} loading={scenarioBusy} /> @@ -329,7 +329,7 @@ export function ReportingClientArea({
{ - const result = await db.execute(sql` +): Promise { + const result = await db.execute(sql` SELECT - SUM(CASE WHEN ${estimatedSql(sql`e`)} = true THEN 1 ELSE 0 END)::int AS estimated, - SUM(CASE WHEN ${estimatedSql(sql`e`)} = false THEN 1 ELSE 0 END)::int AS actual + SUM(CASE WHEN ${withoutEpcSql(sql`e`)} = true THEN 1 ELSE 0 END)::int AS without_epc, + SUM(CASE WHEN ${withoutEpcSql(sql`e`)} = false THEN 1 ELSE 0 END)::int AS with_epc FROM property p LEFT JOIN property_details_epc e ON e.property_id = p.id ${newApproachJoins} @@ -163,16 +173,20 @@ export async function getCountByPropertyType( return result.rows; } +/** + * The "Expired EPCs" card: homes where we found a certificate — lodged or + * historical — and it is out of date. + * + * The old `AND estimated = false` guard is gone, and must stay gone: a home whose + * prediction was conditioned on an expired certificate IS estimated, so that guard + * would exclude exactly the homes this card exists to count. The "a gap-fill has no + * certificate to expire" rule it was really enforcing now lives inside + * `isExpiredSql`, where it can't be forgotten by a caller. + */ export async function getExpiredEpcCount(portfolioId: number): Promise { const result = await db.execute<{ expired: number }>(sql` SELECT - SUM( - CASE - WHEN ${isExpiredSql(sql`e`)} = true AND ${estimatedSql(sql`e`)} = false - THEN 1 - ELSE 0 - END - )::int AS expired + SUM(CASE WHEN ${isExpiredSql(sql`e`)} = true THEN 1 ELSE 0 END)::int AS expired FROM property p LEFT JOIN property_details_epc e ON e.property_id = p.id ${newApproachJoins} @@ -213,7 +227,7 @@ export async function loadBaselineMetrics( totals, ageBands, epcBands, - estimatedCounts, + epcCoverage, expiredEpcs, likelyDowngrades, ] = await Promise.all([ @@ -222,7 +236,7 @@ export async function loadBaselineMetrics( getTotals(portfolioId), getCountByAgeBand(portfolioId), getCountByEpcBand(portfolioId), - getEstimatedCounts(portfolioId), + getEpcCoverageCounts(portfolioId), getExpiredEpcCount(portfolioId), getLikelyDowngrades(portfolioId), ]); @@ -233,7 +247,7 @@ export async function loadBaselineMetrics( totals, ageBands, epcBands, - estimatedCounts, + epcCoverage, expiredEpcs, likelyDowngrades, }; diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx b/src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx index 0e72466c..8c61df68 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/pdf/page.tsx @@ -175,7 +175,7 @@ export default async function ReportingPdfPage(props: { total={baseline.total} totals={baseline.totals} averages={baseline.averages} - estimatedCounts={baseline.estimatedCounts} + epcCoverage={baseline.epcCoverage} scenarioOverlay={scenarioOverlay} /> @@ -188,7 +188,7 @@ export default async function ReportingPdfPage(props: { /> ) => + dialect.sqlToQuery(fragment).sql; + +// A zero-interpolation fragment renders empty on its own, so embed it. +const renderJoins = () => render(sql`SELECT 1 ${newApproachJoins}`); + +describe("newApproachJoins", () => { + it("joins all three sources, so no epc_property row is invisible", () => { + const joins = renderJoins(); + expect(joins).toContain("epl.source = 'lodged'"); + expect(joins).toContain("epp.source = 'predicted'"); + expect(joins).toContain("epx.source = 'expired'"); + }); +}); + +describe("estimatedSql — 'was this picture modelled?'", () => { + it("counts BOTH flavours of the predicted slot as estimated", () => { + // An expired-conditioned prediction is still a prediction: it must carry the + // Estimated provenance signal, not read as a real certificate. + expect(render(estimatedSql(sql`e`))).toContain( + "(epl.id IS NULL AND (epp.id IS NOT NULL OR epx.id IS NOT NULL))", + ); + }); +}); + +describe("withoutEpcSql — 'does any certificate exist?'", () => { + it("requires neither a lodged nor a historical record", () => { + expect(render(withoutEpcSql(sql`e`))).toContain( + "(epl.id IS NULL AND epx.id IS NULL)", + ); + }); + + it("does not consult the blind-predicted row: a prediction is not a certificate", () => { + // A home with only a `predicted` row has no EPC anywhere — that is precisely + // what makes it "without an EPC", so `epp` must not appear as evidence of one. + expect(render(withoutEpcSql(sql`e`))).not.toContain("epp"); + }); + + it("is a different question from estimatedSql", () => { + // The bug was treating these as synonyms. An `expired` home is estimated AND + // has an EPC, so the two predicates must disagree about it. + expect(render(withoutEpcSql(sql`e`))).not.toEqual(render(estimatedSql(sql`e`))); + }); +}); + +describe("isExpiredSql — 'we found a certificate and it is out of date'", () => { + it("treats an expired-source row as expired by construction", () => { + // ADR-0054: an `expired` row is only ever written when the conditioning + // certificate predates 2012, so it is >10 years old by definition. + expect(render(isExpiredSql(sql`e`))).toContain("epx.id IS NOT NULL"); + }); + + it("falls back to inspection_date when the lodged cert has no registration_date", () => { + // Reading registration_date alone silently counted date-less lodged certs as + // current; lodgementDateSql already coalesces the same way. + expect(render(isExpiredSql(sql`e`))).toContain( + "COALESCE(epl.registration_date, epl.inspection_date)", + ); + }); + + it("excludes gap-filled legacy rows — nothing to expire without a certificate", () => { + expect(render(isExpiredSql(sql`e`))).toContain( + "NOT COALESCE(e.estimated, false)", + ); + }); +}); + +describe("provenanceSignalSql", () => { + it("shows the Estimated badge for both flavours of the predicted slot", () => { + // Without epx here, an expired-conditioned home would render with no badge at + // all, as though its picture came off a real current certificate. + expect(render(provenanceSignalSql)).toContain( + "(epp.id IS NOT NULL OR epx.id IS NOT NULL) AND epl.id IS NULL", + ); + }); +}); diff --git a/src/lib/services/epcSources.ts b/src/lib/services/epcSources.ts index 42174e97..59c16562 100644 --- a/src/lib/services/epcSources.ts +++ b/src/lib/services/epcSources.ts @@ -69,11 +69,33 @@ type Alias = ReturnType; /** TRUE for new-approach rows. */ export const isNewApproachSql = sql`p.updated_at >= ${NEW_APPROACH_CUTOFF}::date`; -/** LEFT JOINs that expose the new sources alongside the legacy table. */ +/** + * LEFT JOINs that expose the new sources alongside the legacy table. + * + * `epc_property.source` has three values, not two. `lodged` is a real certificate. + * The other two share one slot — the *predicted slot* — and are both gap-fills: + * + * - `predicted` — synthesised blind from the postcode cohort. Nothing is known + * about this dwelling's own energy performance; there is no certificate for it + * anywhere. + * - `expired` — synthesised, but conditioned on the dwelling's own expired + * pre-2012 certificate from the historic register (Model ADR-0054). The gov EPC + * API only serves certificates registered since 2012, so these dwellings look + * EPC-less to ingestion even though a real, stale certificate exists. + * + * That distinction is the whole point: a home with an `expired` row **has** an EPC + * (an expired one); a home with only a `predicted` row has none at all. + * + * Matching `source = 'predicted'` alone would silently drop every `expired` row — + * it would match neither `epl` nor `epp`, and the home would read as though it had + * a valid current certificate. Address the *slot* (`epp` OR `epx`), never one member. + * Mirrors PREDICTED_SLOT_SOURCES in the Model repo. + */ export const newApproachJoins = sql` LEFT JOIN property_baseline_performance bp ON bp.property_id = p.id LEFT JOIN epc_property epl ON epl.property_id = p.id AND epl.source = 'lodged' LEFT JOIN epc_property epp ON epp.property_id = p.id AND epp.source = 'predicted' + LEFT JOIN epc_property epx ON epx.property_id = p.id AND epx.source = 'expired' `; /** @@ -132,7 +154,7 @@ export const lodgedSapSql = sql`CASE WHEN ${isNewApproachSql} THEN (CASE WHEN ep export const provenanceSignalSql = sql`CASE WHEN ${isNewApproachSql} THEN ( CASE - WHEN epp.id IS NOT NULL AND epl.id IS NULL THEN 'estimated' + WHEN (epp.id IS NOT NULL OR epx.id IS NOT NULL) AND epl.id IS NULL THEN 'estimated' WHEN epl.id IS NOT NULL AND bp.rebaseline_reason IS NOT NULL AND bp.rebaseline_reason <> 'none' THEN 'remodelled' ELSE 'none' END @@ -179,18 +201,57 @@ export const lodgementDateSql = (e: Alias) => sql`CASE WHEN ${isNewApproachSql} THEN COALESCE(epl.registration_date, epl.inspection_date) ELSE ${e}.lodgement_date END`; /** - * "estimated" boolean. New: the property only has a predicted EPC (no lodged - * row). Legacy: e.estimated. + * "estimated" — is this property's EPC picture a gap-fill rather than a real + * certificate? True for *either* flavour of the predicted slot: a blind + * `predicted` and a cert-conditioned `expired` are both synthesised, so both + * carry the Estimated provenance signal. + * + * This answers "was it modelled?", NOT "does a certificate exist?" — an `expired` + * property is estimated AND has an (expired) certificate. For the latter question + * use `withoutEpcSql`. Conflating the two is what made the reporting cards wrong. + * + * Legacy: e.estimated. */ export const estimatedSql = (e: Alias) => - sql`CASE WHEN ${isNewApproachSql} THEN (epl.id IS NULL AND epp.id IS NOT NULL) ELSE COALESCE(${e}.estimated, false) END`; + sql`CASE WHEN ${isNewApproachSql} THEN (epl.id IS NULL AND (epp.id IS NOT NULL OR epx.id IS NOT NULL)) ELSE COALESCE(${e}.estimated, false) END`; /** - * "expired" boolean. New: derived from the lodged EPC age (>10 years), since the - * new graph stores no is_expired flag. Legacy: e.is_expired. + * "no EPC at all" — we found neither a lodgement record nor a historical one, so + * nothing has ever been certified for this dwelling and its picture was gap-filled + * blind. This is the "Homes Without an EPC" card. + * + * Note a plain `predicted` row does not make a property "with" an EPC — a + * prediction is not a certificate. Only `lodged` (a current cert) or `expired` (a + * historical cert we conditioned on) count as having one. A property with no + * epc_property rows at all also lands here, which is right: it too has no EPC. + * + * Legacy: the flat `estimated` flag, which meant exactly this before the register + * had a historical tier to distinguish. + */ +export const withoutEpcSql = (e: Alias) => + sql`CASE WHEN ${isNewApproachSql} THEN (epl.id IS NULL AND epx.id IS NULL) ELSE COALESCE(${e}.estimated, false) END`; + +/** + * "expired" — we found a certificate, of one kind or the other, and it is out of + * date. Two ways that happens: + * + * - an `expired` row exists: by construction (Model ADR-0054) it was conditioned + * on a pre-2012 certificate, which is >10 years old by definition; or + * - the lodged certificate is itself more than 10 years old. + * + * The lodged date falls back to `inspection_date` when `registration_date` is null — + * a sizeable minority of lodged rows carry only the former, and reading + * registration_date alone silently counted them as current. `lodgementDateSql` + * above already coalesces the same way. + * + * A blindly-`predicted` property is never expired: it has no certificate to expire. + * Legacy: e.is_expired, less the gap-filled rows (same reason). */ export const isExpiredSql = (e: Alias) => - sql`CASE WHEN ${isNewApproachSql} THEN (epl.registration_date IS NOT NULL AND epl.registration_date < (CURRENT_DATE - INTERVAL '10 years')) ELSE ${e}.is_expired END`; + sql`CASE WHEN ${isNewApproachSql} THEN ( + epx.id IS NOT NULL + OR COALESCE(epl.registration_date, epl.inspection_date) < (CURRENT_DATE - INTERVAL '10 years') + ) ELSE (${e}.is_expired AND NOT COALESCE(${e}.estimated, false)) END`; /** * The main-fuel scalar for one epc_property alias, read from the JSONB