diff --git a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts index c009be7a..c2419159 100644 --- a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts +++ b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts @@ -7,6 +7,7 @@ import { carbonSql, billsSql, effectiveSapSql, + effectiveEpcBandSql, isNewApproachSql, lodgedSapSql, } from "@/lib/services/epcSources"; @@ -199,6 +200,30 @@ export async function GET( ? EPC_MIN_SAP[scenario.goal_value] : null; + // The scenario's target band, for Increasing-EPC scenarios only (other goals + // have no band target). Used by the interim already-at-target guard below. + const targetBand = + scenario.goal === "Increasing EPC" ? scenario.goal_value : null; + + /** + * INTERIM guard for a backend bug (Hestia-Homes/Model#1652): the modelling engine + * emits costed plans for homes that are ALREADY at the scenario's target band + * — e.g. ~£300–600 of low-energy lighting / system tune-ups on effective-C + * homes in a "Reach EPC C" scenario. Those aren't upgrades toward the target, + * so counting them inflates "Homes upgraded" (and its costs) and stops the KPI + * reconciling with the below-target band distribution. Until the engine stops + * recommending works on already-compliant homes, exclude any home whose + * EFFECTIVE band already meets the target from the upgrade aggregate. Bands + * compare lexically (A best), so "still needs work" = effective band > target. + * Unknown/NULL effective band is kept (can't prove compliant). Only bites for + * Increasing-EPC scenarios; NULL targetBand ⇒ no-op. + */ + const stillNeedsUpgradeSql = sql`( + ${targetBand}::text IS NULL + OR (${effectiveEpcBandSql}) IS NULL + OR (${effectiveEpcBandSql})::text > ${targetBand}::text + )`; + /* ---------------------------------------------------------- The three data queries below are independent — they only need `minSap` (from the scenario definition above), not each other's results — so they @@ -307,6 +332,9 @@ export async function GET( -- (target-level post_sap on already-compliant homes) — not real upgrades. -- COALESCE keeps rows we can't compare. See ADR-0002. AND COALESCE(lp.post_sap_points >= (${effectiveSapSql}), true) + -- INTERIM: drop homes already at the scenario target band (backend bug — + -- see stillNeedsUpgradeSql above, Hestia-Homes/Model#1652). + AND ${stillNeedsUpgradeSql} AND ( ${useLodgedBaseline} = false OR ${minSap}::float IS NULL