fix(reporting): temp guards for sub-baseline scenario post_sap

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) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-25 07:44:22 +00:00
parent 7bb2b093e5
commit 7de48448c0
2 changed files with 60 additions and 12 deletions

View file

@ -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

View file

@ -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