mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-08-02 21:08:21 +00:00
Merge 216e3bb942 into 1d0733563d
This commit is contained in:
commit
c19cb9501a
18 changed files with 863 additions and 125 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export interface DrillTarget {
|
|||
| "band"
|
||||
| "estimated"
|
||||
| "expired"
|
||||
| "no-certificate"
|
||||
| "likely-downgrade"
|
||||
| "likely-upgrade"
|
||||
| "measure";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 `,
|
||||
);
|
||||
|
|
|
|||
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";
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}.`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue