feat(epc): provenance signals + effective-baseline portfolio table

Theme 1 — EPC card provenance. New resolveProvenanceSignal (pure, unit-
tested) + resolveProvenance expose provenanceSignal ("estimated" /
"remodelled" / "none") and the lodged headline on property-meta. The
building-passport card shows a "Lodged EPC" badge on re-modelled
properties (even within-band), never on predicted ones; the existing
"estimated from nearby homes" banner already covers predicted.

Theme 2 — portfolio table. "Current EPC" + reporting now read the
effective baseline (effectiveSapSql/effectiveEpcBandSql); a dedicated
"Lodged EPC" column reads the source-gated lodged value (blank for
predicted); a "Predicted" pill marks estimated rows; a provenance filter
is added and the broken currentEpc/lodgedEpc filters (on null row
columns) point at the effective/lodged baselines. Expected EPC now uses
the default plan's post rating (expectedEpcRating, unit-tested) instead
of current + Σ rec SAP, which double-counted across plans (732385: A→C);
the same rec aggregate is scoped to the default plan, fixing the Cost
column's 2x double-count.

See ADR-0002. Pure logic covered by provenance.test.ts + expectedEpc.test.ts;
SQL/query/UI verified against live properties 732385 and 729529.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-23 22:16:01 +00:00
parent ec88dadae2
commit ea988cfe52
14 changed files with 353 additions and 39 deletions

View file

@ -5,6 +5,9 @@ import { LodgedEpcTooltip } from "./LodgedEpcTooltip";
interface CurrentEfficiencyCardProps { interface CurrentEfficiencyCardProps {
epcRating: string | null; epcRating: string | null;
sapPoints: number; sapPoints: number;
provenanceSignal?: "estimated" | "remodelled" | "none";
lodgedEpcRating?: string | null;
lodgedSapPoints?: number | null;
originalSapPoints?: number | null; originalSapPoints?: number | null;
} }
@ -58,11 +61,24 @@ function getEpcDescription(letter: string | null | undefined): string {
export function CurrentEfficiencyCard({ export function CurrentEfficiencyCard({
epcRating, epcRating,
sapPoints, sapPoints,
provenanceSignal = "none",
lodgedEpcRating,
lodgedSapPoints,
originalSapPoints, originalSapPoints,
}: CurrentEfficiencyCardProps) { }: CurrentEfficiencyCardProps) {
const epcHex = getEpcHex(epcRating); const epcHex = getEpcHex(epcRating);
const lodgedLetter = originalSapPoints ? sapToEpc(originalSapPoints) : null; // New-approach: show the lodged certificate whenever the property was
const showLodgedBadge = lodgedLetter !== null && lodgedLetter !== epcRating; // re-modelled (effective diverged from lodged), even within the same band.
// Legacy: fall back to the row's originalSapPoints, only when the band differs.
const legacyLodgedLetter = originalSapPoints ? sapToEpc(originalSapPoints) : null;
const remodelled = provenanceSignal === "remodelled";
const lodgedLetter = remodelled
? lodgedEpcRating ?? null
: legacyLodgedLetter !== epcRating
? legacyLodgedLetter
: null;
const lodgedBadgeSap = remodelled ? lodgedSapPoints : originalSapPoints;
const showLodgedBadge = lodgedLetter !== null;
const lodgedHex = getEpcHex(lodgedLetter); const lodgedHex = getEpcHex(lodgedLetter);
return ( return (
@ -87,9 +103,9 @@ export function CurrentEfficiencyCard({
> >
{lodgedLetter} {lodgedLetter}
</span> </span>
{originalSapPoints != null && ( {lodgedBadgeSap != null && (
<span className="text-xs font-bold text-gray-400"> <span className="text-xs font-bold text-gray-400">
/ {Math.round(originalSapPoints)} / {Math.round(lodgedBadgeSap)}
</span> </span>
)} )}
</div> </div>

View file

@ -38,6 +38,14 @@ export interface PropertyMeta {
currentEpcRating: string; currentEpcRating: string;
currentSapPoints: number; currentSapPoints: number;
originalSapPoints: number | null; originalSapPoints: number | null;
// Provenance of the displayed "current" figures (see ADR-0002 / provenance.ts).
// "estimated" = no certificate (predicted); "remodelled" = lodged but
// rebaselined; "none" = lodged == effective, or legacy.
provenanceSignal: "estimated" | "remodelled" | "none";
// Lodged (gov-register) headline, only when a real certificate exists; feeds
// the "Lodged EPC" badge alongside the effective "current".
lodgedEpcRating: string | null;
lodgedSapPoints: number | null;
updatedAt: string; updatedAt: string;
currentValuation: number | null; currentValuation: number | null;
detailsEpc: { detailsEpc: {
@ -378,6 +386,13 @@ export interface PropertyWithRelations extends Record<string, unknown> {
// New fields // New fields
landlordPropertyId: string | null; landlordPropertyId: string | null;
originalSapPoints: number | null; originalSapPoints: number | null;
// Effective "current" (ADR-0002) is in currentEpcRating/currentSapPoints; these
// carry the separate lodged certificate + the default plan's post-retrofit
// rating + the provenance signal that drives the "Predicted" pill.
lodgedEpcRating: string | null;
postEpcRating: string | null;
postSapPoints: number | null;
provenanceSignal: "estimated" | "remodelled" | "none";
epcLodgementDate: string | null; epcLodgementDate: string | null;
epcIsExpired: boolean | null; epcIsExpired: boolean | null;
// Optional columns (hidden by default) // Optional columns (hidden by default)

View file

@ -9,8 +9,8 @@ import {
energyConsumptionSql, energyConsumptionSql,
estimatedSql, estimatedSql,
isExpiredSql, isExpiredSql,
sapSql, effectiveSapSql,
epcBandSql, effectiveEpcBandSql,
} from "@/lib/services/epcSources"; } from "@/lib/services/epcSources";
import type { import type {
@ -38,7 +38,7 @@ export async function getAverages(
): Promise<AverageMetrics> { ): Promise<AverageMetrics> {
const result = await db.execute<AverageMetrics>(sql` const result = await db.execute<AverageMetrics>(sql`
SELECT SELECT
AVG(${sapSql})::float AS avg_sap, AVG(${effectiveSapSql})::float AS avg_sap,
AVG(${carbonSql(sql`e`)})::float AS avg_carbon, AVG(${carbonSql(sql`e`)})::float AS avg_carbon,
AVG(${billsSql(sql`e`)})::float AS avg_bills, AVG(${billsSql(sql`e`)})::float AS avg_bills,
AVG(${energyConsumptionSql(sql`e`)})::float AS avg_energy_consumption AVG(${energyConsumptionSql(sql`e`)})::float AS avg_energy_consumption
@ -99,7 +99,7 @@ export async function getCountByEpcBand(
SELECT * SELECT *
FROM ( FROM (
SELECT SELECT
COALESCE((${epcBandSql})::text, 'Unknown') AS epc, COALESCE((${effectiveEpcBandSql})::text, 'Unknown') AS epc,
COUNT(*) FILTER ( COUNT(*) FILTER (
WHERE ${estimatedSql(sql`e`)} = false WHERE ${estimatedSql(sql`e`)} = false
)::int AS actual, )::int AS actual,

View file

@ -238,6 +238,9 @@ export default async function PreAssessmentReport(props: {
<CurrentEfficiencyCard <CurrentEfficiencyCard
epcRating={propertyMeta.currentEpcRating ?? null} epcRating={propertyMeta.currentEpcRating ?? null}
sapPoints={propertyMeta.currentSapPoints ?? 0} sapPoints={propertyMeta.currentSapPoints ?? 0}
provenanceSignal={propertyMeta.provenanceSignal}
lodgedEpcRating={propertyMeta.lodgedEpcRating}
lodgedSapPoints={propertyMeta.lodgedSapPoints}
originalSapPoints={propertyMeta.originalSapPoints} originalSapPoints={propertyMeta.originalSapPoints}
/> />

View file

@ -86,6 +86,9 @@ export default async function BuildingPassportHome(props: {
<CurrentEfficiencyCard <CurrentEfficiencyCard
epcRating={propertyMeta.currentEpcRating ?? null} epcRating={propertyMeta.currentEpcRating ?? null}
sapPoints={propertyMeta.currentSapPoints ?? 0} sapPoints={propertyMeta.currentSapPoints ?? 0}
provenanceSignal={propertyMeta.provenanceSignal}
lodgedEpcRating={propertyMeta.lodgedEpcRating}
lodgedSapPoints={propertyMeta.lodgedSapPoints}
originalSapPoints={propertyMeta.originalSapPoints} originalSapPoints={propertyMeta.originalSapPoints}
/> />

View file

@ -16,6 +16,7 @@ import {
TENURE_OPTIONS, TENURE_OPTIONS,
YEAR_BUILT_OPTIONS, YEAR_BUILT_OPTIONS,
MAINFUEL_OPTIONS, MAINFUEL_OPTIONS,
PROVENANCE_OPTIONS,
} from "@/app/utils/propertyFilters"; } from "@/app/utils/propertyFilters";
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
@ -33,6 +34,7 @@ const FIELD_OPTIONS: { value: FilterField; label: string }[] = [
{ value: "builtForm", label: "Built Form" }, { value: "builtForm", label: "Built Form" },
{ value: "tenure", label: "Tenure" }, { value: "tenure", label: "Tenure" },
{ value: "yearBuilt", label: "Year Built" }, { value: "yearBuilt", label: "Year Built" },
{ value: "provenance", label: "Provenance" },
{ value: "floorArea", label: "Floor Area (m²)" }, { value: "floorArea", label: "Floor Area (m²)" },
{ value: "co2Emissions", label: "CO₂ Emissions (kg/m²/yr)" }, { value: "co2Emissions", label: "CO₂ Emissions (kg/m²/yr)" },
{ value: "mainfuel", label: "Main Fuel" }, { value: "mainfuel", label: "Main Fuel" },
@ -74,6 +76,7 @@ const ENUM_FIELD_OPTIONS: Record<string, EnumOption[]> = {
builtForm: BUILT_FORM_OPTIONS, builtForm: BUILT_FORM_OPTIONS,
tenure: TENURE_OPTIONS, tenure: TENURE_OPTIONS,
yearBuilt: YEAR_BUILT_OPTIONS, yearBuilt: YEAR_BUILT_OPTIONS,
provenance: PROVENANCE_OPTIONS,
mainfuel: MAINFUEL_OPTIONS, mainfuel: MAINFUEL_OPTIONS,
}; };

View file

@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { expectedEpcRating } from "./expectedEpc";
describe("expectedEpcRating", () => {
it("uses the default plan's post EPC rating when present", () => {
expect(
expectedEpcRating({ postEpcRating: "C", postSapPoints: 71.4 }),
).toBe("C");
});
it("derives the band from post SAP when the plan has no stored rating", () => {
expect(
expectedEpcRating({ postEpcRating: null, postSapPoints: 64 }),
).toBe("D");
});
it("is blank when the property has no default plan", () => {
expect(expectedEpcRating(null)).toBe("");
});
});

View file

@ -0,0 +1,19 @@
import { sapToEpc } from "@/app/utils";
/**
* The portfolio "Expected EPC" the post-retrofit band of a property's default
* plan. The plan engine already models interactions, SAP 10 and the effective
* baseline, so we trust `plan.post_epc_rating` (falling back to deriving it from
* `post_sap_points`) rather than adding raw recommendation SAP onto the current
* score, which double-counts across plans and overshoots. See ADR-0002 and the
* grilling notes for property 732385 (read A, should read C).
*
* Returns "" when there is no default plan the column renders blank, not the
* current rating.
*/
export function expectedEpcRating(
plan: { postEpcRating: string | null; postSapPoints: number | null } | null,
): string {
if (!plan) return "";
return plan.postEpcRating ?? sapToEpc(plan.postSapPoints);
}

View file

@ -15,6 +15,7 @@ import { FunnelIcon } from "@heroicons/react/24/outline";
import { formatNumber, getEpcColorClass, sapToEpc } from "@/app/utils"; import { formatNumber, getEpcColorClass, sapToEpc } from "@/app/utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PropertyWithRelations } from "@/app/db/schema/property"; import { PropertyWithRelations } from "@/app/db/schema/property";
import { expectedEpcRating } from "./expectedEpc";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { import {
EnumOption, EnumOption,
@ -72,6 +73,20 @@ const EpcLetterBubble = ({ letter }: { letter: string }) => {
); );
}; };
/**
* Marks a property whose EPC was estimated from nearby homes (no certificate)
* the "trust this less" signal. Re-modelled properties aren't marked (the
* Lodged-EPC column differing from Current already conveys that). See ADR-0002.
*/
const PredictedPill = () => (
<span
className="inline-flex items-center rounded-full bg-amber-100 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wide text-amber-700 border border-amber-200"
title="Estimated from nearby homes — no EPC certificate on record"
>
Predicted
</span>
);
/* ----------------------------------------------------------------------- /* -----------------------------------------------------------------------
Column header with dropdown filter Column header with dropdown filter
------------------------------------------------------------------------ */ ------------------------------------------------------------------------ */
@ -229,19 +244,27 @@ const coreColumns: ColumnDef<PropertyWithRelations>[] = [
</div> </div>
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<div className="text-gray-700 font-medium flex justify-center"> <div className="text-gray-700 font-medium flex items-center justify-center gap-1.5">
<EpcLetterBubble letter={row.original.currentEpcRating || ""} /> <EpcLetterBubble letter={row.original.currentEpcRating || ""} />
{row.original.provenanceSignal === "estimated" && <PredictedPill />}
</div> </div>
), ),
}, },
{ {
accessorKey: "originalSapPoints", accessorKey: "lodgedEpcRating",
header: () => ( header: () => (
<div className="flex justify-center text-xs">Lodged EPC</div> <div className="flex justify-center text-xs">Lodged EPC</div>
), ),
cell: ({ row }) => { cell: ({ row }) => {
const originalSap = row.original.originalSapPoints; // Effective is the "current"; this column shows the real lodged
const letter = originalSap ? sapToEpc(originalSap) : null; // certificate, blank when there is none (predicted properties). For legacy
// rows the query returns the row's rating; new-approach returns lodged or
// NULL. originalSapPoints remains a fallback for legacy rows lacking a band.
const letter =
row.original.lodgedEpcRating ??
(row.original.originalSapPoints
? sapToEpc(row.original.originalSapPoints)
: null);
return ( return (
<div className="text-gray-700 font-medium flex justify-center"> <div className="text-gray-700 font-medium flex justify-center">
<EpcLetterBubble letter={letter || ""} /> <EpcLetterBubble letter={letter || ""} />
@ -255,21 +278,17 @@ const coreColumns: ColumnDef<PropertyWithRelations>[] = [
<div className="flex justify-center text-xs">Expected EPC</div> <div className="flex justify-center text-xs">Expected EPC</div>
), ),
cell: ({ row }) => { cell: ({ row }) => {
const currentSapPoints = row.original.currentSapPoints || 0; const expectedEpc = expectedEpcRating(
const expectedSapPoints = row.original.totalRecommendationSapPoints || 0; row.original.postEpcRating != null || row.original.postSapPoints != null
? {
if (currentSapPoints + expectedSapPoints === 0) { postEpcRating: row.original.postEpcRating,
return ( postSapPoints: row.original.postSapPoints,
<div className="text-gray-700 font-medium flex justify-center"> }
<EpcLetterBubble letter="" /> : null,
</div> );
);
}
const expectedEpc = sapToEpc(currentSapPoints + expectedSapPoints);
return ( return (
<div className="text-gray-700 font-medium flex justify-center"> <div className="text-gray-700 font-medium flex justify-center">
<EpcLetterBubble letter={expectedEpc || ""} /> <EpcLetterBubble letter={expectedEpc} />
</div> </div>
); );
}, },

View file

@ -26,8 +26,11 @@ import {
lodgementDateSql, lodgementDateSql,
isExpiredSql, isExpiredSql,
mainfuelSql, mainfuelSql,
sapSql, effectiveSapSql,
epcBandSql, effectiveEpcBandSql,
lodgedEpcBandSql,
lodgedSapSql,
provenanceSignalSql,
} from "@/lib/services/epcSources"; } from "@/lib/services/epcSources";
import { import {
FilterGroups, FilterGroups,
@ -37,6 +40,7 @@ import {
TENURE_OPTIONS, TENURE_OPTIONS,
YEAR_BUILT_OPTIONS, YEAR_BUILT_OPTIONS,
MAINFUEL_OPTIONS, MAINFUEL_OPTIONS,
PROVENANCE_OPTIONS,
EnumOption, EnumOption,
} from "@/app/utils/propertyFilters"; } from "@/app/utils/propertyFilters";
import { EPC_TO_SAP_MIN, EPC_TO_SAP_MAX } from "@/app/utils/epc"; import { EPC_TO_SAP_MIN, EPC_TO_SAP_MAX } from "@/app/utils/epc";
@ -46,6 +50,7 @@ const ENUM_FIELD_DB_OPTIONS: Record<string, EnumOption[]> = {
builtForm: BUILT_FORM_OPTIONS, builtForm: BUILT_FORM_OPTIONS,
tenure: TENURE_OPTIONS, tenure: TENURE_OPTIONS,
yearBuilt: YEAR_BUILT_OPTIONS, yearBuilt: YEAR_BUILT_OPTIONS,
provenance: PROVENANCE_OPTIONS,
mainfuel: MAINFUEL_OPTIONS, mainfuel: MAINFUEL_OPTIONS,
}; };
@ -523,10 +528,11 @@ function buildConditionSql(filter: PropertyFilter): ReturnType<typeof sql> | nul
return null; return null;
case "currentEpc": case "currentEpc":
return buildEpcSapCondition(sql`p.current_sap_points`, filter.operator, filter.value); // "Current" is the effective baseline (ADR-0002), not the null row column.
return buildEpcSapCondition(effectiveSapSql, filter.operator, filter.value);
case "lodgedEpc": case "lodgedEpc":
return buildEpcSapCondition(sql`p.original_sap_points`, filter.operator, filter.value); return buildEpcSapCondition(lodgedSapSql, filter.operator, filter.value);
case "expectedEpc": { case "expectedEpc": {
if (filter.operator === "epc_at_least") { if (filter.operator === "epc_at_least") {
@ -574,6 +580,7 @@ function buildConditionSql(filter: PropertyFilter): ReturnType<typeof sql> | nul
case "builtForm": case "builtForm":
case "tenure": case "tenure":
case "yearBuilt": case "yearBuilt":
case "provenance":
case "mainfuel": { case "mainfuel": {
if (filter.operator !== "enum_one_of") return null; if (filter.operator !== "enum_one_of") return null;
@ -591,6 +598,7 @@ function buildConditionSql(filter: PropertyFilter): ReturnType<typeof sql> | nul
builtForm: sql`p.built_form`, builtForm: sql`p.built_form`,
tenure: sql`p.tenure`, tenure: sql`p.tenure`,
yearBuilt: sql`p.year_built`, yearBuilt: sql`p.year_built`,
provenance: provenanceSignalSql,
// GAP: new-approach properties have no main-fuel string → NULL, so they // GAP: new-approach properties have no main-fuel string → NULL, so they
// only match the "no value" filter branch. See epcSources.ts. // only match the "no value" filter branch. See epcSources.ts.
mainfuel: mainfuelSql(sql`epc`), mainfuel: mainfuelSql(sql`epc`),
@ -718,15 +726,20 @@ export async function getProperties(
p.postcode AS postcode, p.postcode AS postcode,
p.status AS status, p.status AS status,
p.creation_status AS "creationStatus", p.creation_status AS "creationStatus",
-- Current EPC/SAP: new-approach properties have null headline columns on -- "Current" is the effective (re-baselined) figure the canonical current
-- the property row, so source from the lodged baseline. The "Expected EPC" -- per ADR-0002. The lodged certificate is a separate column. New-approach
-- column is currentSapPoints + recommendation SAP, so this also fixes it. -- properties have null headline columns on the property row, so both source
${epcBandSql} AS "currentEpcRating", -- from property_baseline_performance.
${sapSql} AS "currentSapPoints", ${effectiveEpcBandSql} AS "currentEpcRating",
-- property_targets is no longer read (the "Expected EPC" column derives ${effectiveSapSql} AS "currentSapPoints",
-- from currentSapPoints + recommendation SAP). Kept as NULL to preserve ${lodgedEpcBandSql} AS "lodgedEpcRating",
-- the PropertyWithRelations shape. ${provenanceSignalSql} AS "provenanceSignal",
-- "Expected EPC" is the default plan's modelled post-retrofit rating, not
-- current + Σ recommendation SAP (which double-counts across plans and
-- overshoots). See expectedEpc.ts / ADR-0002.
NULL AS "targetEpc", NULL AS "targetEpc",
pl.post_epc_rating AS "postEpcRating",
pl.post_sap_points AS "postSapPoints",
pl.id AS "planId", pl.id AS "planId",
-- funding_package is no longer read here (the proposals table fetches it -- funding_package is no longer read here (the proposals table fetches it
-- separately). Kept as NULL to preserve the PropertyWithRelations shape. -- separately). Kept as NULL to preserve the PropertyWithRelations shape.
@ -749,7 +762,7 @@ export async function getProperties(
-- LATERAL one-row lookups keep this query at "N properties × index probe" -- LATERAL one-row lookups keep this query at "N properties × index probe"
-- instead of hash-joining the multi-million-row plan/recommendation tables. -- instead of hash-joining the multi-million-row plan/recommendation tables.
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT id, post_sap_points FROM plan SELECT id, post_sap_points, post_epc_rating FROM plan
WHERE property_id = p.id WHERE property_id = p.id
AND portfolio_id = p.portfolio_id AND portfolio_id = p.portfolio_id
AND is_default = true AND is_default = true
@ -768,6 +781,11 @@ export async function getProperties(
COALESCE(SUM(estimated_cost), 0) AS cost COALESCE(SUM(estimated_cost), 0) AS cost
FROM recommendation FROM recommendation
WHERE property_id = p.id WHERE property_id = p.id
-- Scope to the default plan. property_id is the indexed access path, so
-- this only filters the property's own (tiny) rec set in memory no
-- unindexed plan_id scan while stopping the SUM double-counting a
-- measure once per plan when a property has several plans.
AND plan_id = pl.id
AND "default" = true AND "default" = true
AND already_installed = false AND already_installed = false
) rec ON true ) rec ON true

View file

@ -10,6 +10,7 @@ export type FilterField =
| "builtForm" | "builtForm"
| "tenure" | "tenure"
| "yearBuilt" | "yearBuilt"
| "provenance"
| "floorArea" | "floorArea"
| "co2Emissions" | "co2Emissions"
| "mainfuel"; | "mainfuel";
@ -89,6 +90,14 @@ export const TENURE_OPTIONS: EnumOption[] = [
{ label: "Not Recorded", dbValues: ["__null__"] }, { label: "Not Recorded", dbValues: ["__null__"] },
]; ];
// Provenance signal (ADR-0002). dbValues are the raw signal strings emitted by
// provenanceSignalSql / resolveProvenanceSignal.
export const PROVENANCE_OPTIONS: EnumOption[] = [
{ label: "Predicted", dbValues: ["estimated"] },
{ label: "Re-modelled", dbValues: ["remodelled"] },
{ label: "Standard", dbValues: ["none"] },
];
export const YEAR_BUILT_OPTIONS: EnumOption[] = [ export const YEAR_BUILT_OPTIONS: EnumOption[] = [
"1900","1930","1950","1967","1976","1983","1991","1996", "1900","1930","1950","1967","1976","1983","1991","1996",
"2003","2007","2008","2009","2010","2011","2012","2013", "2003","2007","2008","2009","2010","2011","2012","2013",

View file

@ -29,6 +29,7 @@
*/ */
import { db } from "@/app/db/db"; import { db } from "@/app/db/db";
import { and, eq, sql } from "drizzle-orm"; import { and, eq, sql } from "drizzle-orm";
import { resolveProvenanceSignal, type ProvenanceSignal } from "./provenance";
import { import {
epcProperty, epcProperty,
epcEnergyElement, epcEnergyElement,
@ -98,6 +99,44 @@ export const sapSql = sql`CASE WHEN ${isNewApproachSql} THEN bp.lodged_sap_score
/** Baseline EPC band. New: lodged band from property_baseline_performance. Legacy: property.current_epc_rating. */ /** Baseline EPC band. New: lodged band from property_baseline_performance. Legacy: property.current_epc_rating. */
export const epcBandSql = sql`CASE WHEN ${isNewApproachSql} THEN bp.lodged_epc_band ELSE p.current_epc_rating END`; export const epcBandSql = sql`CASE WHEN ${isNewApproachSql} THEN bp.lodged_epc_band ELSE p.current_epc_rating END`;
/**
* Effective (re-baselined) SAP score / EPC band the canonical "current" for
* display and reporting (ADR-0002). New: bp.effective_*; legacy: the property
* row. These replaced the lodged `sapSql`/`epcBandSql` above as the table +
* reporting source; those lodged fragments are intentionally retained (unused
* for now) for a possible future register-fidelity reporting view (ADR-0002).
* The "Lodged EPC" column itself uses the source-gated `lodgedEpcBandSql` below.
*/
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`;
/**
* 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
* predicted property carries lodged_* (mirrored estimates) that must not
* surface, so it renders NULL. Legacy: derive from the property row's
* original_sap_points (handled in the column, kept as the row value here).
*/
export const lodgedEpcBandSql = sql`CASE WHEN ${isNewApproachSql} THEN (CASE WHEN epl.id IS NOT NULL THEN bp.lodged_epc_band ELSE NULL END) ELSE p.current_epc_rating END`;
/** Lodged SAP score for the "Lodged EPC" filter — same source/gate as lodgedEpcBandSql. Legacy: the row's original_sap_points. */
export const lodgedSapSql = sql`CASE WHEN ${isNewApproachSql} THEN (CASE WHEN epl.id IS NOT NULL THEN bp.lodged_sap_score ELSE NULL END) ELSE p.original_sap_points END`;
/**
* Provenance signal for the portfolio table (mirrors resolveProvenanceSignal /
* provenance.ts). New-approach only; legacy 'none'.
*/
export const provenanceSignalSql = sql`CASE
WHEN ${isNewApproachSql} THEN (
CASE
WHEN epp.id IS NOT NULL AND epl.id IS NULL THEN 'estimated'
WHEN epl.id IS NOT NULL AND bp.rebaseline_reason IS NOT NULL AND bp.rebaseline_reason <> 'none' THEN 'remodelled'
ELSE 'none'
END
)
ELSE 'none'
END`;
/** /**
* Annual energy bill (£). New: sum of the individual cost columns on * Annual energy bill (£). New: sum of the individual cost columns on
* property_baseline_performance (NULL when no baseline row exists, so it's * property_baseline_performance (NULL when no baseline row exists, so it's
@ -438,6 +477,57 @@ export async function resolvePropertyHeadline(
}; };
} }
/**
* Provenance signal + the lodged (gov-register) headline for the EPC card.
* `provenanceSignal` drives the "estimated"/"re-modelled" UI signals; the lodged
* band/SAP feed the "Lodged EPC" badge and are only meaningful when a real
* certificate exists (`source = lodged`). Legacy properties get `none` and null
* lodged figures the card falls back to the row's `originalSapPoints`.
* See provenance.ts (pure precedence), CONTEXT.md and ADR-0002.
*/
export async function resolveProvenance(
propertyId: bigint,
updatedAt: Date | string | null | undefined,
): Promise<{
provenanceSignal: ProvenanceSignal;
lodgedEpcRating: string | null;
lodgedSapPoints: number | null;
}> {
if (!isNewApproach(updatedAt)) {
return { provenanceSignal: "none", lodgedEpcRating: null, lodgedSapPoints: null };
}
const [lodged, predicted, baseline] = await Promise.all([
db.query.epcProperty.findFirst({
columns: { id: true },
where: and(eq(epcProperty.propertyId, propertyId), eq(epcProperty.source, "lodged")),
}),
db.query.epcProperty.findFirst({
columns: { id: true },
where: and(eq(epcProperty.propertyId, propertyId), eq(epcProperty.source, "predicted")),
}),
db.query.propertyBaselinePerformance.findFirst({
columns: { rebaselineReason: true, lodgedSapScore: true, lodgedEpcBand: true },
where: eq(propertyBaselinePerformance.propertyId, propertyId),
}),
]);
const hasLodgedEpc = !!lodged;
const provenanceSignal = resolveProvenanceSignal({
isNewApproach: true,
hasLodgedEpc,
hasPredictedEpc: !!predicted,
rebaselineReason: baseline?.rebaselineReason,
});
// Lodged figures only when a real certificate exists — a predicted property
// still carries lodged_* (mirrored estimates) which must not surface.
return {
provenanceSignal,
lodgedEpcRating: hasLodgedEpc ? baseline?.lodgedEpcBand ?? null : null,
lodgedSapPoints: hasLodgedEpc ? baseline?.lodgedSapScore ?? null : null,
};
}
/** The minimal EPC meta the `/api/property-meta` route embeds as `detailsEpc`. */ /** The minimal EPC meta the `/api/property-meta` route embeds as `detailsEpc`. */
export interface DetailsEpcMeta { export interface DetailsEpcMeta {
currentEnergyDemand: number | null; currentEnergyDemand: number | null;
@ -654,16 +744,20 @@ export async function resolvePropertyMeta(propertyId: string) {
if (!propertyMeta) return null; if (!propertyMeta) return null;
const newApproach = isNewApproach(propertyMeta.updatedAt); const newApproach = isNewApproach(propertyMeta.updatedAt);
const [detailsEpc, headline, descriptors] = await Promise.all([ const [detailsEpc, headline, descriptors, provenance] = await Promise.all([
resolveDetailsEpcMeta(propertyMeta.id, propertyMeta.updatedAt), resolveDetailsEpcMeta(propertyMeta.id, propertyMeta.updatedAt),
resolvePropertyHeadline(propertyMeta.id, propertyMeta.updatedAt), resolvePropertyHeadline(propertyMeta.id, propertyMeta.updatedAt),
newApproach newApproach
? resolvePropertyDescriptors(propertyMeta.id) ? resolvePropertyDescriptors(propertyMeta.id)
: Promise.resolve(null), : Promise.resolve(null),
resolveProvenance(propertyMeta.id, propertyMeta.updatedAt),
]); ]);
return { return {
...propertyMeta, ...propertyMeta,
provenanceSignal: provenance.provenanceSignal,
lodgedEpcRating: provenance.lodgedEpcRating,
lodgedSapPoints: provenance.lodgedSapPoints,
propertyType: descriptors?.propertyType ?? propertyMeta.propertyType, propertyType: descriptors?.propertyType ?? propertyMeta.propertyType,
builtForm: descriptors?.builtForm ?? propertyMeta.builtForm, builtForm: descriptors?.builtForm ?? propertyMeta.builtForm,
yearBuilt: descriptors?.yearBuilt ?? propertyMeta.yearBuilt, yearBuilt: descriptors?.yearBuilt ?? propertyMeta.yearBuilt,

View file

@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { resolveProvenanceSignal } from "./provenance";
describe("resolveProvenanceSignal", () => {
it("flags a predicted-only property as estimated, even when rebaselined", () => {
expect(
resolveProvenanceSignal({
isNewApproach: true,
hasLodgedEpc: false,
hasPredictedEpc: true,
rebaselineReason: "pre_sap10",
}),
).toBe("estimated");
});
it("flags a lodged, rebaselined property as remodelled", () => {
expect(
resolveProvenanceSignal({
isNewApproach: true,
hasLodgedEpc: true,
hasPredictedEpc: false,
rebaselineReason: "pre_sap10",
}),
).toBe("remodelled");
});
it("returns none for a lodged property that was not rebaselined", () => {
expect(
resolveProvenanceSignal({
isNewApproach: true,
hasLodgedEpc: true,
hasPredictedEpc: false,
rebaselineReason: "none",
}),
).toBe("none");
});
it("prefers the real certificate when both lodged and predicted EPCs exist", () => {
expect(
resolveProvenanceSignal({
isNewApproach: true,
hasLodgedEpc: true,
hasPredictedEpc: true,
rebaselineReason: "pre_sap10",
}),
).toBe("remodelled");
});
it("returns none for legacy properties regardless of EPC inputs", () => {
expect(
resolveProvenanceSignal({
isNewApproach: false,
hasLodgedEpc: false,
hasPredictedEpc: true,
rebaselineReason: "pre_sap10",
}),
).toBe("none");
});
});

View file

@ -0,0 +1,36 @@
/**
* Provenance signal what the user is told about an EPC's trustworthiness.
* See CONTEXT.md ("EPC provenance", "Provenance signal") and ADR-0002.
*
* Pure decision over a property's EPC provenance (`epc_property.source`) and its
* Rebaseline (`property_baseline_performance.rebaseline_reason`). The DB-reading
* resolver gathers the inputs; this function owns the precedence.
*/
export type ProvenanceSignal = "estimated" | "remodelled" | "none";
export function resolveProvenanceSignal(input: {
isNewApproach: boolean;
hasLodgedEpc: boolean;
hasPredictedEpc: boolean;
rebaselineReason: string | null | undefined;
}): ProvenanceSignal {
// Legacy (pre-cutoff) properties have no effective/lodged split and no
// provenance concept — they read the property row directly.
if (!input.isNewApproach) {
return "none";
}
// No EPC certificate exists — the picture was estimated from nearby homes.
// Dominant: wins even when the baseline was also rebaselined.
if (input.hasPredictedEpc && !input.hasLodgedEpc) {
return "estimated";
}
// A real certificate exists but the modelling baseline diverged from it.
if (
input.hasLodgedEpc &&
input.rebaselineReason != null &&
input.rebaselineReason !== "none"
) {
return "remodelled";
}
return "none";
}