From 7de48448c00c2c20b683e7ebfe4d3c877ce9eeec Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 25 Jun 2026 07:44:22 +0000 Subject: [PATCH] fix(reporting): temp guards for sub-baseline scenario post_sap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The modelling writes a target-level post_sap (e.g. ~C) even for homes already above the scenario target, so plans can carry cost while modelling post_sap BELOW the effective baseline. That skewed three reporting surfaces. Three TEMP (demo) guards, all keyed on the effective baseline (ADR-0002); revert once the Model team fixes the sub-baseline plans: 1. EPC band chart: post-scenario SAP clamped to GREATEST(baseline, post) so already-compliant properties aren't shown "improving" down a band (portfolio 796: EPC B 4,244 -> 1,660 wrongly, now 4,479). 2. n_units_upgraded + cost: exclude plans whose post_sap is below the effective baseline (not real upgrades) -- 796: 10,283 -> 9,765, -£1.28M. 3. total_sap_uplift / £-per-SAP: baseline is the effective SAP, not the null-for-new-approach current_sap_points, and counts genuine gains only -- uplift 0 -> 89,724, so £/SAP £0 -> £536. Also fixes the no-plan branch to use the effective baseline instead of the null current_sap_points (Unknown-band leakage). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../scenario/[scenarioId]/metrics/route.ts | 32 ++++++++++++--- .../scenario/default/metrics/route.ts | 40 +++++++++++++++---- 2 files changed, 60 insertions(+), 12 deletions(-) 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 702ebeda..d56943e8 100644 --- a/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts +++ b/src/app/api/portfolio/[portfolioId]/scenario/[scenarioId]/metrics/route.ts @@ -3,7 +3,12 @@ import { sql } from "drizzle-orm"; import { NextRequest, NextResponse } from "next/server"; import { sapToEpc } from "@/app/utils"; import type { PortfolioGoalType } from "@/app/db/schema/portfolio"; -import { newApproachJoins, carbonSql, billsSql } from "@/lib/services/epcSources"; +import { + newApproachJoins, + carbonSql, + billsSql, + effectiveSapSql, +} from "@/lib/services/epcSources"; /* ======================= Types @@ -125,14 +130,18 @@ export async function GET( SUM(post_energy_bill)::float AS total_bills, SUM( CASE + -- TEMP (demo): baseline is the effective SAP, not p.current_sap_points + -- (NULL for new-approach → uplift summed to 0 → £/SAP showed 0). Count + -- genuine gains only (post > baseline); sub-baseline plans excluded. WHEN cost_of_works > 0 - AND post_sap_points IS NOT NULL - THEN post_sap_points - p.current_sap_points + AND post_sap_points > (${effectiveSapSql}) + THEN post_sap_points - (${effectiveSapSql}) ELSE 0 END )::float AS total_sap_uplift FROM latest_plans lp JOIN property p ON p.id = lp.property_id + LEFT JOIN property_baseline_performance bp ON bp.property_id = p.id -- Conditional filter: only restrict by original_sap_points when the toggle is on -- AND the scenario has an EPC target. Written as an OR chain so Postgres evaluates -- it as a single WHERE clause — avoiding the need to dynamically build the query @@ -177,8 +186,13 @@ export async function GET( )::float AS total_funding FROM latest_plans lp JOIN property p ON p.id = lp.property_id + LEFT JOIN property_baseline_performance bp ON bp.property_id = p.id LEFT JOIN funding_package fp ON fp.plan_id = lp.id WHERE lp.cost_of_works > 0 + -- TEMP (demo): exclude plans whose post SAP is below the effective baseline + -- (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) AND ( ${useOriginalBaseline} = false OR ${minSap}::float IS NULL @@ -257,10 +271,18 @@ export async function GET( const epcRows = await db.execute(sql` SELECT CASE - WHEN lp.id IS NOT NULL THEN lp.post_sap_points - ELSE p.current_sap_points + -- A retrofit scenario can't make a property worse. The engine writes a + -- target-level post_sap (e.g. ~C) even for properties already above the + -- target, so post_sap can sit BELOW the baseline — which made the chart + -- show e.g. B properties "improving" down to C. Clamp to the baseline. + WHEN lp.id IS NOT NULL THEN GREATEST(${effectiveSapSql}, lp.post_sap_points) + -- No qualifying plan → unchanged property. Use the effective + -- (re-baselined) baseline to match the "before" distribution; NOT + -- p.current_sap_points (NULL for new-approach → "Unknown"). See ADR-0002. + ELSE ${effectiveSapSql} END AS effective_sap FROM property p + LEFT JOIN property_baseline_performance bp ON bp.property_id = p.id LEFT JOIN LATERAL ( SELECT * FROM plan diff --git a/src/app/api/portfolio/[portfolioId]/scenario/default/metrics/route.ts b/src/app/api/portfolio/[portfolioId]/scenario/default/metrics/route.ts index a1ed7257..b5f56284 100644 --- a/src/app/api/portfolio/[portfolioId]/scenario/default/metrics/route.ts +++ b/src/app/api/portfolio/[portfolioId]/scenario/default/metrics/route.ts @@ -3,7 +3,12 @@ import { sql } from "drizzle-orm"; import { NextRequest, NextResponse } from "next/server"; import { sapToEpc } from "@/app/utils"; import type { PortfolioGoalType } from "@/app/db/schema/portfolio"; -import { newApproachJoins, carbonSql, billsSql } from "@/lib/services/epcSources"; +import { + newApproachJoins, + carbonSql, + billsSql, + effectiveSapSql, +} from "@/lib/services/epcSources"; /* ======================= Types @@ -91,14 +96,19 @@ export async function GET( SUM(post_energy_bill)::float AS total_bills, SUM( CASE + -- TEMP (demo): baseline is the effective SAP, not p.current_sap_points + -- (NULL for new-approach → uplift summed to 0 → £/SAP showed 0). Count + -- genuine gains only (post > baseline); sub-baseline "downgrade" plans + -- are excluded rather than dragging the uplift negative. See ADR-0002. WHEN cost_of_works > 0 - AND post_sap_points IS NOT NULL - THEN post_sap_points - p.current_sap_points + AND post_sap_points > (${effectiveSapSql}) + THEN post_sap_points - (${effectiveSapSql}) ELSE 0 END )::float AS total_sap_uplift FROM latest_plans lp - JOIN property p ON p.id = lp.property_id; + JOIN property p ON p.id = lp.property_id + LEFT JOIN property_baseline_performance bp ON bp.property_id = p.id; `); const scenarioAgg = scenarioMetricsResult.rows[0] as ScenarioAggregates; @@ -124,8 +134,15 @@ export async function GET( COALESCE(fp.total_uplift, 0) )::float AS total_funding FROM latest_plans lp + JOIN property p ON p.id = lp.property_id + LEFT JOIN property_baseline_performance bp ON bp.property_id = p.id LEFT JOIN funding_package fp ON fp.plan_id = lp.id - WHERE lp.cost_of_works > 0; + WHERE lp.cost_of_works > 0 + -- TEMP (demo): a plan whose post SAP is below the effective baseline isn't + -- a real upgrade (target-level post_sap on already-compliant homes), so + -- exclude it from the count + cost. COALESCE keeps rows we can't compare + -- (NULL post or NULL baseline). See ADR-0002. + AND COALESCE(lp.post_sap_points >= (${effectiveSapSql}), true); `); const upgraded = upgradedResult.rows[0] as UpgradedAggregates; @@ -187,10 +204,19 @@ export async function GET( const epcRows = await db.execute(sql` SELECT CASE - WHEN lp.id IS NOT NULL THEN lp.post_sap_points - ELSE p.current_sap_points + -- A retrofit scenario can't make a property worse. The engine writes a + -- target-level post_sap (e.g. ~C) even for properties already above the + -- target, so post_sap can sit BELOW the baseline — which made the chart + -- show e.g. B properties "improving" down to C. Clamp to the baseline so + -- the post-scenario band is never worse than before. + WHEN lp.id IS NOT NULL THEN GREATEST(${effectiveSapSql}, lp.post_sap_points) + -- No plan → unchanged property. Use the effective (re-baselined) baseline + -- so this matches the "before" distribution — NOT p.current_sap_points, + -- which is NULL for new-approach properties. See ADR-0002. + ELSE ${effectiveSapSql} END AS effective_sap FROM property p + LEFT JOIN property_baseline_performance bp ON bp.property_id = p.id LEFT JOIN LATERAL ( SELECT * FROM plan