fix(building-passport): use effective (modelling) baseline for current SAP/EPC/CO2

Widen the lodged->effective switch from plan-only to all per-property
building-passport views (overview, assessment, plans). New-approach
properties' "current" SAP/EPC/CO2/primary-energy now come from the
effective (re-baselined) figure the modelling and plans were scored
against, so a property's current state agrees with the plan maths --
re-baselining (e.g. pre_sap10) otherwise made plans read as flat or as a
downgrade against the lodged gov-register EPC.

Done by flipping the shared object resolvers (resolvePropertyHeadline,
resolveDetailsEpcMeta, resolveNewConditionReport) to effective_*, which
removes the plan-only getPlanPropertyMeta/resolveEffectiveHeadline
indirection added in the previous commit. The set-based reporting
fragments (sapSql/epcBandSql/carbonSql/...) stay on lodged_* so portfolio
and reporting totals remain matched to the gov register.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-23 20:47:06 +00:00
parent 65db15d3c7
commit e51bf1d23f
4 changed files with 21 additions and 63 deletions

View file

@ -1,6 +1,6 @@
import RecommendationContainer from "@/app/components/building-passport/RecommendationContainer";
import {
getPlanPropertyMeta,
getPropertyMeta,
getRecommendations,
getPlanMeta,
getInstalledMeasuresByUprn,
@ -59,7 +59,7 @@ export default async function PlanDetail(props: {
params: Promise<{ slug: string; propertyId: string; planId: string }>;
}) {
const params = await props.params;
const propertyMeta = await getPlanPropertyMeta(params.propertyId);
const propertyMeta = await getPropertyMeta(params.propertyId);
const [recommendations, planMeta, installedMeasures, scenarioData] =
await Promise.all([
getRecommendations(params.planId),

View file

@ -1,4 +1,4 @@
import { getPlans, getPlanPropertyMeta } from "../utils";
import { getPlans, getPropertyMeta } from "../utils";
import { sapToEpc } from "@/app/utils";
import PlanCard from "./PlanCard";
import PlanHeroCard from "./PlanHeroCard";
@ -9,7 +9,7 @@ export default async function RecommendationPlans(props: {
}) {
const params = await props.params;
const [propertyMeta, plans] = await Promise.all([
getPlanPropertyMeta(params.propertyId),
getPropertyMeta(params.propertyId),
getPlans(params.propertyId),
]);

View file

@ -24,7 +24,6 @@ import {
ConditionReport,
resolveConditionReport,
resolvePropertyMeta,
resolveEffectiveHeadline,
} from "@/lib/services/epcSources";
import { getRating, serializeBigInt } from "@/app/utils";
import { eq, desc, and } from "drizzle-orm";
@ -255,27 +254,6 @@ export async function getPropertyMeta(
return JSON.parse(JSON.stringify(meta, serializeBigInt)) as PropertyMeta;
}
/**
* Property meta for the plan pages. Identical to `getPropertyMeta` except the
* "current" SAP/EPC is the effective (modelling) baseline the plans were scored
* against, not the lodged EPC see `resolveEffectiveHeadline`. Legacy
* properties and backfill gaps fall back to the lodged/row values.
*/
export async function getPlanPropertyMeta(
propertyId: string
): Promise<PropertyMeta> {
const meta = await getPropertyMeta(propertyId);
const effective = await resolveEffectiveHeadline(
BigInt(propertyId),
meta.updatedAt
);
return {
...meta,
currentSapPoints: effective.currentSapPoints ?? meta.currentSapPoints,
currentEpcRating: effective.currentEpcRating ?? meta.currentEpcRating,
};
}
export async function getConditionReport(
propertyId: string
): Promise<ConditionReport> {

View file

@ -320,9 +320,11 @@ async function resolveNewConditionReport(
// GAP: no energy tariff in the new graph — omitted.
energyTariff: null,
currentEnergyDemand: sumKwh,
// Effective (re-baselined) baseline so the assessment view agrees with the
// headline SAP/EPC and the plan maths — see resolvePropertyHeadline.
primaryEnergyConsumption:
baseline?.lodgedPrimaryEnergyIntensityKwhPerM2Yr ?? null,
co2Emissions: baseline?.lodgedCo2EmissionsTPerYr ?? null,
baseline?.effectivePrimaryEnergyIntensityKwhPerM2Yr ?? null,
co2Emissions: baseline?.effectiveCo2EmissionsTPerYr ?? null,
heatingEnergyCostCurrent: baseline?.heatingCostGbp ?? null,
hotWaterEnergyCostCurrent: baseline?.hotWaterCostGbp ?? null,
lightingEnergyCostCurrent: baseline?.lightingCostGbp ?? null,
@ -405,45 +407,23 @@ export async function resolveConditionReport(
/**
* Headline SAP score + EPC band for the property meta. New-approach properties
* don't have `current_sap_points` / `current_epc_rating` written on the row yet,
* so we fall back to the lodged baseline (property_baseline_performance). Returns
* nulls for legacy properties, signalling the caller to keep the row values.
* so we fall back to the modelling baseline (property_baseline_performance).
* Returns nulls for legacy properties, signalling the caller to keep the row
* values.
*
* Uses lodged_* (gov-registry actual) to match the rest of the EPC reads; swap to
* effective_* for the modelling-adjusted baseline.
* Uses `effective_*` the re-baselined figure the modelling (and therefore the
* plans) was scored against. The per-property building-passport views (overview,
* assessment, plans) must agree with the plan maths: re-baselining can move the
* effective score away from the lodged (gov-registry) EPC, so showing lodged as
* "current" misrepresents a plan's uplift e.g. lodged C/78 vs an effective
* baseline of C/71 with a post-retrofit C/71.4 reads as flat instead of the real
* improvement. The set-based reporting fragments (`sapSql`, `epcBandSql`,
* `carbonSql`, ) deliberately stay on `lodged_*` to keep portfolio/reporting
* totals matched to the gov register.
*/
export async function resolvePropertyHeadline(
propertyId: bigint,
updatedAt: Date | string | null | undefined,
): Promise<{ currentSapPoints: number | null; currentEpcRating: string | null }> {
if (!isNewApproach(updatedAt)) {
return { currentSapPoints: null, currentEpcRating: null };
}
const baseline = await db.query.propertyBaselinePerformance.findFirst({
columns: { lodgedSapScore: true, lodgedEpcBand: true },
where: eq(propertyBaselinePerformance.propertyId, propertyId),
});
return {
currentSapPoints: baseline?.lodgedSapScore ?? null,
currentEpcRating: baseline?.lodgedEpcBand ?? null,
};
}
/**
* Headline SAP score + EPC band for the *plan* pages. Plans are scored against
* the modelling baseline (`effective_*` on property_baseline_performance), which
* re-baselining can move away from the lodged (gov-registry) EPC we show
* everywhere else. Using the lodged figure as a plan's "current" would
* misrepresent the uplift e.g. lodged C/71 vs an effective baseline of D/64
* with a post-retrofit C/69.6 reads as a *downgrade* (71 69.6) instead of the
* real D C improvement. So plan pages take the effective baseline as "current".
*
* Returns nulls for legacy properties (no effective/lodged split keep the
* property-row values) and when no baseline row exists yet (backfill gap fall
* back to the lodged headline).
*/
export async function resolveEffectiveHeadline(
propertyId: bigint,
updatedAt: Date | string | null | undefined,
): Promise<{ currentSapPoints: number | null; currentEpcRating: string | null }> {
if (!isNewApproach(updatedAt)) {
return { currentSapPoints: null, currentEpcRating: null };
@ -500,7 +480,7 @@ export async function resolveDetailsEpcMeta(
return {
currentEnergyDemand,
co2Emissions: baseline?.lodgedCo2EmissionsTPerYr ?? null,
co2Emissions: baseline?.effectiveCo2EmissionsTPerYr ?? null,
estimated: !lodged && !!predicted,
};
}