'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 { ok: true; data: ReturnType>; } interface ActionError { ok: false; error: string; } type ActionResult = ActionSuccess | 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 0–1) */ 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({ 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({ 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> { '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>` 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>` 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> { '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> { '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 0–1) */ dc_share_of_growth: number; /** Annualized demand growth rate (%) */ growth_rate_pct: number; } export async function fetchDemandGrowthAttribution(): Promise> { '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(); 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(); 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)}`, }; } }