fix(reporting): count expired-slot certificates as expired (restore #393)

The reporting redesign reintroduced the `AND estimated = false` guard on
the Expired count that PR #393 (ADR-0014) had removed. An expired-slot
(source='expired') home is estimated (modelled) AND expired (has a real,
stale certificate), so the guard dropped it from every Expired surface —
verified live: 2,475 of portfolio 796's homes were mislabelled.

Restore the coverage partition, driven by a pure twin:

- epcEvidence.ts: classifyEpcEvidence (in-date | expired | no-certificate),
  TDD'd — an expired-slot cert is expired, never no-certificate.
- expiredEpcs (getBaselineAggregates) and the "expired" drill: drop the
  estimated=false guard; isExpiredSql already implies a certificate exists.
- getDataQualityMetrics: a coverage partition (In date / Expired / No
  certificate) that sums to total, plus the provenance `estimated` count for
  the "Estimated EPCs" issue row (kept separate — coverage vs provenance is
  #393's whole point).
- Data-quality bar reads the SQL "No certificate" bucket, retiring the
  client-side subtraction added earlier; issue-row copy updated.

Verified on portfolio 796: partition sums to total (31,451) and Expired
recovers the 2,475 expired-slot homes. 95 reporting tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-27 14:51:56 +00:00
parent fe91f002a7
commit bc2cde3984
6 changed files with 164 additions and 35 deletions

View file

@ -80,7 +80,10 @@ export async function GET(
const filterSql = {
band: sql`COALESCE((${effectiveEpcBandSql})::text, 'Unknown') = ${band}`,
estimated: sql`${estimatedSql(sql`e`)} = true`,
expired: sql`${isExpiredSql(sql`e`)} = true AND ${estimatedSql(sql`e`)} = false`,
// Coverage: a certificate of either kind, out of date. No `estimated = false`
// guard — an expired-slot home is estimated AND expired, and this drill must
// match the Expired count (ADR-0014, PR #393). Twin: classifyEpcEvidence.
expired: sql`${isExpiredSql(sql`e`)} = true`,
"likely-downgrade": likelyDowngradeSql,
"likely-upgrade": likelyUpgradeSql,
measure: sql`EXISTS (

View file

@ -5,11 +5,6 @@ import { Clock } from "lucide-react";
import type { DataQualityMetrics } from "@/lib/reporting/types";
import { DrillDownShelf, type DrillTarget } from "../components/DrillDownShelf";
// Diagonal hatch marks the "No EPC record" segment as an absence of evidence,
// distinct from Estimated's solid grey (which is a real, if modelled, picture).
const NO_RECORD_FILL =
"repeating-linear-gradient(45deg, #e6e8ee, #e6e8ee 5px, #f6f7f9 5px, #f6f7f9 10px)";
const UNLOCK_MODULES = [
{
name: "Condition & compliance",
@ -44,6 +39,7 @@ export function DataQualityClientArea({
total,
inDate,
expired,
noCertificate,
estimated,
likelyDowngrades,
likelyUpgrades,
@ -55,32 +51,26 @@ export function DataQualityClientArea({
setPage(1);
}
// Homes with no EPC evidence at all (no epc_property rows yet — the ticketed
// backend gap). Without this, the three evidence buckets under-sum the total
// and the bar silently drops them, breaking the "every home counted once"
// claim. Shown as its own segment so the bar always sums to `total`; it
// self-empties to nothing once the backend populates those rows.
const noRecord = Math.max(total - inDate - expired - estimated, 0);
// Coverage partition — In date / Expired / No certificate — computed in SQL
// (classifyEpcEvidence's twin), so it always sums to total. "No certificate"
// is every home with no real certificate of either kind (predicted-only or no
// EPC record at all); an expired-slot home sits in Expired, since it holds a
// real, stale certificate.
const composition = [
{ label: "In date · " + inDate, value: inDate, color: "#14163d" },
{ label: "Expired · " + expired, value: expired, color: "#a07c42" },
{ label: "Estimated · " + estimated, value: estimated, color: "#c5cad8" },
...(noRecord > 0
? [
{
label: "No EPC record · " + noRecord,
value: noRecord,
color: NO_RECORD_FILL,
},
]
: []),
{
label: "No certificate · " + noCertificate,
value: noCertificate,
color: "#c5cad8",
},
];
const issues = [
{
key: "estimated" as const,
name: "Estimated EPCs",
def: "No certificate exists; performance predicted from nearby homes of similar archetype.",
def: "Performance modelled from nearby homes of similar archetype, not read off a certificate.",
count: estimated,
impact:
"These homes' SAP, bills and carbon are modelled, not certificated — they move averages and band counts.",
@ -89,10 +79,10 @@ export function DataQualityClientArea({
{
key: "expired" as const,
name: "Expired EPCs",
def: "Real certificate, older than 10 years.",
def: "A real certificate — lodged over ten years ago, or a pre-2012 record — that is out of date.",
count: expired,
impact:
"Still counted as lodged, but the home may have changed since assessment. Re-assess before relying on these bands.",
"The certificate is real but stale; the home may have changed since assessment. Re-assess before relying on these bands.",
good: false,
},
{

View file

@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { classifyEpcEvidence } from "./epcEvidence";
describe("classifyEpcEvidence", () => {
it("classifies a current lodged certificate as in-date", () => {
// Arrange
const home = {
hasLodgedCertificate: true,
lodgedExpired: false,
hasExpiredSlotCertificate: false,
};
// Act
const evidence = classifyEpcEvidence(home);
// Assert
expect(evidence).toBe("in-date");
});
it("classifies a lodged certificate over ten years old as expired", () => {
// Arrange
const home = {
hasLodgedCertificate: true,
lodgedExpired: true,
hasExpiredSlotCertificate: false,
};
// Act
const evidence = classifyEpcEvidence(home);
// Assert
expect(evidence).toBe("expired");
});
it("classifies an expired-slot (epx) certificate as expired, not no-certificate", () => {
// The #393 regression: an `expired`-source home has a real (stale)
// certificate, so it must count as expired — never as having no EPC.
// Arrange
const home = {
hasLodgedCertificate: false,
lodgedExpired: false,
hasExpiredSlotCertificate: true,
};
// Act
const evidence = classifyEpcEvidence(home);
// Assert
expect(evidence).toBe("expired");
});
it("classifies a home with no certificate of either kind as no-certificate", () => {
// Arrange
const home = {
hasLodgedCertificate: false,
lodgedExpired: false,
hasExpiredSlotCertificate: false,
};
// Act
const evidence = classifyEpcEvidence(home);
// Assert
expect(evidence).toBe("no-certificate");
});
it("treats an expired-slot certificate as expired even alongside a current lodged read", () => {
// Arrange
const home = {
hasLodgedCertificate: true,
lodgedExpired: false,
hasExpiredSlotCertificate: true,
};
// Act
const evidence = classifyEpcEvidence(home);
// Assert
expect(evidence).toBe("expired");
});
});

View file

@ -0,0 +1,43 @@
/**
* EPC evidence the coverage question ("what certificate exists for this
* dwelling, and is it current?"), as opposed to provenance ("was the picture
* modelled?"). The pure twin of the reporting coverage partition; the SQL
* (getDataQualityMetrics, expiredEpcs, the "expired" drill) mirrors it.
*
* See ADR-0014 and CONTEXT.md (EPC coverage vs EPC provenance). A prediction is
* not a certificate; an expired certificate still is one so an expired-slot
* (`epx`) home is `expired`, never `no-certificate`. Keeping these apart is the
* whole point of #393: an `expired` home is *estimated* (provenance) AND has an
* EPC (coverage) both true.
*/
export type EpcEvidence = "in-date" | "expired" | "no-certificate";
export interface EpcEvidenceInput {
/** A real lodged (register) certificate exists (`epc_property.source = 'lodged'`). */
hasLodgedCertificate: boolean;
/** The lodged certificate is more than ten years old. */
lodgedExpired: boolean;
/**
* A conditioned expired-slot certificate exists (`source = 'expired'`) a
* real pre-2012 certificate the gov API no longer serves. Out of date by
* construction (Model ADR-0054).
*/
hasExpiredSlotCertificate: boolean;
}
export function classifyEpcEvidence(input: EpcEvidenceInput): EpcEvidence {
// A certificate of either kind, out of date. An expired-slot cert is stale by
// construction; a lodged cert may itself be over ten years old.
if (
input.hasExpiredSlotCertificate ||
(input.hasLodgedCertificate && input.lodgedExpired)
) {
return "expired";
}
// No certificate of any kind — a prediction is not a certificate.
if (!input.hasLodgedCertificate && !input.hasExpiredSlotCertificate) {
return "no-certificate";
}
return "in-date";
}

View file

@ -8,6 +8,7 @@ import {
energyConsumptionSql,
estimatedSql,
isExpiredSql,
withoutEpcSql,
effectiveSapSql,
effectiveEpcBandSql,
lodgedEpcBandSql,
@ -126,12 +127,12 @@ export async function getBaselineAggregates(
SUM(${billsSql(sql`e`)})::float AS total_bills,
SUM(CASE WHEN ${est} = true THEN 1 ELSE 0 END)::int AS estimated,
SUM(CASE WHEN ${est} = false THEN 1 ELSE 0 END)::int AS actual,
SUM(
CASE
WHEN ${isExpiredSql(sql`e`)} = true AND ${est} = false THEN 1
ELSE 0
END
)::int AS expired,
-- Coverage: a certificate of either kind, out of date. isExpiredSql
-- already implies a certificate exists (an expired-slot row, or a lodged
-- cert over 10y), so no estimated=false guard an expired-slot home is
-- estimated AND expired, and the Expired count must include it (ADR-0014,
-- PR #393). Twin: classifyEpcEvidence (@/lib/reporting/epcEvidence).
SUM(CASE WHEN ${isExpiredSql(sql`e`)} = true THEN 1 ELSE 0 END)::int AS expired,
${bandCols},
${lodgedBandCols}
FROM property p
@ -393,12 +394,18 @@ export async function getDataQualityMetrics(
const result = await db.execute<DataQualityMetrics>(sql`
SELECT
COUNT(*)::int AS total,
-- Coverage partition (In date / Expired / No certificate) the SQL twin
-- of classifyEpcEvidence (@/lib/reporting/epcEvidence). Mutually exclusive
-- and sums to total, so an expired-slot home lands in Expired (it has a
-- stale certificate), never in No certificate (ADR-0014, PR #393).
SUM(CASE
WHEN ${estimatedSql(sql`e`)} = false AND ${isExpiredSql(sql`e`)} = false
WHEN ${withoutEpcSql(sql`e`)} = false AND ${isExpiredSql(sql`e`)} = false
THEN 1 ELSE 0 END)::int AS "inDate",
SUM(CASE
WHEN ${estimatedSql(sql`e`)} = false AND ${isExpiredSql(sql`e`)} = true
THEN 1 ELSE 0 END)::int AS expired,
SUM(CASE WHEN ${isExpiredSql(sql`e`)} = true THEN 1 ELSE 0 END)::int AS expired,
SUM(CASE WHEN ${withoutEpcSql(sql`e`)} = true THEN 1 ELSE 0 END)::int AS "noCertificate",
-- Provenance, NOT part of the coverage partition spans the predicted
-- slot (an expired-slot home is estimated AND expired). Feeds the
-- "Estimated EPCs" issue row only.
SUM(CASE WHEN ${estimatedSql(sql`e`)} = true THEN 1 ELSE 0 END)::int AS estimated,
SUM(CASE WHEN ${likelyDowngradeSql} THEN 1 ELSE 0 END)::int AS "likelyDowngrades",
SUM(CASE WHEN ${likelyUpgradeSql} THEN 1 ELSE 0 END)::int AS "likelyUpgrades"

View file

@ -83,8 +83,13 @@ export interface ScenarioConfig {
/** Evidence composition + band-movement counts for the data-quality page. */
export type DataQualityMetrics = {
total: number;
/** Coverage partition (sums to total): a current certificate. */
inDate: number;
/** Coverage: a certificate of either kind, out of date (lodged >10y OR expired-slot). */
expired: number;
/** Coverage: no certificate of any kind (predicted-only or no EPC record). */
noCertificate: number;
/** Provenance (not part of the coverage partition): the picture was modelled. */
estimated: number;
likelyDowngrades: number;
likelyUpgrades: number;