This commit is contained in:
KhalimCK 2026-07-27 17:07:51 +00:00 committed by GitHub
commit c19cb9501a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 863 additions and 125 deletions

View file

@ -9,11 +9,13 @@ import {
effectiveEpcBandSql,
estimatedSql,
isExpiredSql,
withoutEpcSql,
likelyDowngradeSql,
likelyUpgradeSql,
provenanceSignalSql,
} from "@/lib/services/epcSources";
import { EPC_BANDS } from "@/lib/epc/bands";
import { BAND_MIN_SAP } from "@/lib/epc/thresholds";
import { tagFilterCondition } from "@/lib/reporting/tagFilterSql";
import { parseReportingViewState } from "@/lib/reporting/viewState";
@ -29,6 +31,7 @@ const FILTERS = [
"band",
"estimated",
"expired",
"no-certificate",
"likely-downgrade",
"likely-upgrade",
"measure",
@ -77,10 +80,75 @@ export async function GET(
MAX_PAGE_SIZE,
);
// A band drill inside a scenario view must match the reporting ladder's
// post-scenario bar, not the effective baseline — else clicking "Band C"
// lists the baseline-C homes rather than the ones the scenario lands in C.
// Honoured only for a concrete scenario id (a bare band drill and the
// recommended/default view stay on the effective baseline).
const scenarioBandDrill =
filter === "band" && scenarioId != null && /^\d+$/.test(scenarioId);
// Latest plan for each property in the drilled scenario (created_at DESC).
const scenarioPlanJoin = scenarioBandDrill
? sql`LEFT JOIN LATERAL (
SELECT id, post_sap_points, post_energy_bill, post_co2_emissions
FROM plan
WHERE plan.property_id = p.id
AND plan.portfolio_id = ${pid}
AND plan.scenario_id = ${BigInt(scenarioId!)}
ORDER BY created_at DESC
LIMIT 1
) sp ON true`
: sql``;
// Post-scenario SAP for banding — twin of overlay.ts `band_sap`: a retrofit
// can't make a home worse, and an untouched home keeps its effective
// baseline. GREATEST folds both (it ignores the NULL post_sap of a home with
// no plan). Round to the whole-number rating before banding (twin of
// sapBandBucketsSql / sapToBand): 68.5 is a 69 rating → C, not D.
const afterSap = sql`GREATEST((${effectiveSapSql}), sp.post_sap_points)`;
const afterRating = sql`ROUND((${afterSap})::numeric)`;
// The drilled band's rounded-rating range, mirroring sapBandBucketsSql's
// half-open buckets. Only built (and only valid) when `band` is set.
const scenarioBandRange = (() => {
if (!scenarioBandDrill || !band) return sql``;
const idx = EPC_BANDS.indexOf(band as (typeof EPC_BANDS)[number]);
const min = BAND_MIN_SAP[band as keyof typeof BAND_MIN_SAP];
if (idx === 0) return sql`${afterRating} >= ${min}`;
const upper = BAND_MIN_SAP[EPC_BANDS[idx - 1] as keyof typeof BAND_MIN_SAP];
if (idx === EPC_BANDS.length - 1) {
return sql`${afterRating} IS NOT NULL AND ${afterRating} < ${upper}`;
}
return sql`${afterRating} >= ${min} AND ${afterRating} < ${upper}`;
})();
// Display columns reflect the scenario "after" state for a scenario band
// drill (every listed home is in `band` by construction), else the baseline.
const epcBandCol = scenarioBandDrill
? sql`${band}::text`
: sql`(${effectiveEpcBandSql})::text`;
const sapCol = scenarioBandDrill
? sql`(${afterSap})::float`
: sql`(${effectiveSapSql})::float`;
const billsCol = scenarioBandDrill
? sql`(CASE WHEN sp.id IS NOT NULL THEN sp.post_energy_bill ELSE (${billsSql(sql`e`)}) END)::float`
: sql`(${billsSql(sql`e`)})::float`;
const carbonCol = scenarioBandDrill
? sql`(CASE WHEN sp.id IS NOT NULL THEN sp.post_co2_emissions ELSE (${carbonSql(sql`e`)}) END)::float`
: sql`(${carbonSql(sql`e`)})::float`;
const filterSql = {
band: sql`COALESCE((${effectiveEpcBandSql})::text, 'Unknown') = ${band}`,
band: scenarioBandDrill
? scenarioBandRange
: 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`,
// Coverage: no certificate of any kind (the #393 "without an EPC" homes).
"no-certificate": sql`${withoutEpcSql(sql`e`)} = true`,
"likely-downgrade": likelyDowngradeSql,
"likely-upgrade": likelyUpgradeSql,
measure: sql`EXISTS (
@ -106,15 +174,16 @@ export async function GET(
p.id,
COALESCE(p.address, p.user_inputted_address) AS address,
COALESCE(p.postcode, p.user_inputted_postcode) AS postcode,
(${effectiveEpcBandSql})::text AS epc_band,
(${effectiveSapSql})::float AS sap,
(${billsSql(sql`e`)})::float AS bills,
(${carbonSql(sql`e`)})::float AS carbon,
${epcBandCol} AS epc_band,
${sapCol} AS sap,
${billsCol} AS bills,
${carbonCol} AS carbon,
${provenanceSignalSql} AS provenance,
COUNT(*) OVER()::int AS total
FROM property p
LEFT JOIN property_details_epc e ON e.property_id = p.id
${newApproachJoins}
${scenarioPlanJoin}
WHERE p.portfolio_id = ${pid}
AND ${tagFilterCondition(parseReportingViewState(params).tags)}
AND ${filterSql}

View file

@ -313,6 +313,41 @@ function HomesCount({
);
}
/** Segmented control switching the EPC distribution between the modelled
* Effective bands and the register's Lodged bands. */
function EpcViewToggle({
value,
onChange,
}: {
value: "effective" | "lodged";
onChange: (v: "effective" | "lodged") => void;
}) {
return (
<div
className="inline-flex rounded-md border border-gray-200 p-0.5"
role="tablist"
aria-label="EPC distribution basis"
>
{(["effective", "lodged"] as const).map((v) => (
<button
key={v}
type="button"
role="tab"
aria-selected={value === v}
onClick={() => onChange(v)}
className={`rounded px-2.5 py-1 text-[0.72rem] font-medium capitalize transition-colors ${
value === v
? "bg-brandblue text-white"
: "text-gray-600 hover:text-gray-900"
}`}
>
{v}
</button>
))}
</div>
);
}
// ── Metrics body ─────────────────────────────────────────────────────────────
function MetricsBody({
@ -341,6 +376,10 @@ function MetricsBody({
const [drill, setDrill] = useState<DrillTarget | null>(null);
const [drillPage, setDrillPage] = useState(1);
// EPC-distribution basis: the modelled Effective bands (default, with any
// scenario overlay + drill) or the register's Lodged bands (real certificates
// only). A display toggle — local state, not URL-persisted.
const [epcView, setEpcView] = useState<"effective" | "lodged">("effective");
const segment = scenarioIdSegment(view);
const isScenario = segment !== null;
@ -438,9 +477,12 @@ function MetricsBody({
),
},
(() => {
// Round both endpoints first so the delta pill agrees with the
// rounded SAP figures shown here and in the sub (a 67 → 71 tile must
// read +4, not +3 off the raw averages).
const d = shapeKpiDelta({
current: avg.avg_sap ?? 0,
after: Number(scenarioData.avg_sap),
current: Math.round(avg.avg_sap ?? 0),
after: Math.round(Number(scenarioData.avg_sap)),
improvesWhenLower: false,
});
return {
@ -575,43 +617,87 @@ function MetricsBody({
<div className="grid grid-cols-1 items-stretch gap-4 lg:grid-cols-[1.55fr_1fr]">
<Panel
title="EPC distribution"
action={<EpcViewToggle value={epcView} onChange={setEpcView} />}
meta={
isScenario
? "Current vs scenario · click a band for its homes"
: "Effective performance · click a band for its homes"
epcView === "lodged"
? "Lodged register certificates"
: isScenario
? "Current vs scenario · click a band for its homes"
: "Effective performance · click a band for its homes"
}
>
<div className="mb-4">
<EpcLadder
bands={baseline.epcBands}
scenarioBands={scenarioBands}
selectedBand={drill?.filter === "band" ? drill.band : null}
onSelectBand={(band) =>
openDrill({
filter: "band",
band,
chipBand: band,
title: `Band ${band}`,
})
bands={
epcView === "lodged" ? baseline.lodgedBands : baseline.epcBands
}
scenarioBands={epcView === "lodged" ? undefined : scenarioBands}
selectedBand={
epcView === "effective" && drill?.filter === "band"
? drill.band
: null
}
onSelectBand={
epcView === "effective"
? (band) =>
openDrill({
filter: "band",
band,
chipBand: band,
title: `Band ${band}`,
// In a scenario view, drill the post-scenario bar (the
// homes the scenario lands in this band), not the
// effective baseline. A number view is a concrete
// scenario; current-stock/recommended stay baseline.
...(typeof view === "number"
? { scenarioId: view }
: {}),
})
: undefined
}
/>
</div>
<GoalCallout
callout={callout}
total={total}
dimensionTotals={{ carbon: baseline.totals.total_carbon ?? 0 }}
onDrill={
callout.kind === "below-band"
? () =>
openDrill({
filter: "band",
band: callout.band,
chipBand: callout.band,
title: `Below EPC ${callout.band}`,
})
: undefined
}
/>
{epcView === "lodged" ? (
<div className="rounded-lg bg-[#f6f7fb] px-4 py-3 text-[0.8rem] text-gray-600">
<b className="text-gray-900">{total.toLocaleString()}</b> homes
{baseline.lodgedNoCertificate > 0 && (
<>
{" · "}
<b className="text-gray-900">
{baseline.lodgedNoCertificate.toLocaleString()}
</b>{" "}
have some estimation
{baseline.noCertificate > 0 && (
<>
, of which{" "}
<b className="text-gray-900">
{baseline.noCertificate.toLocaleString()}
</b>{" "}
never had an EPC
</>
)}
</>
)}
.
</div>
) : (
<GoalCallout
callout={callout}
total={total}
dimensionTotals={{ carbon: baseline.totals.total_carbon ?? 0 }}
onDrill={
callout.kind === "below-band"
? () =>
openDrill({
filter: "band",
band: callout.band,
chipBand: callout.band,
title: `Below EPC ${callout.band}`,
})
: undefined
}
/>
)}
</Panel>
{isScenario && ledger ? (
@ -757,6 +843,13 @@ function ConfidenceStrip({
onDrill({ filter: "estimated", title: "Estimated EPCs" })
}
/>
<Item
label="no certificate"
value={baseline.noCertificate}
onClick={() =>
onDrill({ filter: "no-certificate", title: "Homes without a certificate" })
}
/>
<Item
label="expired"
value={baseline.expiredEpcs}

View file

@ -10,12 +10,13 @@ import {
} from "@/app/shadcn_components/ui/popover";
import { sapToEpc } from "@/app/utils";
import {
pickBestIndex,
pickBestIndices,
countBelowBand,
amountSaved,
capitalOutlay,
costPerSapPoint,
costPerCarbonSaved,
formatTonnes,
} from "@/lib/reporting/model";
import { EpcChip, moneyFull } from "../components/primitives";
@ -41,6 +42,13 @@ interface RowSpec {
direction: "lower" | "higher" | null;
value: (m: ScenarioMetrics, b: Baseline) => number | null;
render: (v: number | null) => React.ReactNode;
/**
* Suppress the "Best" tag when the winning value is 0 for fields where a
* zero isn't an achievement (e.g. Funding secured: £0 means none was secured,
* so crowning it "Best" is meaningless). Fields where 0 *is* a genuine best
* (Homes below C after) leave this off.
*/
hideBestAtZero?: boolean;
}
interface ScenarioMetrics {
@ -61,9 +69,12 @@ interface ScenarioMetrics {
const ROWS: RowSpec[] = [
{
group: "Outcome",
// Compare on the rounded SAP the cell shows, so two columns that both
// display e.g. 71 register as a genuine tie (both marked Best) rather than
// the tag hanging on a sub-point difference the reader can't see.
label: "Average EPC after",
direction: "higher",
value: (m) => Number(m.avg_sap),
value: (m) => Math.round(Number(m.avg_sap)),
render: (v) =>
v === null ? "—" : (
<span className="inline-flex items-center gap-1.5">
@ -87,10 +98,12 @@ const ROWS: RowSpec[] = [
},
{
group: "Outcome",
// One decimal (shared formatTonnes) so two scenarios that both round to
// "2 t" but differ — the reason one is Best — are visibly distinct.
label: "CO₂ saved /yr",
direction: "higher",
value: (m, b) => amountSaved(b.totalCarbon, m.total_carbon),
render: (v) => (v === null ? "—" : `${Math.round(v)} t`),
render: (v) => (v === null ? "—" : `${formatTonnes(v)} t`),
},
{
group: "Outcome",
@ -112,6 +125,7 @@ const ROWS: RowSpec[] = [
direction: "higher",
value: (m) => m.total_funding,
render: (v) => (v === null ? "—" : moneyFull(v)),
hideBestAtZero: true,
},
{
group: "Investment",
@ -316,8 +330,8 @@ export function CompareClientArea({
</div>
<p className="mt-2.5 text-[0.75rem] text-gray-600">
The <span className="font-semibold text-[#0c6b4a]">Best</span> tag marks
the best value in each row no overall winner is crowned; trade-offs
stay visible.
the best value in each row or every column that ties for it. No overall
winner is crowned; trade-offs stay visible.
</p>
</div>
);
@ -351,8 +365,20 @@ function GroupRows({
);
const best =
row.direction === null
? null
: pickBestIndex(scenarioValues, row.direction);
? new Set<number>()
: pickBestIndices(scenarioValues, row.direction);
// A zero winner isn't an achievement on some fields (e.g. no funding
// secured) — drop the tag there rather than crowning £0.
if (row.hideBestAtZero) {
const anyWinner = best.values().next().value;
if (anyWinner !== undefined && (scenarioValues[anyWinner] ?? 0) === 0) {
best.clear();
}
}
// More than one joint-best column = a genuine tie (e.g. two scenarios
// upgrading the same 7 homes). Flag it on each tag so the reader knows
// the shared Best is intentional, not a bug.
const tie = best.size > 1;
// "before" cell: baseline has no scenario metric, so most rows show —.
const baselineCell =
row.label === "Average EPC after"
@ -370,12 +396,12 @@ function GroupRows({
<td
key={i}
className={`px-4 py-2.5 text-right tabular-nums ${
best === i ? "font-bold text-[#0c6b4a]" : "text-gray-900"
best.has(i) ? "font-bold text-[#0c6b4a]" : "text-gray-900"
}`}
>
{best === i && (
{best.has(i) && (
<span className="mr-1.5 inline-flex items-center rounded bg-[#e9f4ef] px-1.5 py-0.5 align-middle text-[0.6rem] font-bold uppercase tracking-wide text-[#0c6b4a]">
Best
{tie ? "Best · tie" : "Best"}
</span>
)}
{row.render(v)}

View file

@ -21,6 +21,7 @@ export interface DrillTarget {
| "band"
| "estimated"
| "expired"
| "no-certificate"
| "likely-downgrade"
| "likely-upgrade"
| "measure";

View file

@ -14,7 +14,7 @@ import {
} from "lucide-react";
import { InfoDot, moneyFull } from "./primitives";
import { COST_HELP } from "./costHelp";
import { carsOffTheRoad, type LedgerView } from "@/lib/reporting/model";
import { formatTonnes, type LedgerView } from "@/lib/reporting/model";
// LedgerView now lives in the reporting model (derived by deriveLedgerView);
// re-exported here so existing imports from this component keep resolving.
@ -196,8 +196,7 @@ export function InvestmentLedger({ v }: { v: LedgerView }) {
<IconRow
icon={Leaf}
label="Carbon saved"
value={`${Math.round(v.carbonSaved)} t`}
note={`${carsOffTheRoad(v.carbonSaved)} cars`}
value={`${formatTonnes(v.carbonSaved)} t`}
tip={COST_HELP.carbonSaved}
tone="benefit"
/>
@ -218,6 +217,15 @@ export function InvestmentLedger({ v }: { v: LedgerView }) {
value={moneyFull(v.costPerCarbon)}
tip={COST_HELP.costPerCarbon}
/>
{/* Print-only: on screen the InfoDots carry this, but buttons are hidden
in print, so the printed report would otherwise show the ratios with
no basis. States what they're divided by and that it's portfolio-wide,
not per home. */}
<p className="mt-2 hidden text-[0.66rem] leading-snug text-gray-500 print:block">
Capital cost (construction works + project delivery) ÷ the total SAP
points, and tonnes of CO saved a year, across all upgraded homes a
portfolio total, not per home. Lower is better.
</p>
</div>
</div>
);

View file

@ -39,6 +39,7 @@ export function DataQualityClientArea({
total,
inDate,
expired,
noCertificate,
estimated,
likelyDowngrades,
likelyUpgrades,
@ -50,17 +51,26 @@ export function DataQualityClientArea({
setPage(1);
}
// 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" },
{
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.",
@ -69,10 +79,19 @@ 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,
},
{
key: "no-certificate" as const,
name: "Homes without a certificate",
def: "No certificate of any kind — never lodged, and no historical record to fall back on.",
count: noCertificate,
impact:
"These homes have never been assessed; their SAP, bills and carbon are predicted blind from nearby homes.",
good: false,
},
{

View file

@ -8,7 +8,7 @@ import {
import { measureLabel } from "@/lib/reporting/measures";
import {
buildHeadline,
carsOffTheRoad,
formatTonnes,
countBelowBand,
shapeKpiDelta,
toBandCounts,
@ -162,9 +162,11 @@ function Delta({ text, improved }: { text: string; improved: boolean }) {
function EpcDistribution({
bandCounts,
scenarioBands,
caption,
}: {
bandCounts: Record<string, number>;
scenarioBands?: Record<string, number>;
caption?: string;
}) {
const bands = EPC_BANDS.filter(
(b) => (bandCounts[b] ?? 0) > 0 || (scenarioBands?.[b] ?? 0) > 0,
@ -175,6 +177,14 @@ function EpcDistribution({
);
return (
<div className="flex flex-col gap-2">
{/* Label the right-hand column so a skimmer reads "9 → 15" as homes. */}
<div className="grid grid-cols-[24px_1fr_96px] items-center gap-x-3">
<span />
<span />
<span className="text-right text-[0.58rem] font-semibold uppercase tracking-wide text-gray-400">
Homes
</span>
</div>
{bands.map((band) => {
const before = bandCounts[band] ?? 0;
const after = scenarioBands?.[band];
@ -209,9 +219,10 @@ function EpcDistribution({
);
})}
<p className="mt-1 text-[0.68rem] text-gray-500">
{scenarioBands
? "Bar = current stock (band colour); line beneath = after scenario (navy)."
: "Homes by current effective EPC band."}
{caption ??
(scenarioBands
? "Bar = current stock (band colour); line beneath = after scenario (navy)."
: "Homes by current effective EPC band.")}
</p>
</div>
);
@ -316,6 +327,72 @@ function RecommendedBrief({ className = "" }: { className?: string }) {
);
}
/**
* Report page 2 the register (lodged) EPC distribution beside the modelled
* (effective) one, so a reader can see how the certificated position compares
* with the modelled reality. `page-break` starts it on a fresh sheet.
*/
function LodgedComparisonPage({
title,
subtitle,
effectiveBands,
lodgedBands,
lodgedNoCertificate,
noCertificate,
total,
}: {
title: string;
subtitle: string;
effectiveBands: Record<string, number>;
lodgedBands: Record<string, number>;
lodgedNoCertificate: number;
noCertificate: number;
total: number;
}) {
return (
<div className="page-break space-y-3 pt-8 print:pt-0">
<Cover title={title} subtitle={subtitle} />
<SectionTitle>EPC distribution register vs modelled</SectionTitle>
<p className="max-w-[62ch] text-[0.88rem] leading-relaxed text-gray-600">
The portfolio&apos;s lodged EPC certificates from the register, beside
the modelled effective ratings the rest of this report is based on.
</p>
<div className="grid grid-cols-2 items-start gap-6">
<section className="avoid-break">
<h3 className="mb-2 text-[0.82rem] font-semibold text-[#14163d]">
Lodged (register)
</h3>
<EpcDistribution
bandCounts={lodgedBands}
caption="Homes by lodged (register) EPC band."
/>
<p className="mt-2 text-[0.72rem] text-gray-500">
{total.toLocaleString()} homes
{lodgedNoCertificate > 0
? ` · ${lodgedNoCertificate.toLocaleString()} have some estimation${
noCertificate > 0
? `, of which ${noCertificate.toLocaleString()} never had an EPC`
: ""
}`
: ""}
.
</p>
</section>
<section className="avoid-break">
<h3 className="mb-2 text-[0.82rem] font-semibold text-[#14163d]">
Effective (modelled)
</h3>
<EpcDistribution
bandCounts={effectiveBands}
caption="Homes by effective (modelled) EPC band."
/>
</section>
</div>
<Footer />
</div>
);
}
function Footer() {
return (
<footer className="mt-2 border-t border-gray-200 pt-3 text-[0.64rem] leading-relaxed text-gray-400">
@ -413,6 +490,16 @@ export default async function ReportingPdfPage(props: {
</section>
<Footer />
<LodgedComparisonPage
title="Current stock"
subtitle={portfolioName}
effectiveBands={bandCounts}
lodgedBands={toBandCounts(baseline.lodgedBands)}
lodgedNoCertificate={baseline.lodgedNoCertificate}
noCertificate={baseline.noCertificate}
total={total}
/>
</div>
</div>
);
@ -437,7 +524,7 @@ export default async function ReportingPdfPage(props: {
totalCarbon: baseline.totals.total_carbon,
totalBills: baseline.totals.total_bills,
});
// Bound for the headline + the "cars off the road" / CO₂-saved tiles below.
// Bound for the headline + the CO₂-saved tile below.
const carbonSaved = ledger.carbonSaved;
const headline = buildHeadline({
@ -453,9 +540,11 @@ export default async function ReportingPdfPage(props: {
const belowCBefore = countBelowBand(bandCounts, "C");
const belowCAfter = countBelowBand(scenarioBands, "C");
// Derive the delta from the same rounded endpoints the tile displays, so the
// pill agrees with them (67 → 71 must read +4, not +3 off the raw 70.6 67.4).
const sapDelta = shapeKpiDelta({
current: avg.avg_sap ?? 0,
after: Number(scenarioData.avg_sap),
current: Math.round(avg.avg_sap ?? 0),
after: Math.round(Number(scenarioData.avg_sap)),
improvesWhenLower: false,
});
@ -501,7 +590,7 @@ export default async function ReportingPdfPage(props: {
<b>{belowCAfter.toLocaleString()}</b>
</span>
}
sub={`${carsOffTheRoad(carbonSaved)} cars off the road`}
sub={`${Math.max(belowCBefore - belowCAfter, 0).toLocaleString()} moved to C or above`}
/>
<Tile
label="Bills saved / yr"
@ -510,7 +599,7 @@ export default async function ReportingPdfPage(props: {
/>
<Tile
label="Carbon saved / yr"
value={`${formatNumber(carbonSaved)} t`}
value={`${formatTonnes(carbonSaved)} t`}
sub="vs current stock"
/>
<Tile
@ -549,6 +638,16 @@ export default async function ReportingPdfPage(props: {
</div>
<Footer />
<LodgedComparisonPage
title={title}
subtitle={portfolioName}
effectiveBands={bandCounts}
lodgedBands={toBandCounts(baseline.lodgedBands)}
lodgedNoCertificate={baseline.lodgedNoCertificate}
noCertificate={baseline.noCertificate}
total={total}
/>
</div>
</div>
);

View file

@ -24,6 +24,18 @@ describe("sapToBand", () => {
expect(sapToBand(20)).toBe("G");
});
it("bands the SAP rating — the score rounded to a whole number (RdSAP)", () => {
// A fractional post_sap just under a floor rounds UP into the better band:
// 68.5+ is a 69 rating → C, not D. Real modelled scores from scenario 1328.
expect(sapToBand(68.5)).toBe("C");
expect(sapToBand(68.84)).toBe("C");
expect(sapToBand(68.64)).toBe("C");
// …and just below the midpoint rounds DOWN and stays in the worse band.
expect(sapToBand(68.49)).toBe("D");
expect(sapToBand(54.5)).toBe("D"); // 55 rating → D floor
expect(sapToBand(54.49)).toBe("E");
});
it("returns Unknown for null and throws for negatives", () => {
expect(sapToBand(null)).toBe("Unknown");
expect(() => sapToBand(-1)).toThrow();

View file

@ -42,12 +42,19 @@ export const EPC_TO_SAP_MAX: Record<EpcBand, number> = Object.fromEntries(
* Maps a SAP score to its EPC band. `null` "Unknown"; a negative score
* throws (SAP is never negative surface the bad datum rather than bucket
* it into G). Mirrored in SQL by `sapBandBucketsSql`.
*
* The band is taken from the SAP *rating* the score rounded to a whole
* number (RdSAP convention). A modelled `post_sap` of 68.5 is a 69 rating and
* therefore band C, not D: without the round it would sit a hair under the
* integer C floor and be mis-banded. Round half up (matches Postgres
* `ROUND(numeric)` in the SQL twin) over the non-negative SAP domain.
*/
export function sapToBand(sapPoints: number | null): EpcBand | "Unknown" {
if (sapPoints === null) return "Unknown";
if (sapPoints < 0) throw new Error("SAP points should be above 0.");
const rating = Math.round(sapPoints);
for (const band of EPC_BANDS) {
if (sapPoints >= BAND_MIN_SAP[band]) return band;
if (rating >= BAND_MIN_SAP[band]) return band;
}
return "G";
}

View file

@ -19,13 +19,20 @@ import { BAND_MIN_SAP } from "./thresholds";
* `sql\`band_sap\``), not a subquery.
*/
export function sapBandBucketsSql(sapExpr: SQL): SQL {
// Band from the SAP *rating* — the score rounded to a whole number (RdSAP
// convention, twin of sapToBand). A modelled post_sap of 68.5 is a 69 rating
// and therefore band C, not D. ROUND on numeric is half-away-from-zero, so it
// matches JS Math.round over the non-negative SAP domain; the ::numeric cast
// avoids double-precision round-half-to-even. NULL rounds to NULL.
const rating = sql`ROUND((${sapExpr})::numeric)`;
const bandCols = EPC_BANDS.map((band, i) => {
const min = BAND_MIN_SAP[band];
const alias = sql.raw(`band_${band.toLowerCase()}`);
// Best band (A): open-topped, everything at or above its floor.
if (i === 0) {
return sql`COUNT(*) FILTER (WHERE ${sapExpr} >= ${min})::int AS ${alias}`;
return sql`COUNT(*) FILTER (WHERE ${rating} >= ${min})::int AS ${alias}`;
}
const upper = BAND_MIN_SAP[EPC_BANDS[i - 1]];
@ -33,16 +40,16 @@ export function sapBandBucketsSql(sapExpr: SQL): SQL {
// Worst band (G): its floor is 0, so guard NULL explicitly rather than
// relying on the `>= 0` comparison to exclude it.
if (i === EPC_BANDS.length - 1) {
return sql`COUNT(*) FILTER (WHERE ${sapExpr} IS NOT NULL AND ${sapExpr} < ${upper})::int AS ${alias}`;
return sql`COUNT(*) FILTER (WHERE ${rating} IS NOT NULL AND ${rating} < ${upper})::int AS ${alias}`;
}
return sql`COUNT(*) FILTER (WHERE ${sapExpr} >= ${min} AND ${sapExpr} < ${upper})::int AS ${alias}`;
return sql`COUNT(*) FILTER (WHERE ${rating} >= ${min} AND ${rating} < ${upper})::int AS ${alias}`;
});
return sql.join(
[
...bandCols,
sql`COUNT(*) FILTER (WHERE ${sapExpr} IS NULL)::int AS band_unknown`,
sql`COUNT(*) FILTER (WHERE ${rating} IS NULL)::int AS band_unknown`,
],
sql`,\n `,
);

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

@ -9,8 +9,10 @@ import {
costPerCarbonSaved,
costPerSapPoint,
deriveLedgerView,
formatTonnes,
isCompliantBeyondWindow,
pickBestIndex,
pickBestIndices,
planCountsAsUpgrade,
selectGoalCallout,
shapeKpiDelta,
toBandCounts,
@ -280,24 +282,32 @@ describe("classifyBandMovement", () => {
});
});
describe("pickBestIndex", () => {
describe("pickBestIndices", () => {
// Compare view (Screen D): mark the best value in each row without
// crowning an overall winner. Nulls (baseline / missing) never win.
it("picks the lowest for a lower-is-better row", () => {
expect(pickBestIndex([null, 50, 74, 103], "lower")).toBe(1);
expect(pickBestIndices([null, 50, 74, 103], "lower")).toEqual(new Set([1]));
});
it("picks the highest for a higher-is-better row", () => {
expect(pickBestIndex([null, 312, 368, 219], "higher")).toBe(2);
expect(pickBestIndices([null, 312, 368, 219], "higher")).toEqual(
new Set([2]),
);
});
it("returns null when every comparable value is missing", () => {
expect(pickBestIndex([null, null], "lower")).toBeNull();
it("marks every column that ties on the best value", () => {
expect(pickBestIndices([null, 71, 71, 68], "higher")).toEqual(
new Set([1, 2]),
);
});
it("returns an empty set when every comparable value is missing", () => {
expect(pickBestIndices([null, null], "lower")).toEqual(new Set());
});
it("ignores nulls rather than treating them as zero", () => {
expect(pickBestIndex([null, null, 5], "lower")).toBe(2);
expect(pickBestIndices([null, null, 5], "lower")).toEqual(new Set([2]));
});
});
@ -449,4 +459,81 @@ describe("buildHeadline", () => {
}),
).toBe("Improving valuation across 187 homes costs £850k net.");
});
it("uses singular 'car' and one-decimal tonnes for a small saving", () => {
expect(
buildHeadline({
goal: "Increasing EPC",
goalValue: "C",
homesUpgraded: 7,
netCost: 42_916,
carbonSavedPerYear: 2.2,
}),
).toBe(
"Reaching EPC C across 7 homes costs £43k net and cuts carbon by 2.2 tonnes a year — like taking 1 car off the road.",
);
});
});
describe("planCountsAsUpgrade", () => {
// The pure twin of the ledger's plan gating — the measures breakdown must
// apply this identically so its total reconciles to the Construction works
// line (CONTEXT.md). Guards against the reporting divergence where the
// breakdown counted plans the ledger excludes.
const genuine = {
costOfWorks: 5000,
postSap: 72,
effectiveSap: 67,
effectiveBand: "D",
targetBand: "C",
};
it("counts a costed plan that lifts the home above baseline toward target", () => {
expect(planCountsAsUpgrade(genuine)).toBe(true);
});
it("excludes a plan with no costed works", () => {
expect(planCountsAsUpgrade({ ...genuine, costOfWorks: 0 })).toBe(false);
});
it("excludes a plan whose post-SAP sits below the effective baseline", () => {
// The Brockley-Flats case: 3 homes with costed plans that don't actually
// improve the home — the £2,828 the breakdown over-counted vs the ledger.
expect(
planCountsAsUpgrade({ ...genuine, postSap: 60, effectiveSap: 67 }),
).toBe(false);
});
it("excludes a home already at the target band (Model#1652 guard)", () => {
expect(
planCountsAsUpgrade({
...genuine,
postSap: 75,
effectiveSap: 72,
effectiveBand: "C",
targetBand: "C",
}),
).toBe(false);
});
it("keeps a plan when the baseline SAP is unknown (NULL comparison)", () => {
expect(
planCountsAsUpgrade({ ...genuine, effectiveSap: null, effectiveBand: null }),
).toBe(true);
});
it("applies only the cost + post-SAP gates when there is no target band", () => {
expect(planCountsAsUpgrade({ ...genuine, targetBand: null })).toBe(true);
expect(
planCountsAsUpgrade({ ...genuine, targetBand: null, postSap: 60 }),
).toBe(false);
});
});
describe("formatTonnes", () => {
it("keeps one decimal but strips a trailing .0", () => {
expect(formatTonnes(2.2)).toBe("2.2");
expect(formatTonnes(2)).toBe("2");
expect(formatTonnes(312)).toBe("312");
});
});

View file

@ -168,6 +168,53 @@ export function deriveLedgerView(
};
}
/* ------------------------------------------------------------------
Plan gating what counts toward a scenario's construction cost
------------------------------------------------------------------ */
export interface UpgradePlan {
/** The plan's costed works (£). */
costOfWorks: number;
/** Modelled post-retrofit SAP, or null when unknown. */
postSap: number | null;
/** The home's effective (re-baselined) SAP, or null when unknown. */
effectiveSap: number | null;
/** The home's effective EPC band, or null when unknown. */
effectiveBand: string | null;
/** The scenario's target band, or null for non-EPC goals / recommended view. */
targetBand: string | null;
}
/**
* Whether a plan's works count toward the scenario's construction cost the
* pure twin of the Investment ledger's plan gating (`upgradedCostsSql` in
* overlay.ts, and `stillNeedsUpgradeSql` in epcSources). Both the ledger and
* the "where the money goes" measures breakdown must apply this identically, so
* the breakdown total reconciles to the Construction works line (CONTEXT.md).
*
* A plan counts iff it has costed works AND its modelled post-SAP is at least
* the home's effective baseline (a plan that doesn't lift the home above
* baseline isn't a real upgrade — ADR-0002) AND the home hasn't already met the
* scenario's target band (INTERIM guard for Model#1652). NULL comparisons keep
* the plan (mirrors the SQL's COALESCE(..., true)); bands compare lexically,
* A best.
*/
export function planCountsAsUpgrade(plan: UpgradePlan): boolean {
if (!(plan.costOfWorks > 0)) return false;
const meetsBaseline =
plan.postSap === null || plan.effectiveSap === null
? true
: plan.postSap >= plan.effectiveSap;
if (!meetsBaseline) return false;
return (
plan.targetBand === null ||
plan.effectiveBand === null ||
plan.effectiveBand > plan.targetBand
);
}
/* ------------------------------------------------------------------
Compliance window ADR-0010
------------------------------------------------------------------ */
@ -269,24 +316,30 @@ const GOAL_DIMENSIONS: Partial<Record<string, GoalDimension>> = {
};
/**
* Index of the best value in a compare row (Screen D). Nulls baseline
* columns and missing data never win. Returns null when nothing is
* comparable, so no cell is marked.
* Indices of the best value in a compare row (Screen D) *every* column that
* holds the joint-best value, so a tie marks all of them rather than silently
* crowning the leftmost. Nulls baseline columns and missing data never win.
* Returns an empty set when nothing is comparable, so no cell is marked.
*
* Ties are decided by exact equality: a caller that rounds for display (e.g.
* Average EPC whole SAP) should round the compared value too, so a tie the
* reader can see is a tie the tag agrees with.
*/
export function pickBestIndex(
export function pickBestIndices(
values: (number | null)[],
direction: "lower" | "higher",
): number | null {
let bestIndex: number | null = null;
): Set<number> {
let best = direction === "lower" ? Infinity : -Infinity;
for (const v of values) {
if (v === null) continue;
if (direction === "lower" ? v < best : v > best) best = v;
}
const winners = new Set<number>();
if (!Number.isFinite(best)) return winners; // every comparable value was null
values.forEach((v, i) => {
if (v === null) return;
if (direction === "lower" ? v < best : v > best) {
best = v;
bestIndex = i;
}
if (v === best) winners.add(i);
});
return bestIndex;
return winners;
}
/** Collapses baseline band rows (actual + estimated) into a plain band→count map. */
@ -365,6 +418,15 @@ export function carsOffTheRoad(tonnesPerYear: number): number {
return Math.round(tonnesPerYear / TONNES_CO2_PER_CAR_PER_YEAR);
}
/**
* Carbon tonnes for display one decimal, trailing ".0" stripped
* (2.2 "2.2", 2 "2"). The single carbon formatter shared by the headline,
* the KPI card and the ledger, so one saved figure never renders three ways.
*/
export function formatTonnes(tonnes: number): string {
return tonnes.toFixed(1).replace(/\.0$/, "");
}
export interface KpiDelta {
delta: number;
improved: boolean;
@ -418,20 +480,29 @@ export interface HeadlineInput {
*/
export function buildHeadline(input: HeadlineInput): string {
const money = formatMoneyCompact(input.netCost);
const tonnes = Math.round(input.carbonSavedPerYear);
const cars = carsOffTheRoad(input.carbonSavedPerYear);
const carClause = ` — like taking ${cars} cars off the road.`;
const tonnesNum = input.carbonSavedPerYear;
const hasCarbon = Math.round(tonnesNum) > 0;
const tonnes = formatTonnes(tonnesNum);
const cars = carsOffTheRoad(tonnesNum);
// The "cars off the road" analogy lives here in the headline only (the one
// board-quotable line) — not repeated on the tiles or ledger. Dropped when it
// rounds below one car, where "0 cars" would read as noise. Singular "car"
// for exactly one.
const carClause =
cars > 0
? ` — like taking ${cars} car${cars === 1 ? "" : "s"} off the road.`
: "";
if (input.goal === GOALS.EPC) {
const base = `Reaching EPC ${input.goalValue ?? DEFAULT_GOAL_BAND} across ${input.homesUpgraded} homes costs ${money} net`;
return tonnes > 0
return hasCarbon
? `${base} and cuts carbon by ${tonnes} tonnes a year${carClause}`
: `${base}.`;
}
if (input.goal === GOALS.CO2) {
const base = `Cutting carbon by ${tonnes} tonnes a year across ${input.homesUpgraded} homes costs ${money} net`;
return tonnes > 0 ? `${base}${carClause}` : `${base}.`;
return hasCarbon ? `${base}${carClause}` : `${base}.`;
}
const lead =
@ -439,7 +510,7 @@ export function buildHeadline(input: HeadlineInput): string {
? "Cutting energy use"
: "Improving valuation";
const base = `${lead} across ${input.homesUpgraded} homes costs ${money} net`;
return tonnes > 0
return hasCarbon
? `${base} and cuts carbon by ${tonnes} tonnes a year${carClause}`
: `${base}.`;
}

View file

@ -7,7 +7,7 @@ import {
carbonSql,
billsSql,
effectiveSapSql,
effectiveEpcBandSql,
stillNeedsUpgradeSql,
isNewApproachSql,
lodgedSapSql,
} from "@/lib/services/epcSources";
@ -202,18 +202,9 @@ export async function getScenarioOverlay(
? sql`AND plan.post_sap_points >= ${minSap}::float`
: sql``;
/**
* INTERIM guard for a backend bug (Hestia-Homes/Model#1652): the engine emits
* costed plans for homes ALREADY at the target band, inflating "Homes
* upgraded" and its costs. Exclude any home whose EFFECTIVE band already meets
* the target from the upgrade aggregate. Bands compare lexically (A best), so
* "still needs work" = effective band > target. NULL target no-op.
*/
const stillNeedsUpgrade: SQL = sql`(
${targetBand}::text IS NULL
OR (${effectiveEpcBandSql}) IS NULL
OR (${effectiveEpcBandSql})::text > ${targetBand}::text
)`;
// The Model#1652 upgrade guard (definition + rationale in epcSources) — shared
// with the measures breakdown (queryScenarioMeasures) so the two reconcile.
const stillNeedsUpgrade: SQL = stillNeedsUpgradeSql(targetBand);
/* --------------------------------------------------------
QUERY 1 Scenario metrics (PLANS ONLY): the works-list count and the

View file

@ -8,8 +8,11 @@ import {
energyConsumptionSql,
estimatedSql,
isExpiredSql,
withoutEpcSql,
effectiveSapSql,
effectiveEpcBandSql,
lodgedEpcBandSql,
stillNeedsUpgradeSql,
likelyDowngradeSql,
likelyUpgradeSql,
propertyTypeSql,
@ -75,8 +78,11 @@ export async function getBaselineAggregates(
averages: AverageMetrics;
totals: TotalMetrics;
epcBands: EpcBandCount[];
lodgedBands: EpcBandCount[];
lodgedNoCertificate: number;
estimatedCounts: EstimatedCounts;
expiredEpcs: number;
noCertificate: number;
}> {
const est = estimatedSql(sql`e`);
const bandExpr = sql`COALESCE((${effectiveEpcBandSql})::text, 'Unknown')`;
@ -94,6 +100,23 @@ export async function getBaselineAggregates(
sql`, `,
);
// Lodged (register) distribution — real certificates only, so no estimated
// split. `lodgedEpcBandSql` is NULL for homes without a real certificate;
// those are counted separately as `lodged_none` ("No certificate"), not in a
// band. Rides the same scan (epl/bp are already joined by newApproachJoins).
const lodgedBandExpr = sql`(${lodgedEpcBandSql})::text`;
const lodgedLetterBands = BASELINE_EPC_BANDS.filter((b) => b !== "Unknown");
const lodgedBandCols = sql.join(
[
...lodgedLetterBands.map(
(b) =>
sql`COUNT(*) FILTER (WHERE ${lodgedBandExpr} = ${b})::int AS ${sql.raw(`lodged_${b.toLowerCase()}`)}`,
),
sql`COUNT(*) FILTER (WHERE ${lodgedBandExpr} IS NULL)::int AS lodged_none`,
],
sql`, `,
);
const result = await db.execute<Record<string, number | null>>(sql`
SELECT
COUNT(*)::int AS total,
@ -105,13 +128,17 @@ 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,
${bandCols}
-- 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,
-- Coverage: no certificate of any kind (the #393 "without an EPC" figure),
-- distinct from the provenance estimated count above. Twin: classifyEpcEvidence.
SUM(CASE WHEN ${withoutEpcSql(sql`e`)} = true THEN 1 ELSE 0 END)::int AS no_certificate,
${bandCols},
${lodgedBandCols}
FROM property p
-- Scope the legacy (being-phased-out) table to this portfolio so its
-- (property_id, portfolio_id) index is used instead of a full seq scan of
@ -134,6 +161,18 @@ export async function getBaselineAggregates(
};
}).filter((band) => band.actual + band.estimated > 0);
// Lodged bands carry no estimated split (a certificate is a certificate);
// `estimated: 0` keeps the shared EpcBandCount shape so the ladder/PDF reuse.
const lodgedBands: EpcBandCount[] = BASELINE_EPC_BANDS.filter(
(b) => b !== "Unknown",
)
.map((b) => ({
epc: b,
actual: Number(row[`lodged_${b.toLowerCase()}`] ?? 0),
estimated: 0,
}))
.filter((band) => band.actual > 0);
return {
total: Number(row.total ?? 0),
averages: {
@ -147,11 +186,14 @@ export async function getBaselineAggregates(
total_bills: row.total_bills as number | null,
},
epcBands,
lodgedBands,
lodgedNoCertificate: Number(row.lodged_none ?? 0),
estimatedCounts: {
estimated: Number(row.estimated ?? 0),
actual: Number(row.actual ?? 0),
},
expiredEpcs: Number(row.expired ?? 0),
noCertificate: Number(row.no_certificate ?? 0),
};
}
@ -244,8 +286,11 @@ export async function loadBaselineMetrics(
totals: aggregates.totals,
ageBands,
epcBands: aggregates.epcBands,
lodgedBands: aggregates.lodgedBands,
lodgedNoCertificate: aggregates.lodgedNoCertificate,
estimatedCounts: aggregates.estimatedCounts,
expiredEpcs: aggregates.expiredEpcs,
noCertificate: aggregates.noCertificate,
likelyDowngrades,
};
}
@ -355,12 +400,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"
@ -384,6 +435,24 @@ export async function getDataQualityMetrics(
* measure_type only, since the UI buckets by category and never reads the
* `type` variant.
*/
/**
* The scenario's target band for the Model#1652 upgrade guard the goal value
* only for an EPC-target scenario (other goals / the recommended view have no
* target). Mirrors the overlay's targetBand derivation.
*/
async function resolveScenarioTargetBand(
portfolioId: bigint,
scenarioId: bigint,
): Promise<string | null> {
const res = await db.execute<{ goal: string; goal_value: string | null }>(sql`
SELECT goal, goal_value FROM scenario
WHERE id = ${scenarioId} AND portfolio_id = ${portfolioId}
LIMIT 1
`);
const row = res.rows[0];
return row && row.goal === "Increasing EPC" ? row.goal_value : null;
}
export async function queryScenarioMeasures(
portfolioId: number,
scenarioId: number | "default",
@ -398,26 +467,46 @@ export async function queryScenarioMeasures(
? sql`is_default = true`
: sql`scenario_id = ${BigInt(scenarioId)}`;
// Gate to the same "genuine upgrade" plans the Investment ledger's
// construction cost counts (overlay.ts upgradedCostsSql), so the breakdown
// total reconciles to the Construction works line rather than over-stating it
// with plans that don't lift the home above baseline or are already at target.
// Pure twin of the gate: planCountsAsUpgrade (@/lib/reporting/model).
const targetBand =
scenarioId === "default"
? null
: await resolveScenarioTargetBand(pid, BigInt(scenarioId));
const result = await db.execute<{
measure_type: string | null;
homes_count: number;
total_cost: number | null;
average_cost: number | null;
}>(sql`
SELECT
r.measure_type,
COUNT(DISTINCT r.property_id)::int AS homes_count,
SUM(r.estimated_cost)::float AS total_cost,
AVG(r.estimated_cost)::float AS average_cost
FROM (
WITH latest_plans AS (
SELECT DISTINCT ON (property_id)
id, property_id
id, property_id, cost_of_works, post_sap_points
FROM plan
WHERE portfolio_id = ${pid}
AND ${planScope}
AND ${tagFilterCondition(tags, sql`plan.property_id`)}
ORDER BY property_id, created_at DESC
) lp
),
upgrade_plans AS (
SELECT lp.id, lp.property_id
FROM latest_plans lp
JOIN property p ON p.id = lp.property_id
LEFT JOIN property_baseline_performance bp ON bp.property_id = p.id
WHERE lp.cost_of_works > 0
AND COALESCE(lp.post_sap_points >= (${effectiveSapSql}), true)
AND ${stillNeedsUpgradeSql(targetBand)}
)
SELECT
r.measure_type,
COUNT(DISTINCT r.property_id)::int AS homes_count,
SUM(r.estimated_cost)::float AS total_cost,
AVG(r.estimated_cost)::float AS average_cost
FROM upgrade_plans lp
JOIN recommendation r
ON r.plan_id = lp.id
AND r.default = true

View file

@ -44,8 +44,18 @@ export interface BaselineMetrics {
totals: TotalMetrics;
ageBands: AgeBandCount[];
epcBands: EpcBandCount[];
/**
* Distribution by *lodged* (register) band real certificates only, so the
* `estimated` split is always 0. Homes with no real lodged certificate are
* counted in `lodgedNoCertificate`, not here.
*/
lodgedBands: EpcBandCount[];
/** Homes with no real lodged certificate (predicted / expired / none). */
lodgedNoCertificate: number;
estimatedCounts: EstimatedCounts;
expiredEpcs: number;
/** Coverage: homes with no certificate of any kind (the #393 "without an EPC" figure). */
noCertificate: number;
likelyDowngrades: number;
}
@ -75,8 +85,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;

View file

@ -136,6 +136,26 @@ export const epcBandSql = sql`CASE WHEN ${isNewApproachSql} THEN bp.lodged_epc_b
export const effectiveSapSql = sql`CASE WHEN ${isNewApproachSql} THEN bp.effective_sap_score ELSE p.current_sap_points END`;
export const effectiveEpcBandSql = sql`CASE WHEN ${isNewApproachSql} THEN bp.effective_epc_band ELSE p.current_epc_rating END`;
/**
* "Still needs upgrading" the home hasn't already met the scenario's target
* band. INTERIM guard for backend bug Hestia-Homes/Model#1652 (the engine emits
* costed plans for homes ALREADY at target, inflating "Homes upgraded" and its
* costs). Bands compare lexically (A best), so "still needs work" = effective
* band worse than target; a NULL target (non-EPC goals / the recommended view)
* makes it inert. Requires `p` + `bp` in scope (newApproachJoins or a
* property_baseline_performance join).
*
* Shared by the Investment ledger (overlay.ts) and the "where the money goes"
* measures breakdown (server.ts) so the two reconcile. Pure twin:
* planCountsAsUpgrade (@/lib/reporting/model) folds this with the cost +
* post-SAP gates keep them in sync.
*/
export const stillNeedsUpgradeSql = (targetBand: string | null) => sql`(
${targetBand}::text IS NULL
OR (${effectiveEpcBandSql}) IS NULL
OR (${effectiveEpcBandSql})::text > ${targetBand}::text
)`;
/**
* Lodged EPC band for the "Lodged EPC" column only when a real certificate
* exists (`source = lodged`, i.e. the lodged epc_property row `epl`). A