mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
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:
parent
fe91f002a7
commit
bc2cde3984
6 changed files with 164 additions and 35 deletions
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
81
src/lib/reporting/epcEvidence.test.ts
Normal file
81
src/lib/reporting/epcEvidence.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
43
src/lib/reporting/epcEvidence.ts
Normal file
43
src/lib/reporting/epcEvidence.ts
Normal 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";
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue