+
+ {row.original.provenanceSignal === "estimated" &&
}
),
},
{
- accessorKey: "originalSapPoints",
+ accessorKey: "lodgedEpcRating",
header: () => (
Lodged EPC
),
cell: ({ row }) => {
- const originalSap = row.original.originalSapPoints;
- const letter = originalSap ? sapToEpc(originalSap) : null;
+ // Effective is the "current"; this column shows the real lodged
+ // 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 (
@@ -255,21 +278,17 @@ const coreColumns: ColumnDef
[] = [
Expected EPC
),
cell: ({ row }) => {
- const currentSapPoints = row.original.currentSapPoints || 0;
- const expectedSapPoints = row.original.totalRecommendationSapPoints || 0;
-
- if (currentSapPoints + expectedSapPoints === 0) {
- return (
-
-
-
- );
- }
- const expectedEpc = sapToEpc(currentSapPoints + expectedSapPoints);
-
+ const expectedEpc = expectedEpcRating(
+ row.original.postEpcRating != null || row.original.postSapPoints != null
+ ? {
+ postEpcRating: row.original.postEpcRating,
+ postSapPoints: row.original.postSapPoints,
+ }
+ : null,
+ );
return (
-
+
);
},
diff --git a/src/app/portfolio/[slug]/utils.ts b/src/app/portfolio/[slug]/utils.ts
index 40e612a5..b429f6c8 100644
--- a/src/app/portfolio/[slug]/utils.ts
+++ b/src/app/portfolio/[slug]/utils.ts
@@ -26,8 +26,11 @@ import {
lodgementDateSql,
isExpiredSql,
mainfuelSql,
- sapSql,
- epcBandSql,
+ effectiveSapSql,
+ effectiveEpcBandSql,
+ lodgedEpcBandSql,
+ lodgedSapSql,
+ provenanceSignalSql,
} from "@/lib/services/epcSources";
import {
FilterGroups,
@@ -37,6 +40,7 @@ import {
TENURE_OPTIONS,
YEAR_BUILT_OPTIONS,
MAINFUEL_OPTIONS,
+ PROVENANCE_OPTIONS,
EnumOption,
} from "@/app/utils/propertyFilters";
import { EPC_TO_SAP_MIN, EPC_TO_SAP_MAX } from "@/app/utils/epc";
@@ -46,6 +50,7 @@ const ENUM_FIELD_DB_OPTIONS: Record = {
builtForm: BUILT_FORM_OPTIONS,
tenure: TENURE_OPTIONS,
yearBuilt: YEAR_BUILT_OPTIONS,
+ provenance: PROVENANCE_OPTIONS,
mainfuel: MAINFUEL_OPTIONS,
};
@@ -523,10 +528,11 @@ function buildConditionSql(filter: PropertyFilter): ReturnType | nul
return null;
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":
- return buildEpcSapCondition(sql`p.original_sap_points`, filter.operator, filter.value);
+ return buildEpcSapCondition(lodgedSapSql, filter.operator, filter.value);
case "expectedEpc": {
if (filter.operator === "epc_at_least") {
@@ -574,6 +580,7 @@ function buildConditionSql(filter: PropertyFilter): ReturnType | nul
case "builtForm":
case "tenure":
case "yearBuilt":
+ case "provenance":
case "mainfuel": {
if (filter.operator !== "enum_one_of") return null;
@@ -591,6 +598,7 @@ function buildConditionSql(filter: PropertyFilter): ReturnType | nul
builtForm: sql`p.built_form`,
tenure: sql`p.tenure`,
yearBuilt: sql`p.year_built`,
+ provenance: provenanceSignalSql,
// GAP: new-approach properties have no main-fuel string → NULL, so they
// only match the "no value" filter branch. See epcSources.ts.
mainfuel: mainfuelSql(sql`epc`),
@@ -718,15 +726,20 @@ export async function getProperties(
p.postcode AS postcode,
p.status AS status,
p.creation_status AS "creationStatus",
- -- Current EPC/SAP: new-approach properties have null headline columns on
- -- the property row, so source from the lodged baseline. The "Expected EPC"
- -- column is currentSapPoints + recommendation SAP, so this also fixes it.
- ${epcBandSql} AS "currentEpcRating",
- ${sapSql} AS "currentSapPoints",
- -- property_targets is no longer read (the "Expected EPC" column derives
- -- from currentSapPoints + recommendation SAP). Kept as NULL to preserve
- -- the PropertyWithRelations shape.
+ -- "Current" is the effective (re-baselined) figure — the canonical current
+ -- per ADR-0002. The lodged certificate is a separate column. New-approach
+ -- properties have null headline columns on the property row, so both source
+ -- from property_baseline_performance.
+ ${effectiveEpcBandSql} AS "currentEpcRating",
+ ${effectiveSapSql} AS "currentSapPoints",
+ ${lodgedEpcBandSql} AS "lodgedEpcRating",
+ ${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",
+ pl.post_epc_rating AS "postEpcRating",
+ pl.post_sap_points AS "postSapPoints",
pl.id AS "planId",
-- funding_package is no longer read here (the proposals table fetches it
-- 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"
-- instead of hash-joining the multi-million-row plan/recommendation tables.
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
AND portfolio_id = p.portfolio_id
AND is_default = true
@@ -768,6 +781,11 @@ export async function getProperties(
COALESCE(SUM(estimated_cost), 0) AS cost
FROM recommendation
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 already_installed = false
) rec ON true
diff --git a/src/app/utils/propertyFilters.ts b/src/app/utils/propertyFilters.ts
index e67309ae..1f85675e 100644
--- a/src/app/utils/propertyFilters.ts
+++ b/src/app/utils/propertyFilters.ts
@@ -10,6 +10,7 @@ export type FilterField =
| "builtForm"
| "tenure"
| "yearBuilt"
+ | "provenance"
| "floorArea"
| "co2Emissions"
| "mainfuel";
@@ -89,6 +90,14 @@ export const TENURE_OPTIONS: EnumOption[] = [
{ 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[] = [
"1900","1930","1950","1967","1976","1983","1991","1996",
"2003","2007","2008","2009","2010","2011","2012","2013",
diff --git a/src/lib/services/epcSources.ts b/src/lib/services/epcSources.ts
index cbec24b5..d1a77dc0 100644
--- a/src/lib/services/epcSources.ts
+++ b/src/lib/services/epcSources.ts
@@ -29,6 +29,7 @@
*/
import { db } from "@/app/db/db";
import { and, eq, sql } from "drizzle-orm";
+import { resolveProvenanceSignal, type ProvenanceSignal } from "./provenance";
import {
epcProperty,
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. */
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
* 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`. */
export interface DetailsEpcMeta {
currentEnergyDemand: number | null;
@@ -654,16 +744,20 @@ export async function resolvePropertyMeta(propertyId: string) {
if (!propertyMeta) return null;
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),
resolvePropertyHeadline(propertyMeta.id, propertyMeta.updatedAt),
newApproach
? resolvePropertyDescriptors(propertyMeta.id)
: Promise.resolve(null),
+ resolveProvenance(propertyMeta.id, propertyMeta.updatedAt),
]);
return {
...propertyMeta,
+ provenanceSignal: provenance.provenanceSignal,
+ lodgedEpcRating: provenance.lodgedEpcRating,
+ lodgedSapPoints: provenance.lodgedSapPoints,
propertyType: descriptors?.propertyType ?? propertyMeta.propertyType,
builtForm: descriptors?.builtForm ?? propertyMeta.builtForm,
yearBuilt: descriptors?.yearBuilt ?? propertyMeta.yearBuilt,
diff --git a/src/lib/services/provenance.test.ts b/src/lib/services/provenance.test.ts
new file mode 100644
index 00000000..77c95423
--- /dev/null
+++ b/src/lib/services/provenance.test.ts
@@ -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");
+ });
+});
diff --git a/src/lib/services/provenance.ts b/src/lib/services/provenance.ts
new file mode 100644
index 00000000..b16e49a6
--- /dev/null
+++ b/src/lib/services/provenance.ts
@@ -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";
+}