assessment-model/src/lib/reporting/model.ts
Khalim Conn-Kowlessar 4f6271641f fix(reporting): reconcile measure breakdown to the ledger construction cost
The "where the money goes" breakdown (and its CSV export) summed
recommendation.estimated_cost over every latest plan, while the
Investment ledger's Construction works line sums plan.cost_of_works over
only genuine upgrades. So the breakdown over-stated spend by counting
plans the ledger excludes (verified on portfolio 850/scenario 1329:
£59,366 vs £56,538 — 3 homes whose post-SAP sits below the effective
baseline; plan.cost_of_works ties to the recommendation sum exactly, so
there is no data residual).

Gate queryScenarioMeasures to the same upgrade set as the ledger
(cost_of_works > 0, post_sap >= effective baseline, stillNeedsUpgrade).
Extract stillNeedsUpgradeSql so the ledger and breakdown share one
definition, and add planCountsAsUpgrade as the pure twin with tests
(including the post-SAP-below-baseline case). Verified: the breakdown now
totals £56,538, matching the ledger.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 12:45:25 +00:00

516 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Reporting domain model — the pure logic behind the Reporting page.
*
* See CONTEXT.md (Reporting: Gross cost, Net cost, Project delivery,
* Current stock; Baseline performance: Likely downgrade, Likely upgrade)
* and ADR-0010 (the compliance-window filter is a report-view parameter).
* PRD: issue #370.
*/
import { EPC_BANDS, GOALS, type EpcBand } from "@/lib/epc/bands";
/**
* Project delivery is not yet a data point: it is estimated as a fixed
* share of construction works. Disclosed wherever gross cost is shown.
*/
export const PROJECT_DELIVERY_RATE = 0.3;
export interface LedgerInput {
constructionCost: number;
contingency: number;
funding: number;
homesUpgraded: number;
}
export interface Ledger {
constructionCost: number;
projectDelivery: number;
contingency: number;
funding: number;
grossCost: number;
netCost: number;
grossPerHome: number;
}
/**
* The Scenario overlay — the "After scenario" read model that every reporting
* surface consumes (the client metrics body, the Compare table, the PDF). This
* is the wire contract of the scenario-metrics endpoints: keep the field names
* in lockstep with getScenarioOverlay (the server read model) and its readers.
*
* `avg_sap` is a pre-rounded string (`toFixed(1)`) or null — the one field the
* server formats rather than the client. Costs are the canonical ledger figures
* (CONTEXT.md: Gross cost, Net cost, Project delivery). `scenario_epc_counts`
* is the after-scenario band distribution keyed A…G plus "Unknown".
*/
export interface ScenarioOverlay {
avg_sap: string | null;
avg_carbon: number | null;
avg_bills: number | null;
total_carbon: number | null;
total_bills: number | null;
n_units: number;
n_units_upgraded: number;
construction_cost: number;
contingency: number;
total_funding: number;
gross_cost: number;
net_cost: number;
total_sap_uplift: number;
gross_per_unit: number;
scenario_epc_counts: Record<string, number>;
pc_cost: number;
}
export function computeLedger(input: LedgerInput): Ledger {
const projectDelivery = input.constructionCost * PROJECT_DELIVERY_RATE;
const grossCost = input.constructionCost + projectDelivery + input.contingency;
return {
constructionCost: input.constructionCost,
projectDelivery,
contingency: input.contingency,
funding: input.funding,
grossCost,
netCost: grossCost - input.funding,
grossPerHome: input.homesUpgraded > 0 ? grossCost / input.homesUpgraded : 0,
};
}
/* ------------------------------------------------------------------
Ledger view — the derived "value for money" figures every scenario
surface renders (the metrics body, the PDF, the Compare table). These
turn a ScenarioOverlay + the current-stock totals into the figures the
InvestmentLedger reads; previously hand-derived in each of the three
surfaces, where they could silently disagree.
------------------------------------------------------------------ */
export interface LedgerView {
constructionCost: number;
projectDelivery: number;
contingency: number;
funding: number;
grossCost: number;
netCost: number;
grossPerHome: number;
billSavings: number;
billSavingsPerHome: number;
carbonSaved: number;
costPerSap: number;
costPerCarbon: number;
}
/** A total's improvement vs the current-stock baseline (nulls read as 0). */
export function amountSaved(
baselineTotal: number | null,
afterTotal: number | null,
): number {
return (baselineTotal ?? 0) - (afterTotal ?? 0);
}
/**
* Capital outlay — construction works + project delivery. The numerator of
* the £/SAP and £/CO₂ value-for-money ratios (contingency and funding sit
* outside it).
*/
export function capitalOutlay(overlay: {
construction_cost: number;
pc_cost: number;
}): number {
return overlay.construction_cost + overlay.pc_cost;
}
/** Cost per SAP point gained; null when there was no positive uplift. */
export function costPerSapPoint(
capital: number,
sapUplift: number,
): number | null {
return sapUplift > 0 ? capital / sapUplift : null;
}
/** Cost per tonne of CO₂ saved per year; null when nothing was saved. */
export function costPerCarbonSaved(
capital: number,
carbonSaved: number,
): number | null {
return carbonSaved > 0 ? capital / carbonSaved : null;
}
/**
* The full derived ledger for a scenario overlay against the current-stock
* baseline totals. The one home-count guard (`|| 1`) avoids a divide-by-zero
* in the per-home savings. Not-applicable ratios read as 0 here (the ledger
* renders a number); the Compare table keeps its own null-for-"—" rendering
* off the same `costPer*` helpers.
*/
export function deriveLedgerView(
overlay: ScenarioOverlay,
baseline: { totalCarbon: number | null; totalBills: number | null },
): LedgerView {
const carbonSaved = amountSaved(baseline.totalCarbon, overlay.total_carbon);
const billSaved = amountSaved(baseline.totalBills, overlay.total_bills);
const homes = overlay.n_units_upgraded || 1;
const capital = capitalOutlay(overlay);
return {
constructionCost: overlay.construction_cost,
projectDelivery: overlay.pc_cost,
contingency: overlay.contingency,
funding: overlay.total_funding,
grossCost: overlay.gross_cost,
netCost: overlay.net_cost,
grossPerHome: overlay.gross_per_unit,
billSavings: billSaved,
billSavingsPerHome: billSaved / homes,
carbonSaved,
costPerSap: costPerSapPoint(capital, overlay.total_sap_uplift) ?? 0,
costPerCarbon: costPerCarbonSaved(capital, carbonSaved) ?? 0,
};
}
/* ------------------------------------------------------------------
Plan gating — what counts toward a scenario's construction cost
------------------------------------------------------------------ */
export interface UpgradePlan {
/** The plan's costed works (£). */
costOfWorks: number;
/** Modelled post-retrofit SAP, or null when unknown. */
postSap: number | null;
/** The home's effective (re-baselined) SAP, or null when unknown. */
effectiveSap: number | null;
/** The home's effective EPC band, or null when unknown. */
effectiveBand: string | null;
/** The scenario's target band, or null for non-EPC goals / recommended view. */
targetBand: string | null;
}
/**
* Whether a plan's works count toward the scenario's construction cost — the
* pure twin of the Investment ledger's plan gating (`upgradedCostsSql` in
* overlay.ts, and `stillNeedsUpgradeSql` in epcSources). Both the ledger and
* the "where the money goes" measures breakdown must apply this identically, so
* the breakdown total reconciles to the Construction works line (CONTEXT.md).
*
* A plan counts iff it has costed works AND its modelled post-SAP is at least
* the home's effective baseline (a plan that doesn't lift the home above
* baseline isn't a real upgrade — ADR-0002) AND the home hasn't already met the
* scenario's target band (INTERIM guard for Model#1652). NULL comparisons keep
* the plan (mirrors the SQL's COALESCE(..., true)); bands compare lexically,
* A best.
*/
export function planCountsAsUpgrade(plan: UpgradePlan): boolean {
if (!(plan.costOfWorks > 0)) return false;
const meetsBaseline =
plan.postSap === null || plan.effectiveSap === null
? true
: plan.postSap >= plan.effectiveSap;
if (!meetsBaseline) return false;
return (
plan.targetBand === null ||
plan.effectiveBand === null ||
plan.effectiveBand > plan.targetBand
);
}
/* ------------------------------------------------------------------
Compliance window — ADR-0010
------------------------------------------------------------------ */
export interface CertificateEvidence {
provenance: "lodged" | "predicted";
lodgedBand: EpcBand | null;
expiryDate: Date | null;
}
export interface ComplianceWindow {
band: EpcBand;
date: Date;
}
/** A is better than B; lower index = better band. */
function bandAtLeast(band: EpcBand, threshold: EpcBand): boolean {
return EPC_BANDS.indexOf(band) <= EPC_BANDS.indexOf(threshold);
}
/**
* A home may be skipped from a scenario's works view iff its lodged
* certificate demonstrates the window band or better AND outlives the
* window date (ADR-0010).
*/
export function isCompliantBeyondWindow(
evidence: CertificateEvidence,
window: ComplianceWindow,
): boolean {
// A predicted home has no real certificate — its lodged_* values are
// mirrored estimates (CONTEXT.md, EPC provenance) and never qualify.
if (evidence.provenance !== "lodged") {
return false;
}
if (evidence.lodgedBand === null || evidence.expiryDate === null) {
return false;
}
return (
bandAtLeast(evidence.lodgedBand, window.band) &&
evidence.expiryDate.getTime() > window.date.getTime()
);
}
/* ------------------------------------------------------------------
Band movement — Likely downgrade / Likely upgrade (CONTEXT.md)
------------------------------------------------------------------ */
export type BandMovement = "likely-downgrade" | "likely-upgrade" | "none";
export interface BandMovementInput {
provenance: "lodged" | "predicted";
lodgedBand: EpcBand | null;
effectiveBand: EpcBand | null;
}
/**
* Compares the certificate's band with the effective (modelled) band.
* Lodged above effective → a re-survey would likely certificate lower;
* effective above lodged → a re-survey alone would likely certificate
* higher. Band movement only — SAP drift within a band never qualifies.
*/
export function classifyBandMovement(input: BandMovementInput): BandMovement {
// No real certificate (predicted) or an incomplete picture → no signal.
if (
input.provenance !== "lodged" ||
input.lodgedBand === null ||
input.effectiveBand === null
) {
return "none";
}
const lodged = EPC_BANDS.indexOf(input.lodgedBand);
const effective = EPC_BANDS.indexOf(input.effectiveBand);
if (lodged < effective) return "likely-downgrade";
if (lodged > effective) return "likely-upgrade";
return "none";
}
/* ------------------------------------------------------------------
Goal-aware callout
------------------------------------------------------------------ */
/** EPC-goal portfolios carry no band value; C is the sector default. */
export const DEFAULT_GOAL_BAND: EpcBand = "C";
export type GoalDimension = "carbon" | "energy" | "valuation";
export type GoalCallout =
| { kind: "below-band"; band: EpcBand; count: number }
| { kind: "dimension-total"; dimension: GoalDimension }
| { kind: "below-band-movement"; band: EpcBand; before: number; after: number }
| { kind: "dimension-movement"; dimension: GoalDimension };
const GOAL_DIMENSIONS: Partial<Record<string, GoalDimension>> = {
[GOALS.CO2]: "carbon",
[GOALS.ENERGY]: "energy",
[GOALS.VALUATION]: "valuation",
};
/**
* Indices of the best value in a compare row (Screen D) — *every* column that
* holds the joint-best value, so a tie marks all of them rather than silently
* crowning the leftmost. Nulls — baseline columns and missing data — never win.
* Returns an empty set when nothing is comparable, so no cell is marked.
*
* Ties are decided by exact equality: a caller that rounds for display (e.g.
* Average EPC → whole SAP) should round the compared value too, so a tie the
* reader can see is a tie the tag agrees with.
*/
export function pickBestIndices(
values: (number | null)[],
direction: "lower" | "higher",
): Set<number> {
let best = direction === "lower" ? Infinity : -Infinity;
for (const v of values) {
if (v === null) continue;
if (direction === "lower" ? v < best : v > best) best = v;
}
const winners = new Set<number>();
if (!Number.isFinite(best)) return winners; // every comparable value was null
values.forEach((v, i) => {
if (v === best) winners.add(i);
});
return winners;
}
/** Collapses baseline band rows (actual + estimated) into a plain band→count map. */
export function toBandCounts(
bands: { epc: string; actual: number; estimated: number }[],
): Record<string, number> {
const counts: Record<string, number> = {};
for (const b of bands) {
counts[b.epc] = (counts[b.epc] ?? 0) + b.actual + b.estimated;
}
return counts;
}
/** Counts homes in bands strictly worse than the target; Unknown never counts. */
export function countBelowBand(
bandCounts: Record<string, number>,
band: EpcBand,
): number {
return EPC_BANDS.filter((b) => EPC_BANDS.indexOf(b) > EPC_BANDS.indexOf(band))
.reduce((sum, b) => sum + (bandCounts[b] ?? 0), 0);
}
export type GoalCalloutInput =
| {
view: "current-stock";
goal: string;
bandCounts: Record<string, number>;
}
| {
view: "scenario";
goal: string;
goalValue: string | null;
bandCounts: Record<string, number>;
scenarioBandCounts: Record<string, number>;
};
export function selectGoalCallout(input: GoalCalloutInput): GoalCallout {
const dimension = GOAL_DIMENSIONS[input.goal];
if (input.view === "current-stock") {
if (dimension) {
return { kind: "dimension-total", dimension };
}
return {
kind: "below-band",
band: DEFAULT_GOAL_BAND,
count: countBelowBand(input.bandCounts, DEFAULT_GOAL_BAND),
};
}
if (dimension) {
return { kind: "dimension-movement", dimension };
}
const band = (input.goalValue as EpcBand) ?? DEFAULT_GOAL_BAND;
return {
kind: "below-band-movement",
band,
before: countBelowBand(input.bandCounts, band),
after: countBelowBand(input.scenarioBandCounts, band),
};
}
/* ------------------------------------------------------------------
Carbon equivalents & KPI deltas
------------------------------------------------------------------ */
/**
* Average annual tailpipe CO₂ of a UK car (BEIS/DEFRA GHG conversion
* factors, average car at average mileage ≈ 1.8 tCO₂e/yr). Methodology
* pending sign-off (PRD #370) — cite the source wherever this renders.
*/
export const TONNES_CO2_PER_CAR_PER_YEAR = 1.8;
export function carsOffTheRoad(tonnesPerYear: number): number {
if (tonnesPerYear <= 0) return 0;
return Math.round(tonnesPerYear / TONNES_CO2_PER_CAR_PER_YEAR);
}
/**
* Carbon tonnes for display — one decimal, trailing ".0" stripped
* (2.2 → "2.2", 2 → "2"). The single carbon formatter shared by the headline,
* the KPI card and the ledger, so one saved figure never renders three ways.
*/
export function formatTonnes(tonnes: number): string {
return tonnes.toFixed(1).replace(/\.0$/, "");
}
export interface KpiDelta {
delta: number;
improved: boolean;
}
export function shapeKpiDelta(input: {
current: number;
after: number;
improvesWhenLower: boolean;
}): KpiDelta {
const delta = input.after - input.current;
const improved = input.improvesWhenLower ? delta < 0 : delta > 0;
return { delta, improved };
}
/* ------------------------------------------------------------------
Board headline
------------------------------------------------------------------ */
/**
* £2,477,000 → "£2.48m"; £850,000 → "£850k"; £900 → "£900". The single
* compact money formatter for every reporting surface (headline, KPI tiles,
* the money-allocation bar) — re-exported from components/primitives as
* `moneyCompact`.
*/
export function formatMoneyCompact(amount: number): string {
const abs = Math.abs(amount);
const sign = amount < 0 ? "-" : "";
if (abs >= 1_000_000) return `${sign}£${(abs / 1_000_000).toFixed(2)}m`;
if (abs >= 1_000) return `${sign}£${Math.round(abs / 1_000)}k`;
return `${sign}£${Math.round(abs)}`;
}
/** £2,477,000 → "£2,477,000". The single full money formatter (ledger, PDF). */
export function moneyFull(amount: number): string {
const sign = amount < 0 ? "" : "";
return `${sign}£${Math.abs(Math.round(amount)).toLocaleString("en-GB")}`;
}
export interface HeadlineInput {
goal: string;
goalValue: string | null;
homesUpgraded: number;
netCost: number;
carbonSavedPerYear: number;
}
/**
* One board-quotable sentence per scenario. Opens the scenario view and
* the PDF; keep it plain enough to read aloud.
*/
export function buildHeadline(input: HeadlineInput): string {
const money = formatMoneyCompact(input.netCost);
const tonnesNum = input.carbonSavedPerYear;
const hasCarbon = Math.round(tonnesNum) > 0;
const tonnes = formatTonnes(tonnesNum);
const cars = carsOffTheRoad(tonnesNum);
// The "cars off the road" analogy lives here in the headline only (the one
// board-quotable line) — not repeated on the tiles or ledger. Dropped when it
// rounds below one car, where "0 cars" would read as noise. Singular "car"
// for exactly one.
const carClause =
cars > 0
? ` — like taking ${cars} car${cars === 1 ? "" : "s"} off the road.`
: "";
if (input.goal === GOALS.EPC) {
const base = `Reaching EPC ${input.goalValue ?? DEFAULT_GOAL_BAND} across ${input.homesUpgraded} homes costs ${money} net`;
return hasCarbon
? `${base} and cuts carbon by ${tonnes} tonnes a year${carClause}`
: `${base}.`;
}
if (input.goal === GOALS.CO2) {
const base = `Cutting carbon by ${tonnes} tonnes a year across ${input.homesUpgraded} homes costs ${money} net`;
return hasCarbon ? `${base}${carClause}` : `${base}.`;
}
const lead =
input.goal === GOALS.ENERGY
? "Cutting energy use"
: "Improving valuation";
const base = `${lead} across ${input.homesUpgraded} homes costs ${money} net`;
return hasCarbon
? `${base} and cuts carbon by ${tonnes} tonnes a year${carClause}`
: `${base}.`;
}