mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-08-03 05:18:20 +00:00
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>
516 lines
17 KiB
TypeScript
516 lines
17 KiB
TypeScript
/**
|
||
* 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}.`;
|
||
}
|