From 41b89c0d8fc84c8e9cfb98dfa2ec4d287339904e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 20 Jul 2026 09:30:53 +0100 Subject: [PATCH] =?UTF-8?q?fix(reporting):=20interim=20guard=20=E2=80=94?= =?UTF-8?q?=20don't=20count=20already-at-target=20homes=20as=20upgraded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The modelling engine emits costed plans for homes already at a scenario's target EPC band (e.g. ~£300–600 of low-energy lighting / system tune-ups on effective-C homes in a "Reach EPC C" scenario). Counting them inflated "Homes upgraded" and its costs, and stopped the KPI reconciling with the below-target band distribution (portfolio 796 / scenario 1268: 9492 upgraded vs 9039 homes below C). Add an interim guard to the scenario metrics upgrade aggregate (count + cost): for Increasing-EPC scenarios, drop any home whose effective band already meets the target (effective band <= goal_value; lexical, A best). Unknown/NULL effective band is kept. Verified against the DB: scenario 1268 goes 9492 → 8871 upgraded, −£282k of works on compliant homes. This is a reporting-only band-aid; the root cause is in the engine — tracked in Hestia-Homes/Model#1652. The default/recommended view has no single target band, so the guard doesn't apply there. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../scenario/[scenarioId]/metrics/route.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) 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