mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-12 13:28:55 +00:00
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:
parent
ec88dadae2
commit
ea988cfe52
14 changed files with 353 additions and 39 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
20
src/app/portfolio/[slug]/components/expectedEpc.test.ts
Normal file
20
src/app/portfolio/[slug]/components/expectedEpc.test.ts
Normal 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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
19
src/app/portfolio/[slug]/components/expectedEpc.ts
Normal file
19
src/app/portfolio/[slug]/components/expectedEpc.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
59
src/lib/services/provenance.test.ts
Normal file
59
src/lib/services/provenance.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
36
src/lib/services/provenance.ts
Normal file
36
src/lib/services/provenance.ts
Normal 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";
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue