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 {
epcRating: string | null;
sapPoints: number;
provenanceSignal?: "estimated" | "remodelled" | "none";
lodgedEpcRating?: string | null;
lodgedSapPoints?: number | null;
originalSapPoints?: number | null;
}
@ -58,11 +61,24 @@ function getEpcDescription(letter: string | null | undefined): string {
export function CurrentEfficiencyCard({
epcRating,
sapPoints,
provenanceSignal = "none",
lodgedEpcRating,
lodgedSapPoints,
originalSapPoints,
}: CurrentEfficiencyCardProps) {
const epcHex = getEpcHex(epcRating);
const lodgedLetter = originalSapPoints ? sapToEpc(originalSapPoints) : null;
const showLodgedBadge = lodgedLetter !== null && lodgedLetter !== epcRating;
// New-approach: show the lodged certificate whenever the property was
// 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);
return (
@ -87,9 +103,9 @@ export function CurrentEfficiencyCard({
>
{lodgedLetter}
</span>
{originalSapPoints != null && (
{lodgedBadgeSap != null && (
<span className="text-xs font-bold text-gray-400">
/ {Math.round(originalSapPoints)}
/ {Math.round(lodgedBadgeSap)}
</span>
)}
</div>

View file

@ -38,6 +38,14 @@ export interface PropertyMeta {
currentEpcRating: string;
currentSapPoints: number;
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;
currentValuation: number | null;
detailsEpc: {
@ -378,6 +386,13 @@ export interface PropertyWithRelations extends Record<string, unknown> {
// New fields
landlordPropertyId: string | 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;
epcIsExpired: boolean | null;
// Optional columns (hidden by default)

View file

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

View file

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

View file

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

View file

@ -16,6 +16,7 @@ import {
TENURE_OPTIONS,
YEAR_BUILT_OPTIONS,
MAINFUEL_OPTIONS,
PROVENANCE_OPTIONS,
} from "@/app/utils/propertyFilters";
/* -----------------------------------------------------------------------
@ -33,6 +34,7 @@ const FIELD_OPTIONS: { value: FilterField; label: string }[] = [
{ value: "builtForm", label: "Built Form" },
{ value: "tenure", label: "Tenure" },
{ value: "yearBuilt", label: "Year Built" },
{ value: "provenance", label: "Provenance" },
{ value: "floorArea", label: "Floor Area (m²)" },
{ value: "co2Emissions", label: "CO₂ Emissions (kg/m²/yr)" },
{ value: "mainfuel", label: "Main Fuel" },
@ -74,6 +76,7 @@ const ENUM_FIELD_OPTIONS: Record<string, EnumOption[]> = {
builtForm: BUILT_FORM_OPTIONS,
tenure: TENURE_OPTIONS,
yearBuilt: YEAR_BUILT_OPTIONS,
provenance: PROVENANCE_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 { cn } from "@/lib/utils";
import { PropertyWithRelations } from "@/app/db/schema/property";
import { expectedEpcRating } from "./expectedEpc";
import { X } from "lucide-react";
import {
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
------------------------------------------------------------------------ */
@ -229,19 +244,27 @@ const coreColumns: ColumnDef<PropertyWithRelations>[] = [
</div>
),
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 || ""} />
{row.original.provenanceSignal === "estimated" && <PredictedPill />}
</div>
),
},
{
accessorKey: "originalSapPoints",
accessorKey: "lodgedEpcRating",
header: () => (
<div className="flex justify-center text-xs">Lodged EPC</div>
),
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 (
<div className="text-gray-700 font-medium flex justify-center">
<EpcLetterBubble letter={letter || ""} />
@ -255,21 +278,17 @@ const coreColumns: ColumnDef<PropertyWithRelations>[] = [
<div className="flex justify-center text-xs">Expected EPC</div>
),
cell: ({ row }) => {
const currentSapPoints = row.original.currentSapPoints || 0;
const expectedSapPoints = row.original.totalRecommendationSapPoints || 0;
if (currentSapPoints + expectedSapPoints === 0) {
return (
<div className="text-gray-700 font-medium flex justify-center">
<EpcLetterBubble letter="" />
</div>
);
}
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 (
<div className="text-gray-700 font-medium flex justify-center">
<EpcLetterBubble letter={expectedEpc || ""} />
<EpcLetterBubble letter={expectedEpc} />
</div>
);
},

View file

@ -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<string, EnumOption[]> = {
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<typeof sql> | 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<typeof sql> | 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<typeof sql> | 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

View file

@ -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",

View file

@ -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,

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";
}