busi488energy/src/actions/dc-impact.ts
2026-04-05 20:53:33 -04:00

487 lines
16 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.

'use server';
import { getCapacityPriceTimeline, getDcPriceImpact } from '@/generated/prisma/sql.js';
import { prisma } from '@/lib/db.js';
import { serialize } from '@/lib/superjson.js';
import { validateRegionCode } from '@/lib/utils.js';
import { cacheLife, cacheTag } from 'next/cache';
interface ActionSuccess<T> {
ok: true;
data: ReturnType<typeof serialize<T>>;
}
interface ActionError {
ok: false;
error: string;
}
type ActionResult<T> = ActionSuccess<T> | ActionError;
// ---------------------------------------------------------------------------
// Multivariate Regression: DC Capacity effect on electricity prices
// controlling for fuel costs, demand, and generation mix
// ---------------------------------------------------------------------------
export interface MultivariateDataPoint {
region_code: string;
region_name: string;
/** Total DC capacity in the region (MW) */
dc_capacity_mw: number;
/** Average electricity price ($/MWh) */
avg_price: number;
/** Average natural gas price during same period ($/MMBtu) */
avg_gas_price: number;
/** Average demand (MW) */
avg_demand: number;
/** Gas generation share (fraction 01) */
gas_share: number;
/** Residual: price unexplained by fuel/demand model */
residual: number;
}
export interface RegressionCoefficient {
variable: string;
coefficient: number;
/** Standardized coefficient (beta weight) */
beta: number;
}
export interface MultivariateResult {
points: MultivariateDataPoint[];
coefficients: RegressionCoefficient[];
r2_full: number;
r2_without_dc: number;
/** Partial R² attributable to DC capacity */
r2_partial_dc: number;
n: number;
}
/**
* OLS multiple regression using normal equations.
* X is a matrix of [n x k] predictors (with intercept column prepended).
* Returns coefficient vector, R², and predicted values.
*/
function olsRegression(
y: number[],
X: number[][],
varNames: string[],
): { coefficients: RegressionCoefficient[]; r2: number; predicted: number[] } | null {
const n = y.length;
const k = X[0]!.length;
if (n <= k) return null;
// Add intercept column
const Xa = X.map(row => [1, ...row]);
const ka = k + 1;
// X'X
const XtX: number[][] = Array.from({ length: ka }, () => Array.from<number>({ length: ka }).fill(0));
for (let i = 0; i < ka; i++) {
for (let j = 0; j < ka; j++) {
let sum = 0;
for (let r = 0; r < n; r++) sum += Xa[r]![i]! * Xa[r]![j]!;
XtX[i]![j] = sum;
}
}
// X'y
const Xty: number[] = Array.from<number>({ length: ka }).fill(0);
for (let i = 0; i < ka; i++) {
let sum = 0;
for (let r = 0; r < n; r++) sum += Xa[r]![i]! * y[r]!;
Xty[i] = sum;
}
// Solve via Gauss-Jordan elimination
const aug: number[][] = XtX.map((row, i) => [...row, Xty[i]!]);
for (let col = 0; col < ka; col++) {
// Partial pivoting
let maxRow = col;
for (let row = col + 1; row < ka; row++) {
if (Math.abs(aug[row]![col]!) > Math.abs(aug[maxRow]![col]!)) maxRow = row;
}
[aug[col], aug[maxRow]] = [aug[maxRow]!, aug[col]!];
const pivot = aug[col]![col]!;
if (Math.abs(pivot) < 1e-12) return null; // singular
for (let j = col; j <= ka; j++) aug[col]![j]! /= pivot;
for (let row = 0; row < ka; row++) {
if (row === col) continue;
const factor = aug[row]![col]!;
for (let j = col; j <= ka; j++) aug[row]![j]! -= factor * aug[col]![j]!;
}
}
const beta = aug.map(row => row[ka]!);
// Predicted and R²
const predicted = Xa.map(row => row.reduce((sum, val, i) => sum + val * beta[i]!, 0));
const meanY = y.reduce((s, v) => s + v, 0) / n;
const ssTot = y.reduce((s, v) => s + (v - meanY) ** 2, 0);
const ssRes = y.reduce((s, v, i) => s + (v - predicted[i]!) ** 2, 0);
const r2 = ssTot === 0 ? 0 : 1 - ssRes / ssTot;
// Compute standardized coefficients (beta weights)
// beta_std_j = beta_j * (sd_xj / sd_y)
const sdY = Math.sqrt(ssTot / n);
const coefficients: RegressionCoefficient[] = varNames.map((name, idx) => {
const colIdx = idx; // 0-indexed in original X (no intercept)
const xValues = X.map(row => row[colIdx]!);
const meanX = xValues.reduce((s, v) => s + v, 0) / n;
const sdX = Math.sqrt(xValues.reduce((s, v) => s + (v - meanX) ** 2, 0) / n);
const rawCoeff = beta[idx + 1]!; // +1 to skip intercept
return {
variable: name,
coefficient: rawCoeff,
beta: sdY > 0 && sdX > 0 ? rawCoeff * (sdX / sdY) : 0,
};
});
return { coefficients, r2, predicted };
}
export async function fetchMultivariateAnalysis(): Promise<ActionResult<MultivariateResult>> {
'use cache';
cacheLife('prices');
cacheTag('multivariate-analysis');
try {
// Get all regions with DC data
const regions = await prisma.gridRegion.findMany({
select: {
id: true,
code: true,
name: true,
datacenters: { select: { capacityMw: true } },
},
});
// For each region, compute monthly observations joining price + gas + demand + gen mix
// This gives us many more data points than just one per region
const monthlyData = await prisma.$queryRaw<
Array<{
region_code: string;
region_name: string;
month: Date;
avg_price: number;
avg_demand: number;
}>
>`
SELECT
r.code AS region_code,
r.name AS region_name,
date_trunc('month', ep.timestamp) AS month,
AVG(ep.price_mwh) AS avg_price,
AVG(ep.demand_mw) AS avg_demand
FROM electricity_prices ep
JOIN grid_regions r ON ep.region_id = r.id
GROUP BY r.code, r.name, date_trunc('month', ep.timestamp)
HAVING COUNT(*) >= 10
ORDER BY r.code, month
`;
// Get monthly gas prices
const gasData = await prisma.$queryRaw<Array<{ month: Date; avg_gas: number }>>`
SELECT
date_trunc('month', timestamp) AS month,
AVG(price) AS avg_gas
FROM commodity_prices
WHERE commodity = 'natural_gas'
GROUP BY date_trunc('month', timestamp)
`;
const gasByMonth = new Map(gasData.map(g => [g.month.toISOString(), g.avg_gas]));
// Get gas generation share per region (latest available)
const genData = await prisma.$queryRaw<Array<{ region_code: string; gas_share: number }>>`
SELECT
r.code AS region_code,
COALESCE(
SUM(CASE WHEN gm.fuel_type = 'NG' THEN gm.generation_mw ELSE 0 END) /
NULLIF(SUM(gm.generation_mw), 0),
0
) AS gas_share
FROM generation_mix gm
JOIN grid_regions r ON gm.region_id = r.id
WHERE gm.timestamp >= NOW() - INTERVAL '30 days'
GROUP BY r.code
`;
const gasShareByRegion = new Map(genData.map(g => [g.region_code, Number(g.gas_share)]));
// DC capacity per region
const dcCapByRegion = new Map(regions.map(r => [r.code, r.datacenters.reduce((sum, d) => sum + d.capacityMw, 0)]));
// Build observation matrix: each monthly region observation is a row
const observations: Array<{
region_code: string;
region_name: string;
price: number;
gas_price: number;
demand: number;
gas_share: number;
dc_capacity: number;
}> = [];
for (const row of monthlyData) {
const gasPrice = gasByMonth.get(row.month.toISOString());
if (gasPrice === undefined) continue;
const gasShare = gasShareByRegion.get(row.region_code) ?? 0;
const dcCap = dcCapByRegion.get(row.region_code) ?? 0;
observations.push({
region_code: row.region_code,
region_name: row.region_name,
price: Number(row.avg_price),
gas_price: Number(gasPrice),
demand: Number(row.avg_demand),
gas_share: Number(gasShare),
dc_capacity: dcCap,
});
}
if (observations.length < 10) {
return { ok: false, error: 'Insufficient data for multivariate regression' };
}
// Full model: price ~ gas_price + demand + gas_share + dc_capacity
const y = observations.map(o => o.price);
const X_full = observations.map(o => [o.gas_price, o.demand, o.gas_share, o.dc_capacity]);
const fullResult = olsRegression(y, X_full, ['gas_price', 'demand', 'gas_share', 'dc_capacity']);
// Reduced model: price ~ gas_price + demand + gas_share (no DC capacity)
const X_reduced = observations.map(o => [o.gas_price, o.demand, o.gas_share]);
const reducedResult = olsRegression(y, X_reduced, ['gas_price', 'demand', 'gas_share']);
if (!fullResult || !reducedResult) {
return { ok: false, error: 'Regression failed — singular matrix or insufficient variation' };
}
// Partial R² for DC capacity = (R²_full - R²_reduced) / (1 - R²_reduced)
const r2PartialDc = reducedResult.r2 < 1 ? (fullResult.r2 - reducedResult.r2) / (1 - reducedResult.r2) : 0;
// Compute residuals from the reduced model (fuel + demand only)
// These residuals show what's left after accounting for fuel costs and demand
const residuals = y.map((yi, i) => yi - reducedResult.predicted[i]!);
// Aggregate to per-region points for the scatter chart
const regionAgg = new Map<
string,
{
name: string;
prices: number[];
residuals: number[];
gasPrices: number[];
demands: number[];
gasShare: number;
dcCap: number;
}
>();
for (let i = 0; i < observations.length; i++) {
const obs = observations[i]!;
const existing = regionAgg.get(obs.region_code) ?? {
name: obs.region_name,
prices: [],
residuals: [],
gasPrices: [],
demands: [],
gasShare: obs.gas_share,
dcCap: obs.dc_capacity,
};
existing.prices.push(obs.price);
existing.residuals.push(residuals[i]!);
existing.gasPrices.push(obs.gas_price);
existing.demands.push(obs.demand);
regionAgg.set(obs.region_code, existing);
}
const points: MultivariateDataPoint[] = Array.from(regionAgg.entries()).map(([code, agg]) => {
const avg = (arr: number[]) => arr.reduce((s, v) => s + v, 0) / arr.length;
return {
region_code: code,
region_name: agg.name,
dc_capacity_mw: agg.dcCap,
avg_price: Number(avg(agg.prices).toFixed(2)),
avg_gas_price: Number(avg(agg.gasPrices).toFixed(2)),
avg_demand: Number(avg(agg.demands).toFixed(0)),
gas_share: Number(agg.gasShare.toFixed(3)),
residual: Number(avg(agg.residuals).toFixed(2)),
};
});
return {
ok: true,
data: serialize({
points,
coefficients: fullResult.coefficients,
r2_full: Number(fullResult.r2.toFixed(4)),
r2_without_dc: Number(reducedResult.r2.toFixed(4)),
r2_partial_dc: Number(r2PartialDc.toFixed(4)),
n: observations.length,
}),
};
} catch (err) {
return {
ok: false,
error: `Failed to run multivariate analysis: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
export async function fetchCapacityPriceTimeline(
regionCode: string,
): Promise<ActionResult<getCapacityPriceTimeline.Result[]>> {
'use cache';
cacheLife('prices');
cacheTag(`capacity-price-timeline-${regionCode}`);
try {
if (!validateRegionCode(regionCode)) {
return { ok: false, error: `Invalid region code: ${regionCode}` };
}
const rows = await prisma.$queryRawTyped(getCapacityPriceTimeline(regionCode));
return { ok: true, data: serialize(rows) };
} catch (err) {
return {
ok: false,
error: `Failed to fetch capacity/price timeline: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
export async function fetchDcPriceImpact(): Promise<ActionResult<getDcPriceImpact.Result[]>> {
'use cache';
cacheLife('prices');
cacheTag('dc-price-impact');
try {
const rows = await prisma.$queryRawTyped(getDcPriceImpact());
return { ok: true, data: serialize(rows) };
} catch (err) {
return {
ok: false,
error: `Failed to fetch DC price impact: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
// ---------------------------------------------------------------------------
// Demand Growth Attribution — DC share of regional load growth
// ---------------------------------------------------------------------------
export interface DemandGrowthRow {
region_code: string;
region_name: string;
/** Average demand in earliest available year (MW) */
baseline_demand_mw: number;
/** Average demand in most recent year (MW) */
current_demand_mw: number;
/** Total demand growth (MW) */
demand_growth_mw: number;
/** Total DC capacity added in this period (MW) */
dc_capacity_added_mw: number;
/** Estimated DC share of demand growth (fraction 01) */
dc_share_of_growth: number;
/** Annualized demand growth rate (%) */
growth_rate_pct: number;
}
export async function fetchDemandGrowthAttribution(): Promise<ActionResult<DemandGrowthRow[]>> {
'use cache';
cacheLife('prices');
cacheTag('demand-growth-attribution');
try {
// Get average demand per region per year
const yearlyDemand = await prisma.$queryRaw<
Array<{ region_code: string; region_name: string; yr: number; avg_demand: number }>
>`
SELECT
r.code AS region_code,
r.name AS region_name,
EXTRACT(YEAR FROM ep.timestamp)::int AS yr,
AVG(ep.demand_mw) AS avg_demand
FROM electricity_prices ep
JOIN grid_regions r ON ep.region_id = r.id
GROUP BY r.code, r.name, EXTRACT(YEAR FROM ep.timestamp)
HAVING COUNT(*) >= 50
ORDER BY r.code, yr
`;
// Get DC capacity by region, grouped by year_opened
const dcCapacity = await prisma.$queryRaw<
Array<{ region_code: string; year_opened: number; total_capacity: number }>
>`
SELECT
r.code AS region_code,
d.year_opened,
SUM(d.capacity_mw) AS total_capacity
FROM datacenters d
JOIN grid_regions r ON d.region_id = r.id
GROUP BY r.code, d.year_opened
`;
// Build per-region results
const byRegion = new Map<string, typeof yearlyDemand>();
for (const row of yearlyDemand) {
const arr = byRegion.get(row.region_code) ?? [];
arr.push(row);
byRegion.set(row.region_code, arr);
}
const dcByRegion = new Map<string, typeof dcCapacity>();
for (const row of dcCapacity) {
const arr = dcByRegion.get(row.region_code) ?? [];
arr.push(row);
dcByRegion.set(row.region_code, arr);
}
const results: DemandGrowthRow[] = [];
for (const [code, years] of byRegion.entries()) {
if (years.length < 2) continue;
const sorted = years.sort((a, b) => a.yr - b.yr);
const earliest = sorted[0]!;
const latest = sorted[sorted.length - 1]!;
const baselineDemand = Number(earliest.avg_demand);
const currentDemand = Number(latest.avg_demand);
const demandGrowth = currentDemand - baselineDemand;
const yearSpan = latest.yr - earliest.yr;
// Sum DC capacity added in this period
const dcEntries = dcByRegion.get(code) ?? [];
const dcAdded = dcEntries
.filter(d => d.year_opened >= earliest.yr && d.year_opened <= latest.yr)
.reduce((sum, d) => sum + Number(d.total_capacity), 0);
// DC load is typically ~85% of nameplate capacity (high utilization)
const dcLoadMw = dcAdded * 0.85;
const dcShareOfGrowth = demandGrowth > 0 ? Math.min(1, dcLoadMw / demandGrowth) : 0;
const growthRate =
yearSpan > 0 && baselineDemand > 0 ? ((currentDemand / baselineDemand) ** (1 / yearSpan) - 1) * 100 : 0;
results.push({
region_code: code,
region_name: earliest.region_name,
baseline_demand_mw: Number(baselineDemand.toFixed(0)),
current_demand_mw: Number(currentDemand.toFixed(0)),
demand_growth_mw: Number(demandGrowth.toFixed(0)),
dc_capacity_added_mw: Number(dcAdded.toFixed(0)),
dc_share_of_growth: Number(dcShareOfGrowth.toFixed(3)),
growth_rate_pct: Number(growthRate.toFixed(2)),
});
}
results.sort((a, b) => b.dc_share_of_growth - a.dc_share_of_growth);
return { ok: true, data: serialize(results) };
} catch (err) {
return {
ok: false,
error: `Failed to fetch demand growth attribution: ${err instanceof Error ? err.message : String(err)}`,
};
}
}