fixes and tweaks to iran war content

This commit is contained in:
Joey Eamigh 2026-04-05 20:53:33 -04:00
parent 2846a1305e
commit 2be824405d
No known key found for this signature in database
GPG Key ID: CE8C05DFFC53C9CB
22 changed files with 1774 additions and 75 deletions

View File

@ -9,12 +9,17 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
# Prisma client + TypedSQL must be pre-generated locally (needs live DB for --sql). # Prisma client + TypedSQL must be pre-generated locally (needs live DB for --sql).
# src/generated/ is gitignored but included in Docker context from local dev. # src/generated/ is gitignored but included in Docker context from local dev.
ENV DATABASE_URL="postgresql://placeholder:placeholder@localhost:5432/placeholder" ARG DATABASE_URL
ARG NEXT_PUBLIC_GOOGLE_MAPS_API_KEY ARG NEXT_PUBLIC_GOOGLE_MAPS_API_KEY
ARG NEXT_PUBLIC_GOOGLE_MAP_ID ARG NEXT_PUBLIC_GOOGLE_MAP_ID
ARG EIA_API_KEY
ARG FRED_API_KEY
ENV DATABASE_URL=$DATABASE_URL
ENV NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=$NEXT_PUBLIC_GOOGLE_MAPS_API_KEY ENV NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=$NEXT_PUBLIC_GOOGLE_MAPS_API_KEY
ENV NEXT_PUBLIC_GOOGLE_MAP_ID=$NEXT_PUBLIC_GOOGLE_MAP_ID ENV NEXT_PUBLIC_GOOGLE_MAP_ID=$NEXT_PUBLIC_GOOGLE_MAP_ID
RUN npx next build ENV EIA_API_KEY=$EIA_API_KEY
ENV FRED_API_KEY=$FRED_API_KEY
RUN --network=host npx next build
FROM node:22-slim AS runner FROM node:22-slim AS runner
WORKDIR /app WORKDIR /app

View File

@ -1,4 +1,5 @@
-- Before/after price comparison for each datacenter opening event (year_opened >= 2019) -- Before/after price comparison for each datacenter opening event (year_opened >= 2019)
-- Includes natural gas price context for the same time windows
WITH dc_events AS ( WITH dc_events AS (
SELECT SELECT
d.name AS dc_name, d.name AS dc_name,
@ -32,6 +33,26 @@ price_after AS (
AND ep.timestamp >= de.event_date AND ep.timestamp >= de.event_date
AND ep.timestamp < de.event_date + INTERVAL '6 months' AND ep.timestamp < de.event_date + INTERVAL '6 months'
GROUP BY de.dc_name GROUP BY de.dc_name
),
gas_before AS (
SELECT
de.dc_name,
AVG(cp.price) AS avg_gas_before
FROM dc_events de
JOIN commodity_prices cp ON cp.commodity = 'natural_gas'
AND cp.timestamp >= de.event_date - INTERVAL '6 months'
AND cp.timestamp < de.event_date
GROUP BY de.dc_name
),
gas_after AS (
SELECT
de.dc_name,
AVG(cp.price) AS avg_gas_after
FROM dc_events de
JOIN commodity_prices cp ON cp.commodity = 'natural_gas'
AND cp.timestamp >= de.event_date
AND cp.timestamp < de.event_date + INTERVAL '6 months'
GROUP BY de.dc_name
) )
SELECT SELECT
de.dc_name, de.dc_name,
@ -45,10 +66,19 @@ SELECT
WHEN pb.avg_price_before > 0 WHEN pb.avg_price_before > 0
THEN ((pa.avg_price_after - pb.avg_price_before) / pb.avg_price_before * 100) THEN ((pa.avg_price_after - pb.avg_price_before) / pb.avg_price_before * 100)
ELSE NULL ELSE NULL
END AS pct_change END AS pct_change,
gb.avg_gas_before,
ga.avg_gas_after,
CASE
WHEN gb.avg_gas_before > 0
THEN ((ga.avg_gas_after - gb.avg_gas_before) / gb.avg_gas_before * 100)
ELSE NULL
END AS gas_pct_change
FROM dc_events de FROM dc_events de
LEFT JOIN price_before pb ON pb.dc_name = de.dc_name LEFT JOIN price_before pb ON pb.dc_name = de.dc_name
LEFT JOIN price_after pa ON pa.dc_name = de.dc_name LEFT JOIN price_after pa ON pa.dc_name = de.dc_name
LEFT JOIN gas_before gb ON gb.dc_name = de.dc_name
LEFT JOIN gas_after ga ON ga.dc_name = de.dc_name
WHERE pb.avg_price_before IS NOT NULL WHERE pb.avg_price_before IS NOT NULL
AND pa.avg_price_after IS NOT NULL AND pa.avg_price_after IS NOT NULL
ORDER BY ABS(COALESCE(pa.avg_price_after - pb.avg_price_before, 0)) DESC ORDER BY ABS(COALESCE(pa.avg_price_after - pb.avg_price_before, 0)) DESC

38
scripts/build-and-push.sh Executable file
View File

@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
IMAGE="registry.claiborne.soy/busi488energy:latest"
KCTX="media"
DB_LOCAL_PORT=5434
# Source .env for build args
set -a
source "$(dirname "$0")/../.env"
set +a
# Start port-forward to prod DB
echo "==> Port-forwarding postgis on :${DB_LOCAL_PORT}..."
kubectl --context "$KCTX" -n database port-forward svc/postgis "${DB_LOCAL_PORT}:5432" &
PF_PID=$!
trap "kill $PF_PID 2>/dev/null || true" EXIT
sleep 2
BUILD_DATABASE_URL="postgresql://busi488energy:busi488energy@host.docker.internal:${DB_LOCAL_PORT}/busi488energy"
echo "==> Building ${IMAGE}..."
docker build --network=host \
--build-arg DATABASE_URL="postgresql://busi488energy:busi488energy@127.0.0.1:${DB_LOCAL_PORT}/busi488energy" \
--build-arg NEXT_PUBLIC_GOOGLE_MAPS_API_KEY="$NEXT_PUBLIC_GOOGLE_MAPS_API_KEY" \
--build-arg NEXT_PUBLIC_GOOGLE_MAP_ID="$NEXT_PUBLIC_GOOGLE_MAP_ID" \
--build-arg EIA_API_KEY="$EIA_API_KEY" \
--build-arg FRED_API_KEY="$FRED_API_KEY" \
-t "$IMAGE" .
echo "==> Pushing ${IMAGE}..."
docker push "$IMAGE"
echo "==> Restarting deployment..."
kubectl --context "$KCTX" -n random rollout restart deploy/busi488energy
kubectl --context "$KCTX" -n random rollout status deploy/busi488energy --timeout=120s
echo "==> Done!"

View File

@ -574,6 +574,57 @@ export async function fetchNgFuturesCurve(): Promise<ActionResult<FuturesCurveRo
} }
} }
// ---------------------------------------------------------------------------
// STEO Energy Projections (Short-Term Energy Outlook)
// ---------------------------------------------------------------------------
export interface SteoProjectionRow {
period: string;
seriesId: string;
label: string;
value: number;
unit: string;
category: string;
timestamp: number;
isForecast: boolean;
}
export async function fetchSteoProjections(): Promise<ActionResult<SteoProjectionRow[]>> {
'use cache';
cacheLife('conflict');
cacheTag('steo-projections');
try {
const { getSteoProjections, STEO_SERIES } = await import('@/lib/api/eia.js');
const data = await getSteoProjections({ start: '2025-01' });
// Current month — anything after this is a forecast
const now = new Date();
const currentMonth = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}`;
const rows: SteoProjectionRow[] = data.map(d => {
const meta = STEO_SERIES[d.seriesId];
return {
period: d.period,
seriesId: d.seriesId,
label: meta?.label ?? d.description,
value: d.value,
unit: meta?.unit ?? d.unit,
category: meta?.category ?? 'other',
timestamp: d.timestamp.getTime(),
isForecast: d.period > currentMonth,
};
});
return { ok: true, data: serialize(rows) };
} catch (err) {
return {
ok: false,
error: `Failed to fetch STEO projections: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Conflict Hero Metrics (latest values for key indicators) // Conflict Hero Metrics (latest values for key indicators)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -18,6 +18,317 @@ interface ActionError {
type ActionResult<T> = ActionSuccess<T> | ActionError; 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( export async function fetchCapacityPriceTimeline(
regionCode: string, regionCode: string,
): Promise<ActionResult<getCapacityPriceTimeline.Result[]>> { ): Promise<ActionResult<getCapacityPriceTimeline.Result[]>> {
@ -54,3 +365,122 @@ export async function fetchDcPriceImpact(): Promise<ActionResult<getDcPriceImpac
}; };
} }
} }
// ---------------------------------------------------------------------------
// 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)}`,
};
}
}

View File

@ -57,7 +57,6 @@ export async function ConflictHeroMetrics() {
alertThreshold={100} alertThreshold={100}
/> />
<MetricCard label="WTI Crude" value={metrics.wtiPrice} unit="$/bbl" alertThreshold={100} /> <MetricCard label="WTI Crude" value={metrics.wtiPrice} unit="$/bbl" alertThreshold={100} />
<MetricCard label="Dubai Crude" value={metrics.dubaiPrice} unit="$/bbl" alertThreshold={100} />
<MetricCard label="WTI-Brent Spread" value={metrics.wtiBrentSpread} unit="$/bbl" alertThreshold={5} /> <MetricCard label="WTI-Brent Spread" value={metrics.wtiBrentSpread} unit="$/bbl" alertThreshold={5} />
<MetricCard label="Oil Volatility" value={metrics.ovxLevel} unit="OVX" alertThreshold={40} /> <MetricCard label="Oil Volatility" value={metrics.ovxLevel} unit="OVX" alertThreshold={40} />
<MetricCard label="US Gasoline" value={metrics.gasolinePrice} unit="$/gal" alertThreshold={4.0} /> <MetricCard label="US Gasoline" value={metrics.gasolinePrice} unit="$/gal" alertThreshold={4.0} />

View File

@ -5,14 +5,21 @@ import { deserialize } from '@/lib/superjson.js';
import type { MarketIndicatorRow } from '@/actions/conflict.js'; import type { MarketIndicatorRow } from '@/actions/conflict.js';
/** Compute percentile-based thresholds from historical data */ /**
function computeThresholds(prices: number[], p50 = 0.6, p90 = 0.85): [number, number] { * Absolute thresholds based on well-established historical interpretation scales.
if (prices.length < 5) return [0, 0]; * Percentile-based thresholds are unreliable with limited/skewed data windows.
const sorted = [...prices].sort((a, b) => a - b); *
const idx50 = Math.floor(sorted.length * p50); * VIX: <20 calm, 20-30 elevated, >30 extreme (>40 = panic)
const idx90 = Math.floor(sorted.length * p90); * OVX: <25 calm, 25-40 elevated, >40 extreme
return [sorted[idx50]!, sorted[idx90]!]; * DXY: <100 weak, 100-110 elevated, >110 strong (inverted strong $ hurts commodities)
} * STLFSI: <0 below-avg stress, 0-1.5 elevated, >1.5 extreme (>3 = crisis)
*/
const ABSOLUTE_THRESHOLDS: Record<string, [number, number]> = {
ovx: [25, 40],
vix: [20, 30],
dxy: [100, 110],
financial_stress: [0, 1.5],
};
export async function MarketFearSection() { export async function MarketFearSection() {
const result = await fetchMarketIndicators('1y'); const result = await fetchMarketIndicators('1y');
@ -20,14 +27,6 @@ export async function MarketFearSection() {
const rows = deserialize<MarketIndicatorRow[]>(result.data); const rows = deserialize<MarketIndicatorRow[]>(result.data);
// Collect all values per commodity for threshold computation
const historyByType = new Map<string, number[]>();
for (const row of rows) {
const arr = historyByType.get(row.commodity) ?? [];
arr.push(row.price);
historyByType.set(row.commodity, arr);
}
// Get latest value for each indicator (data is sorted by timestamp asc) // Get latest value for each indicator (data is sorted by timestamp asc)
const latestByType = new Map<string, number>(); const latestByType = new Map<string, number>();
for (let i = rows.length - 1; i >= 0; i--) { for (let i = rows.length - 1; i >= 0; i--) {
@ -37,26 +36,18 @@ export async function MarketFearSection() {
} }
} }
// Compute data-driven thresholds (60th and 85th percentile of 1-year data) const ovxThresholds = ABSOLUTE_THRESHOLDS['ovx']!;
// Fall back to static thresholds if insufficient data const vixThresholds = ABSOLUTE_THRESHOLDS['vix']!;
const ovxThresholds = historyByType.has('ovx') const dxyThresholds = ABSOLUTE_THRESHOLDS['dxy']!;
? computeThresholds(historyByType.get('ovx')!) const stressThresholds = ABSOLUTE_THRESHOLDS['financial_stress']!;
: ([25, 40] as [number, number]);
const vixThresholds = historyByType.has('vix')
? computeThresholds(historyByType.get('vix')!)
: ([20, 35] as [number, number]);
const dxyThresholds = historyByType.has('dxy')
? computeThresholds(historyByType.get('dxy')!)
: ([100, 120] as [number, number]);
const stressThresholds = historyByType.has('financial_stress')
? computeThresholds(historyByType.get('financial_stress')!)
: ([1, 3] as [number, number]);
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Market Fear Indicators</CardTitle> <CardTitle>Market Fear Indicators</CardTitle>
<CardDescription>Gauges colored by 1-year percentile trends (60th = elevated, 85th = extreme)</CardDescription> <CardDescription>
Gauges colored by historical absolute thresholds (VIX &gt;30, OVX &gt;40, STLFSI &gt;1.5 = extreme)
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">

View File

@ -0,0 +1,16 @@
import { fetchSteoProjections } from '@/actions/conflict.js';
import { SteoProjectionsChart } from '@/components/charts/steo-projections-chart.js';
export async function SteoSection() {
const result = await fetchSteoProjections();
if (!result.ok) {
return (
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-border">
<p className="text-sm text-destructive">{result.error}</p>
</div>
);
}
return <SteoProjectionsChart data={result.data} />;
}

View File

@ -14,6 +14,7 @@ import { ConflictHeroMetrics } from './_sections/hero-metrics.js';
import { MarketFearSection } from './_sections/market-fear-section.js'; import { MarketFearSection } from './_sections/market-fear-section.js';
import { NormalizedSection } from './_sections/normalized-section.js'; import { NormalizedSection } from './_sections/normalized-section.js';
import { OilSection } from './_sections/oil-section.js'; import { OilSection } from './_sections/oil-section.js';
import { SteoSection } from './_sections/steo-section.js';
import { SupplySection } from './_sections/supply-section.js'; import { SupplySection } from './_sections/supply-section.js';
import { WarPremiumSection } from './_sections/war-premium-section.js'; import { WarPremiumSection } from './_sections/war-premium-section.js';
@ -113,6 +114,11 @@ export default function ConflictPage() {
<CrackSpreadSection /> <CrackSpreadSection />
</Suspense> </Suspense>
{/* STEO Energy Projections — forecast vs actual */}
<Suspense fallback={<ChartSkeleton />}>
<SteoSection />
</Suspense>
{/* Market Fear Indicators */} {/* Market Fear Indicators */}
<Suspense fallback={<GaugeSkeleton />}> <Suspense fallback={<GaugeSkeleton />}>
<MarketFearSection /> <MarketFearSection />

View File

@ -0,0 +1,16 @@
import { fetchDemandGrowthAttribution } from '@/actions/dc-impact.js';
import { DemandGrowthChart } from '@/components/charts/demand-growth-chart.js';
export async function DemandGrowthSection() {
const result = await fetchDemandGrowthAttribution();
if (!result.ok) {
return (
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-border">
<p className="text-sm text-destructive">{result.error}</p>
</div>
);
}
return <DemandGrowthChart data={result.data} />;
}

View File

@ -0,0 +1,16 @@
import { fetchMultivariateAnalysis } from '@/actions/dc-impact.js';
import { ResidualChart } from '@/components/charts/residual-chart.js';
export async function MultivariateSection() {
const result = await fetchMultivariateAnalysis();
if (!result.ok) {
return (
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-border">
<p className="text-sm text-destructive">{result.error}</p>
</div>
);
}
return <ResidualChart data={result.data} />;
}

View File

@ -5,6 +5,8 @@ import type { Metadata } from 'next';
import { CorrelationSection } from './_sections/correlation-section.js'; import { CorrelationSection } from './_sections/correlation-section.js';
import { DcImpactSection } from './_sections/dc-impact-section.js'; import { DcImpactSection } from './_sections/dc-impact-section.js';
import { DemandGrowthSection } from './_sections/demand-growth-section.js';
import { MultivariateSection } from './_sections/multivariate-section.js';
import { PriceChartSection } from './_sections/price-chart-section.js'; import { PriceChartSection } from './_sections/price-chart-section.js';
export const metadata: Metadata = { export const metadata: Metadata = {
@ -30,9 +32,20 @@ export default function TrendsPage() {
<DcImpactSection /> <DcImpactSection />
</Suspense> </Suspense>
{/* Multivariate analysis: price residuals after controlling for fuel/demand */}
<Suspense fallback={<ChartSkeleton />}>
<MultivariateSection />
</Suspense>
{/* Simple bivariate correlation for comparison */}
<Suspense fallback={<ChartSkeleton />}> <Suspense fallback={<ChartSkeleton />}>
<CorrelationSection /> <CorrelationSection />
</Suspense> </Suspense>
{/* Demand growth attribution: DC share of load growth */}
<Suspense fallback={<ChartSkeleton />}>
<DemandGrowthSection />
</Suspense>
</div> </div>
); );
} }

View File

@ -33,6 +33,9 @@ interface RegionRow {
avg_price_after: number; avg_price_after: number;
pct_change: number; pct_change: number;
increased: boolean; increased: boolean;
avg_gas_before: number | null;
avg_gas_after: number | null;
gas_pct_change: number | null;
} }
interface DcRow { interface DcRow {
@ -44,16 +47,38 @@ interface DcRow {
avg_price_after: number; avg_price_after: number;
pct_change: number; pct_change: number;
increased: boolean; increased: boolean;
avg_gas_before: number | null;
avg_gas_after: number | null;
gas_pct_change: number | null;
} }
function aggregateByRegion(rows: getDcPriceImpact.Result[]): RegionRow[] { function aggregateByRegion(rows: getDcPriceImpact.Result[]): RegionRow[] {
const byRegion = new Map<string, { before: number[]; after: number[]; count: number; capacity: number }>(); const byRegion = new Map<
string,
{
before: number[];
after: number[];
gasBefore: number[];
gasAfter: number[];
count: number;
capacity: number;
}
>();
for (const r of rows) { for (const r of rows) {
if (r.avg_price_before === null || r.avg_price_after === null) continue; if (r.avg_price_before === null || r.avg_price_after === null) continue;
const existing = byRegion.get(r.region_code) ?? { before: [], after: [], count: 0, capacity: 0 }; const existing = byRegion.get(r.region_code) ?? {
before: [],
after: [],
gasBefore: [],
gasAfter: [],
count: 0,
capacity: 0,
};
existing.before.push(r.avg_price_before); existing.before.push(r.avg_price_before);
existing.after.push(r.avg_price_after); existing.after.push(r.avg_price_after);
if (r.avg_gas_before !== null) existing.gasBefore.push(r.avg_gas_before);
if (r.avg_gas_after !== null) existing.gasAfter.push(r.avg_gas_after);
existing.count++; existing.count++;
existing.capacity += r.capacity_mw; existing.capacity += r.capacity_mw;
byRegion.set(r.region_code, existing); byRegion.set(r.region_code, existing);
@ -64,6 +89,14 @@ function aggregateByRegion(rows: getDcPriceImpact.Result[]): RegionRow[] {
const avgBefore = data.before.reduce((s, v) => s + v, 0) / data.before.length; const avgBefore = data.before.reduce((s, v) => s + v, 0) / data.before.length;
const avgAfter = data.after.reduce((s, v) => s + v, 0) / data.after.length; const avgAfter = data.after.reduce((s, v) => s + v, 0) / data.after.length;
const pctChange = avgBefore > 0 ? ((avgAfter - avgBefore) / avgBefore) * 100 : 0; const pctChange = avgBefore > 0 ? ((avgAfter - avgBefore) / avgBefore) * 100 : 0;
const avgGasBefore =
data.gasBefore.length > 0 ? data.gasBefore.reduce((s, v) => s + v, 0) / data.gasBefore.length : null;
const avgGasAfter =
data.gasAfter.length > 0 ? data.gasAfter.reduce((s, v) => s + v, 0) / data.gasAfter.length : null;
const gasPctChange =
avgGasBefore && avgGasBefore > 0 && avgGasAfter !== null
? ((avgGasAfter - avgGasBefore) / avgGasBefore) * 100
: null;
return { return {
region_code: code, region_code: code,
dc_count: data.count, dc_count: data.count,
@ -72,6 +105,9 @@ function aggregateByRegion(rows: getDcPriceImpact.Result[]): RegionRow[] {
avg_price_after: avgAfter, avg_price_after: avgAfter,
pct_change: pctChange, pct_change: pctChange,
increased: avgAfter >= avgBefore, increased: avgAfter >= avgBefore,
avg_gas_before: avgGasBefore,
avg_gas_after: avgGasAfter,
gas_pct_change: gasPctChange,
}; };
}) })
.sort((a, b) => Math.abs(b.pct_change) - Math.abs(a.pct_change)); .sort((a, b) => Math.abs(b.pct_change) - Math.abs(a.pct_change));
@ -89,10 +125,27 @@ function transformDcData(rows: getDcPriceImpact.Result[]): DcRow[] {
avg_price_after: r.avg_price_after!, avg_price_after: r.avg_price_after!,
pct_change: r.pct_change ?? 0, pct_change: r.pct_change ?? 0,
increased: (r.avg_price_after ?? 0) >= (r.avg_price_before ?? 0), increased: (r.avg_price_after ?? 0) >= (r.avg_price_before ?? 0),
avg_gas_before: r.avg_gas_before,
avg_gas_after: r.avg_gas_after,
gas_pct_change: r.gas_pct_change,
})) }))
.sort((a, b) => Math.abs(b.pct_change) - Math.abs(a.pct_change)); .sort((a, b) => Math.abs(b.pct_change) - Math.abs(a.pct_change));
} }
/** Small inline indicator showing gas price movement direction */
function GasContext({ gasPct, elecPct }: { gasPct: number | null; elecPct: number }) {
if (gasPct === null) return null;
const sameDirection = (gasPct >= 0 && elecPct >= 0) || (gasPct < 0 && elecPct < 0);
return (
<span
className={cn('ml-1 text-[9px] tabular-nums', sameDirection ? 'text-muted-foreground/60' : 'text-amber-400/80')}
title={`Gas ${gasPct >= 0 ? '+' : ''}${gasPct.toFixed(1)}% in same period${sameDirection ? ' (moved same direction — confounded)' : ' (moved opposite direction — DC effect likely real)'}`}>
{gasPct >= 0 ? '+' : ''}
{gasPct.toFixed(0)}%
</span>
);
}
export function DcPriceImpactBars({ data }: DcPriceImpactBarsProps) { export function DcPriceImpactBars({ data }: DcPriceImpactBarsProps) {
const [viewMode, setViewMode] = useState<'region' | 'datacenter'>('region'); const [viewMode, setViewMode] = useState<'region' | 'datacenter'>('region');
const rawRows = useMemo(() => deserialize<getDcPriceImpact.Result[]>(data), [data]); const rawRows = useMemo(() => deserialize<getDcPriceImpact.Result[]>(data), [data]);
@ -107,6 +160,21 @@ export function DcPriceImpactBars({ data }: DcPriceImpactBarsProps) {
return barData.reduce((sum, r) => sum + r.pct_change, 0) / barData.length; return barData.reduce((sum, r) => sum + r.pct_change, 0) / barData.length;
}, [barData]); }, [barData]);
// Count how many entries have gas moving same direction vs opposite
const confoundedCount = useMemo(() => {
let same = 0;
let opposite = 0;
for (const row of barData) {
if (row.gas_pct_change === null) continue;
if ((row.gas_pct_change >= 0 && row.pct_change >= 0) || (row.gas_pct_change < 0 && row.pct_change < 0)) {
same++;
} else {
opposite++;
}
}
return { same, opposite };
}, [barData]);
if (barData.length === 0) { if (barData.length === 0) {
return ( return (
<Card> <Card>
@ -184,16 +252,25 @@ export function DcPriceImpactBars({ data }: DcPriceImpactBarsProps) {
<ChartTooltip <ChartTooltip
content={ content={
<ChartTooltipContent <ChartTooltipContent
labelFormatter={label => { labelFormatter={_label => {
const labelStr = String(label); const labelStr =
typeof _label === 'string' ? _label : typeof _label === 'number' ? String(_label) : '';
if (viewMode === 'region') { if (viewMode === 'region') {
const region = regionData.find(d => d.region_code === labelStr); const region = regionData.find(d => d.region_code === labelStr);
if (!region) return labelStr; if (!region) return labelStr;
return `${region.region_code}${region.dc_count} DCs, ${region.total_capacity_mw} MW total`; const gasTxt =
region.gas_pct_change !== null
? ` | Gas: ${region.gas_pct_change >= 0 ? '+' : ''}${region.gas_pct_change.toFixed(1)}%`
: '';
return `${region.region_code}${region.dc_count} DCs, ${region.total_capacity_mw} MW${gasTxt}`;
} }
const item = dcData.find(d => d.dc_name === labelStr); const item = dcData.find(d => d.dc_name === labelStr);
if (!item) return labelStr; if (!item) return labelStr;
return `${item.dc_name} (${item.region_code}, ${item.year_opened}) — ${item.capacity_mw} MW`; const gasTxt =
item.gas_pct_change !== null
? ` | Gas: ${item.gas_pct_change >= 0 ? '+' : ''}${item.gas_pct_change.toFixed(1)}%`
: '';
return `${item.dc_name} (${item.region_code}, ${item.year_opened}) — ${item.capacity_mw} MW${gasTxt}`;
}} }}
formatter={(value, name) => { formatter={(value, name) => {
const numVal = Number(value); const numVal = Number(value);
@ -214,7 +291,25 @@ export function DcPriceImpactBars({ data }: DcPriceImpactBarsProps) {
</BarChart> </BarChart>
</ChartContainer> </ChartContainer>
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-muted-foreground"> {/* Gas price context annotations below chart */}
{viewMode === 'region' && (
<div className="mt-3 flex flex-wrap gap-2">
{regionData.map(row => (
<div
key={row.region_code}
className="flex items-center gap-1 rounded border border-border/30 px-2 py-0.5 text-[10px]">
<span className="font-medium">{row.region_code}</span>
<span className={row.pct_change >= 0 ? 'text-red-400/80' : 'text-emerald-400/80'}>
{row.pct_change >= 0 ? '+' : ''}
{row.pct_change.toFixed(0)}%
</span>
<GasContext gasPct={row.gas_pct_change} elecPct={row.pct_change} />
</div>
))}
</div>
)}
<div className="mt-3 flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: 'hsl(210, 70%, 50%)' }} /> <span className="h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: 'hsl(210, 70%, 50%)' }} />
Before DC Opening Before DC Opening
@ -227,9 +322,20 @@ export function DcPriceImpactBars({ data }: DcPriceImpactBarsProps) {
<span className="h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: 'hsl(145, 60%, 45%)' }} /> <span className="h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: 'hsl(145, 60%, 45%)' }} />
After (Price Decreased) After (Price Decreased)
</div> </div>
<div className="flex items-center gap-1.5">
<span className="text-[10px]"></span>
Gas price change (same period)
</div>
</div> </div>
<p className="mt-3 rounded-md border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-xs leading-relaxed text-amber-300/80"> <p className="mt-3 rounded-md border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-xs leading-relaxed text-amber-300/80">
{confoundedCount.same > 0 && (
<>
In {confoundedCount.same} of {confoundedCount.same + confoundedCount.opposite} cases, gas prices moved in
the same direction as electricity prices the apparent DC effect may be confounded by fuel cost
changes.{' '}
</>
)}
Correlation does not imply causation. Electricity prices are influenced by many factors including fuel costs, Correlation does not imply causation. Electricity prices are influenced by many factors including fuel costs,
weather, grid congestion, regulatory changes, and seasonal demand patterns. weather, grid congestion, regulatory changes, and seasonal demand patterns.
</p> </p>

View File

@ -0,0 +1,164 @@
'use client';
import { useMemo } from 'react';
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import type { SuperJSONResult } from 'superjson';
import type { DemandGrowthRow } from '@/actions/dc-impact.js';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js';
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart.js';
import { deserialize } from '@/lib/superjson.js';
import { cn } from '@/lib/utils.js';
interface DemandGrowthChartProps {
data: SuperJSONResult;
}
const chartConfig: ChartConfig = {
dc_load: {
label: 'DC Load Contribution',
color: 'hsl(25, 90%, 55%)',
},
other_growth: {
label: 'Other Growth',
color: 'hsl(210, 70%, 50%)',
},
};
export function DemandGrowthChart({ data }: DemandGrowthChartProps) {
const rows = useMemo(() => deserialize<DemandGrowthRow[]>(data), [data]);
const chartData = useMemo(
() =>
rows
.filter(r => r.demand_growth_mw !== 0 || r.dc_capacity_added_mw > 0)
.map(r => {
const dcLoad = r.dc_capacity_added_mw * 0.85; // 85% utilization
const otherGrowth = Math.max(0, r.demand_growth_mw - dcLoad);
return {
region_code: r.region_code,
dc_load: Number(dcLoad.toFixed(0)),
other_growth: Number(otherGrowth.toFixed(0)),
total_growth: r.demand_growth_mw,
dc_share: r.dc_share_of_growth,
growth_rate: r.growth_rate_pct,
dc_capacity: r.dc_capacity_added_mw,
};
})
.sort((a, b) => b.dc_share - a.dc_share),
[rows],
);
const totalDcLoad = useMemo(() => chartData.reduce((sum, r) => sum + r.dc_load, 0), [chartData]);
const totalGrowth = useMemo(() => chartData.reduce((sum, r) => sum + r.total_growth, 0), [chartData]);
const overallDcShare = totalGrowth > 0 ? totalDcLoad / totalGrowth : 0;
if (chartData.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Demand Growth Attribution</CardTitle>
<CardDescription>No demand growth data available.</CardDescription>
</CardHeader>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>Demand Growth Attribution</CardTitle>
<CardDescription>
Regional load growth decomposed into datacenter contribution vs. other sources
</CardDescription>
</div>
<div className="flex gap-2">
<div className="rounded-lg border border-border/50 bg-muted/30 px-3 py-2 text-center">
<span className="block text-[10px] text-muted-foreground">DC Share of Growth</span>
<span
className={cn(
'text-sm font-bold tabular-nums',
overallDcShare > 0.3 ? 'text-amber-400' : 'text-foreground',
)}>
{(overallDcShare * 100).toFixed(1)}%
</span>
</div>
<div className="rounded-lg border border-border/50 bg-muted/30 px-3 py-2 text-center">
<span className="block text-[10px] text-muted-foreground">Total DC Load</span>
<span className="text-sm font-bold text-foreground tabular-nums">
{totalDcLoad >= 1000 ? `${(totalDcLoad / 1000).toFixed(1)} GW` : `${totalDcLoad} MW`}
</span>
</div>
</div>
</div>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[40vh] max-h-[500px] min-h-[250px] w-full">
<BarChart data={chartData} margin={{ top: 10, right: 20, left: 10, bottom: 20 }} barCategoryGap="20%">
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" vertical={false} />
<XAxis
dataKey="region_code"
tick={{ fontSize: 12, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
interval={0}
/>
<YAxis
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}GW` : `${v}MW`)}
label={{
value: 'Load Growth (MW)',
angle: -90,
position: 'insideLeft',
style: { fontSize: 11, fill: 'var(--color-muted-foreground)' },
}}
/>
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={_label => {
const labelStr =
typeof _label === 'string' ? _label : typeof _label === 'number' ? String(_label) : '';
const row = chartData.find(d => d.region_code === labelStr);
if (!row) return labelStr;
return `${row.region_code}${row.dc_capacity} MW DC capacity, ${row.growth_rate.toFixed(1)}%/yr growth`;
}}
formatter={(value, name) => {
const v = Number(value);
const nameStr = String(name);
if (nameStr === 'dc_load') return [`${v} MW (DC load at 85% utilization)`, undefined];
if (nameStr === 'other_growth') return [`${v} MW (non-DC growth)`, undefined];
return [`${v}`, undefined];
}}
/>
}
/>
<Bar dataKey="other_growth" stackId="growth" fill="var(--color-other_growth)" radius={[0, 0, 0, 0]} />
<Bar dataKey="dc_load" stackId="growth" fill="var(--color-dc_load)" radius={[3, 3, 0, 0]} />
</BarChart>
</ChartContainer>
<div className="mt-3 flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: 'hsl(25, 90%, 55%)' }} />
Datacenter Load (85% of nameplate)
</div>
<div className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: 'hsl(210, 70%, 50%)' }} />
Other Demand Growth
</div>
</div>
<p className="mt-3 rounded-md border border-blue-500/20 bg-blue-500/5 px-3 py-2 text-xs leading-relaxed text-blue-300/80">
DC load estimated at 85% of nameplate capacity (high-utilization facilities). Regions where DCs account for a
large share of load growth are more likely to see price pressure from datacenter demand, independent of fuel
cost changes.
</p>
</CardContent>
</Card>
);
}

View File

@ -30,7 +30,6 @@ const ALL_SERIES = [
{ key: 'ovx', label: 'OVX', color: 'hsl(280, 70%, 60%)' }, { key: 'ovx', label: 'OVX', color: 'hsl(280, 70%, 60%)' },
{ key: 'vix', label: 'VIX', color: 'hsl(320, 60%, 55%)' }, { key: 'vix', label: 'VIX', color: 'hsl(320, 60%, 55%)' },
{ key: 'gpr_daily', label: 'GPR Index', color: 'hsl(45, 90%, 50%)' }, { key: 'gpr_daily', label: 'GPR Index', color: 'hsl(45, 90%, 50%)' },
{ key: 'dubai_crude', label: 'Dubai Crude', color: 'hsl(160, 60%, 50%)' },
] as const; ] as const;
const chartConfig: ChartConfig = Object.fromEntries(ALL_SERIES.map(s => [s.key, { label: s.label, color: s.color }])); const chartConfig: ChartConfig = Object.fromEntries(ALL_SERIES.map(s => [s.key, { label: s.label, color: s.color }]));

View File

@ -29,14 +29,12 @@ interface PivotedRow {
label: string; label: string;
wti_crude?: number; wti_crude?: number;
brent_crude?: number; brent_crude?: number;
dubai_crude?: number;
spread?: number; spread?: number;
} }
const chartConfig: ChartConfig = { const chartConfig: ChartConfig = {
brent_crude: { label: 'Brent Crude', color: 'hsl(0, 70%, 55%)' }, brent_crude: { label: 'Brent Crude', color: 'hsl(0, 70%, 55%)' },
wti_crude: { label: 'WTI Crude', color: 'hsl(210, 80%, 55%)' }, wti_crude: { label: 'WTI Crude', color: 'hsl(210, 80%, 55%)' },
dubai_crude: { label: 'Dubai Crude', color: 'hsl(160, 60%, 50%)' },
spread: { label: 'WTI-Brent Spread', color: 'hsl(45, 80%, 55%)' }, spread: { label: 'WTI-Brent Spread', color: 'hsl(45, 80%, 55%)' },
}; };
@ -59,7 +57,6 @@ export function OilChart({ oilData, events }: OilChartProps) {
const pivot = byDay.get(dayKey)!; const pivot = byDay.get(dayKey)!;
if (row.commodity === 'wti_crude') pivot.wti_crude = row.price; if (row.commodity === 'wti_crude') pivot.wti_crude = row.price;
if (row.commodity === 'brent_crude') pivot.brent_crude = row.price; if (row.commodity === 'brent_crude') pivot.brent_crude = row.price;
if (row.commodity === 'dubai_crude') pivot.dubai_crude = row.price;
} }
const result = Array.from(byDay.values()).sort((a, b) => a.timestamp - b.timestamp); const result = Array.from(byDay.values()).sort((a, b) => a.timestamp - b.timestamp);
@ -102,7 +99,7 @@ export function OilChart({ oilData, events }: OilChartProps) {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<CardTitle>Oil Prices</CardTitle> <CardTitle>Oil Prices</CardTitle>
<CardDescription>WTI, Brent & Dubai crude benchmarks with geopolitical event annotations</CardDescription> <CardDescription>WTI & Brent crude benchmarks with geopolitical event annotations</CardDescription>
</div> </div>
<button <button
onClick={() => setShowSpread(prev => !prev)} onClick={() => setShowSpread(prev => !prev)}
@ -183,15 +180,6 @@ export function OilChart({ oilData, events }: OilChartProps) {
dot={false} dot={false}
connectNulls connectNulls
/> />
<Line
type="monotone"
dataKey="dubai_crude"
stroke="var(--color-dubai_crude)"
strokeWidth={1.5}
dot={false}
connectNulls
strokeDasharray="4 2"
/>
</LineChart> </LineChart>
)} )}
</ChartContainer> </ChartContainer>

View File

@ -0,0 +1,302 @@
'use client';
import { useMemo } from 'react';
import { CartesianGrid, ComposedChart, Label, Line, Scatter, XAxis, YAxis, ZAxis } from 'recharts';
import type { SuperJSONResult } from 'superjson';
import type { MultivariateResult, RegressionCoefficient } from '@/actions/dc-impact.js';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js';
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart.js';
import { deserialize } from '@/lib/superjson.js';
import { cn } from '@/lib/utils.js';
interface ResidualChartProps {
data: SuperJSONResult;
}
const REGION_COLORS: Record<string, string> = {
PJM: 'hsl(210, 90%, 55%)',
ERCOT: 'hsl(25, 90%, 55%)',
CAISO: 'hsl(140, 70%, 45%)',
NYISO: 'hsl(280, 70%, 55%)',
ISONE: 'hsl(340, 70%, 55%)',
MISO: 'hsl(55, 80%, 50%)',
SPP: 'hsl(180, 60%, 45%)',
BPA: 'hsl(95, 55%, 50%)',
NWMT: 'hsl(310, 50%, 55%)',
WAPA: 'hsl(165, 50%, 50%)',
TVA: 'hsl(15, 70%, 50%)',
DUKE: 'hsl(240, 55%, 60%)',
SOCO: 'hsl(350, 55%, 50%)',
FPC: 'hsl(45, 60%, 45%)',
};
const chartConfig: ChartConfig = {
residual: {
label: 'Price Residual vs DC Capacity',
color: 'hsl(210, 90%, 55%)',
},
};
interface ScatterPoint {
region_code: string;
dc_capacity_mw: number;
residual: number;
fill: string;
z: number;
}
function linearRegression(
points: ScatterPoint[],
): { slope: number; intercept: number; r2: number; line: { x: number; y: number }[] } | null {
const n = points.length;
if (n < 2) return null;
let sumX = 0,
sumY = 0,
sumXY = 0,
sumX2 = 0;
for (const p of points) {
sumX += p.dc_capacity_mw;
sumY += p.residual;
sumXY += p.dc_capacity_mw * p.residual;
sumX2 += p.dc_capacity_mw * p.dc_capacity_mw;
}
const denom = n * sumX2 - sumX * sumX;
if (denom === 0) return null;
const slope = (n * sumXY - sumX * sumY) / denom;
const intercept = (sumY - slope * sumX) / n;
const meanY = sumY / n;
const ssTot = points.reduce((s, p) => s + (p.residual - meanY) ** 2, 0);
const ssRes = points.reduce((s, p) => s + (p.residual - (slope * p.dc_capacity_mw + intercept)) ** 2, 0);
const r2 = ssTot === 0 ? 0 : 1 - ssRes / ssTot;
const xs = points.map(p => p.dc_capacity_mw);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
return {
slope,
intercept,
r2,
line: [
{ x: minX, y: slope * minX + intercept },
{ x: maxX, y: slope * maxX + intercept },
],
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function getNumberProp(obj: Record<string, unknown>, key: string): number {
const val = obj[key];
return typeof val === 'number' ? val : 0;
}
function CustomDot(props: unknown): React.JSX.Element {
if (!isRecord(props)) return <g />;
const cx = getNumberProp(props, 'cx');
const cy = getNumberProp(props, 'cy');
const payload = isRecord(props['payload']) ? props['payload'] : null;
if (!payload) return <g />;
const fill = typeof payload['fill'] === 'string' ? payload['fill'] : 'hsl(0,0%,50%)';
const regionCode = typeof payload['region_code'] === 'string' ? payload['region_code'] : '';
const z = getNumberProp(payload, 'z');
const radius = Math.max(6, Math.min(20, z / 80));
return (
<g>
<circle cx={cx} cy={cy} r={radius} fill={fill} fillOpacity={0.6} stroke={fill} strokeWidth={2} />
<text
x={cx}
y={cy - radius - 6}
textAnchor="middle"
fill="var(--color-foreground)"
fontSize={11}
fontWeight={600}>
{regionCode}
</text>
</g>
);
}
function CoefficientBadge({ coeff }: { coeff: RegressionCoefficient }) {
const displayNames: Record<string, string> = {
gas_price: 'Gas Price',
demand: 'Demand',
gas_share: 'Gas Share',
dc_capacity: 'DC Capacity',
};
return (
<div className="flex items-center gap-2 rounded-md border border-border/50 bg-muted/30 px-2 py-1">
<span className="text-[10px] text-muted-foreground">{displayNames[coeff.variable] ?? coeff.variable}</span>
<span
className={cn(
'text-xs font-semibold tabular-nums',
Math.abs(coeff.beta) > 0.3 ? 'text-amber-400' : 'text-muted-foreground',
)}>
β={coeff.beta.toFixed(3)}
</span>
</div>
);
}
export function ResidualChart({ data }: ResidualChartProps) {
const result = useMemo(() => deserialize<MultivariateResult>(data), [data]);
const scatterData: ScatterPoint[] = useMemo(
() =>
result.points.map(p => ({
region_code: p.region_code,
dc_capacity_mw: p.dc_capacity_mw,
residual: p.residual,
fill: REGION_COLORS[p.region_code] ?? 'hsl(0, 0%, 50%)',
z: Math.max(p.dc_capacity_mw, 100),
})),
[result],
);
const regression = useMemo(() => linearRegression(scatterData), [scatterData]);
const combinedData = useMemo(() => {
type CombinedPoint = {
region_code: string;
dc_capacity_mw: number;
fill: string;
z: number;
residual?: number;
trendResidual?: number;
};
const scattered: CombinedPoint[] = scatterData.map(p => ({ ...p }));
if (!regression) return scattered;
const trendPoints: CombinedPoint[] = regression.line.map(t => ({
region_code: '',
dc_capacity_mw: t.x,
fill: '',
z: 0,
trendResidual: Number(t.y.toFixed(2)),
}));
return [...scattered, ...trendPoints].sort((a, b) => a.dc_capacity_mw - b.dc_capacity_mw);
}, [scatterData, regression]);
if (result.points.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Price Residuals vs. DC Capacity</CardTitle>
<CardDescription>Insufficient data for multivariate analysis.</CardDescription>
</CardHeader>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>Price Residuals vs. DC Capacity</CardTitle>
<CardDescription>
Electricity price after removing fuel cost and demand effects ({result.n} monthly observations)
</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="rounded-lg border border-border/50 bg-muted/30 px-3 py-2 text-center">
<span className="block text-[10px] text-muted-foreground">Full Model R²</span>
<span className="text-sm font-bold text-foreground tabular-nums">{result.r2_full.toFixed(3)}</span>
</div>
<div className="rounded-lg border border-border/50 bg-muted/30 px-3 py-2 text-center">
<span className="block text-[10px] text-muted-foreground">Fuel+Demand R²</span>
<span className="text-sm font-bold text-foreground tabular-nums">{result.r2_without_dc.toFixed(3)}</span>
</div>
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-center">
<span className="block text-[10px] text-amber-300/80">DC Partial R²</span>
<span className="text-sm font-bold text-amber-400 tabular-nums">{result.r2_partial_dc.toFixed(4)}</span>
</div>
</div>
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
{result.coefficients.map(c => (
<CoefficientBadge key={c.variable} coeff={c} />
))}
</div>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[40vh] max-h-[500px] min-h-[250px] w-full">
<ComposedChart data={combinedData} margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" />
<XAxis
type="number"
dataKey="dc_capacity_mw"
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => `${v} MW`}>
<Label
value="DC Capacity (MW)"
offset={-10}
position="insideBottom"
style={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
/>
</XAxis>
<YAxis
type="number"
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
tickFormatter={(v: number) => `$${v}`}
domain={['auto', 'auto']}>
<Label
value="Price Residual ($/MWh)"
angle={-90}
position="insideLeft"
style={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
/>
</YAxis>
<ZAxis dataKey="z" range={[100, 1600]} />
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value, name) => {
const nameStr = String(name);
const v = Number(value);
if (nameStr === 'DC Capacity') return [`${v} MW`, undefined];
if (nameStr === 'Residual') return [`$${v.toFixed(2)}/MWh`, undefined];
return [String(value), undefined];
}}
/>
}
/>
{regression && (
<Line
type="linear"
dataKey="trendResidual"
stroke="color-mix(in oklch, var(--color-foreground) 30%, transparent)"
strokeWidth={2}
strokeDasharray="8 4"
dot={false}
connectNulls
isAnimationActive={false}
/>
)}
<Scatter name="Regions" dataKey="residual" data={scatterData} shape={CustomDot} />
</ComposedChart>
</ChartContainer>
<p className="mt-3 rounded-md border border-blue-500/20 bg-blue-500/5 px-3 py-2 text-xs leading-relaxed text-blue-300/80">
This chart shows the portion of electricity price variation <strong>not explained</strong> by natural gas
prices, demand levels, and generation mix. If DC capacity independently affects prices, regions with more
datacenter capacity should show positive residuals. The partial R² measures how much additional price
variation DC capacity explains beyond fuel and demand factors.
</p>
</CardContent>
</Card>
);
}

View File

@ -1,12 +1,13 @@
'use client'; 'use client';
import { useMemo } from 'react'; import { useMemo, useState } from 'react';
import { Area, AreaChart, CartesianGrid, ReferenceLine, XAxis, YAxis } from 'recharts'; import { Area, AreaChart, CartesianGrid, ReferenceLine, XAxis, YAxis } from 'recharts';
import type { SuperJSONResult } from 'superjson'; import type { SuperJSONResult } from 'superjson';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js';
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart.js'; import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart.js';
import { deserialize } from '@/lib/superjson.js'; import { deserialize } from '@/lib/superjson.js';
import { cn } from '@/lib/utils.js';
import type { SupplyMetricRow } from '@/actions/conflict.js'; import type { SupplyMetricRow } from '@/actions/conflict.js';
@ -16,12 +17,58 @@ interface SPRChartProps {
const chartConfig: ChartConfig = { const chartConfig: ChartConfig = {
spr_level: { label: 'SPR Level', color: 'hsl(210, 80%, 55%)' }, spr_level: { label: 'SPR Level', color: 'hsl(210, 80%, 55%)' },
projected: { label: 'Projected', color: 'hsl(0, 70%, 55%)' },
}; };
/** Historical reference levels (million barrels) */ /** Historical reference levels (million barrels) */
const SPR_CAPACITY = 714; const SPR_CAPACITY = 714;
const SPR_1982_LOW = 264; const SPR_1982_LOW = 264;
/** Minimum operational SPR level — below this, deliverability degrades */
const SPR_MIN_OPERATIONAL = 100;
interface SPRDataPoint {
timestamp: number;
label: string;
spr_level?: number;
projected?: number;
}
/**
* Calculate drawdown rate and project days remaining.
* Uses last 30 days of data (or all available if less) to compute rate.
*/
function computeDrawdownMetrics(rows: SPRDataPoint[]) {
if (rows.length < 2) return null;
const lastRow = rows[rows.length - 1]!;
const current = lastRow.spr_level;
if (current === undefined) return null;
// Find the level 30 days ago (or closest)
const thirtyDaysAgo = lastRow.timestamp - 30 * 24 * 60 * 60 * 1000;
const recentStart = rows.find(r => r.timestamp >= thirtyDaysAgo) ?? rows[0]!;
if (recentStart.spr_level === undefined) return null;
const daysBetween = (lastRow.timestamp - recentStart.timestamp) / (24 * 60 * 60 * 1000);
if (daysBetween < 7) return null; // Need at least a week of data
const drawdownMbbl = recentStart.spr_level - current;
const dailyRate = drawdownMbbl / daysBetween;
if (dailyRate <= 0) return { current, dailyRate: 0, daysToEmpty: null, daysToMinOperational: null };
const daysToEmpty = current / dailyRate;
const daysToMinOperational = (current - SPR_MIN_OPERATIONAL) / dailyRate;
return {
current,
dailyRate,
daysToEmpty: Math.max(0, daysToEmpty),
daysToMinOperational: Math.max(0, daysToMinOperational),
};
}
export function SPRChart({ data }: SPRChartProps) { export function SPRChart({ data }: SPRChartProps) {
const rows = useMemo(() => { const rows = useMemo(() => {
const all = deserialize<SupplyMetricRow[]>(data); const all = deserialize<SupplyMetricRow[]>(data);
@ -35,6 +82,40 @@ export function SPRChart({ data }: SPRChartProps) {
.sort((a, b) => a.timestamp - b.timestamp); .sort((a, b) => a.timestamp - b.timestamp);
}, [data]); }, [data]);
const metrics = useMemo(() => computeDrawdownMetrics(rows), [rows]);
/** How many days old the latest SPR data point is */
const [now] = useState(() => Date.now());
const latestTs = rows.length > 0 ? rows[rows.length - 1]!.timestamp : null;
const dataAgeDays = latestTs !== null ? Math.floor((now - latestTs) / (24 * 60 * 60 * 1000)) : null;
const isStale = dataAgeDays !== null && dataAgeDays > 7;
// Generate projected drawdown line (extend 180 days from last data point)
const chartData: SPRDataPoint[] = useMemo(() => {
if (!metrics || metrics.dailyRate <= 0) return rows;
const lastRow = rows[rows.length - 1]!;
const projectedPoints: SPRDataPoint[] = [];
// Add the transition point (last real + projected start at same value)
const extendedRows = rows.map(r => ({ ...r, projected: undefined as number | undefined }));
extendedRows[extendedRows.length - 1]!.projected = lastRow.spr_level;
for (let dayOffset = 30; dayOffset <= 180; dayOffset += 30) {
const projTs = lastRow.timestamp + dayOffset * 24 * 60 * 60 * 1000;
const projDate = new Date(projTs);
const projLevel = Math.max(0, lastRow.spr_level - metrics.dailyRate * dayOffset);
projectedPoints.push({
timestamp: projTs,
label: projDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: '2-digit' }),
spr_level: undefined,
projected: Number(projLevel.toFixed(1)),
});
}
return [...extendedRows, ...projectedPoints];
}, [rows, metrics]);
if (rows.length === 0) { if (rows.length === 0) {
return ( return (
<Card> <Card>
@ -49,12 +130,53 @@ export function SPRChart({ data }: SPRChartProps) {
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Strategic Petroleum Reserve</CardTitle> <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<CardDescription>US SPR stock levels (million barrels)</CardDescription> <div>
<CardTitle>Strategic Petroleum Reserve</CardTitle>
<CardDescription>US SPR stock levels (million barrels) with drawdown projection</CardDescription>
{isStale && (
<p className="mt-1 text-[10px] text-amber-400/80">
Latest data is {dataAgeDays} days old EIA weekly report may be delayed. Will update automatically.
</p>
)}
</div>
{metrics && metrics.dailyRate > 0 && (
<div className="flex gap-2">
<div className="rounded-lg border border-border/50 bg-muted/30 px-3 py-2 text-center">
<span className="block text-[10px] text-muted-foreground">Drawdown Rate</span>
<span className="text-sm font-bold text-red-400 tabular-nums">
{metrics.dailyRate.toFixed(1)}M bbl/day
</span>
</div>
{metrics.daysToMinOperational !== null && (
<div
className={cn(
'rounded-lg border px-3 py-2 text-center',
metrics.daysToMinOperational < 180
? 'border-red-500/30 bg-red-500/10'
: 'border-border/50 bg-muted/30',
)}>
<span className="block text-[10px] text-muted-foreground">Days to Min Operational</span>
<span
className={cn(
'text-sm font-bold tabular-nums',
metrics.daysToMinOperational < 180 ? 'text-red-400' : 'text-foreground',
)}>
{Math.round(metrics.daysToMinOperational)}
</span>
</div>
)}
<div className="rounded-lg border border-border/50 bg-muted/30 px-3 py-2 text-center">
<span className="block text-[10px] text-muted-foreground">Current Level</span>
<span className="text-sm font-bold text-foreground tabular-nums">{metrics.current.toFixed(0)}M</span>
</div>
</div>
)}
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ChartContainer config={chartConfig} className="h-[30vh] max-h-80 min-h-50 w-full"> <ChartContainer config={chartConfig} className="h-[30vh] max-h-80 min-h-50 w-full">
<AreaChart data={rows} margin={{ top: 10, right: 30, left: 10, bottom: 0 }}> <AreaChart data={chartData} margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" /> <CartesianGrid strokeDasharray="3 3" className="stroke-border/30" />
<XAxis <XAxis
dataKey="label" dataKey="label"
@ -71,7 +193,16 @@ export function SPRChart({ data }: SPRChartProps) {
domain={[0, SPR_CAPACITY + 50]} domain={[0, SPR_CAPACITY + 50]}
/> />
<ChartTooltip <ChartTooltip
content={<ChartTooltipContent formatter={value => [`${Number(value).toFixed(1)}M barrels`, undefined]} />} content={
<ChartTooltipContent
formatter={(value, name) => {
const v = Number(value);
const nameStr = String(name);
if (nameStr === 'projected') return [`${v.toFixed(1)}M bbl (projected)`, undefined];
return [`${v.toFixed(1)}M barrels`, undefined];
}}
/>
}
/> />
<ReferenceLine <ReferenceLine
y={SPR_CAPACITY} y={SPR_CAPACITY}
@ -90,6 +221,17 @@ export function SPRChart({ data }: SPRChartProps) {
fill: 'hsl(0, 60%, 60%)', fill: 'hsl(0, 60%, 60%)',
}} }}
/> />
<ReferenceLine
y={SPR_MIN_OPERATIONAL}
stroke="hsl(0, 90%, 45%)"
strokeDasharray="3 3"
label={{
value: 'Min Operational (100M)',
position: 'insideBottomRight',
fontSize: 10,
fill: 'hsl(0, 90%, 55%)',
}}
/>
<Area <Area
type="monotone" type="monotone"
dataKey="spr_level" dataKey="spr_level"
@ -97,9 +239,28 @@ export function SPRChart({ data }: SPRChartProps) {
fillOpacity={0.2} fillOpacity={0.2}
stroke="var(--color-spr_level)" stroke="var(--color-spr_level)"
strokeWidth={2} strokeWidth={2}
connectNulls={false}
/>
<Area
type="monotone"
dataKey="projected"
fill="hsl(0, 70%, 55%)"
fillOpacity={0.08}
stroke="hsl(0, 70%, 55%)"
strokeWidth={2}
strokeDasharray="6 3"
connectNulls
/> />
</AreaChart> </AreaChart>
</ChartContainer> </ChartContainer>
{metrics && metrics.dailyRate > 0 && (
<p className="mt-3 rounded-md border border-red-500/20 bg-red-500/5 px-3 py-2 text-xs leading-relaxed text-red-300/80">
At the current drawdown rate of {metrics.dailyRate.toFixed(1)}M bbl/day, the SPR will reach minimum
operational levels (~100M bbl) in approximately {Math.round(metrics.daysToMinOperational ?? 0)} days. Below
this level, physical deliverability from salt dome caverns degrades significantly. The dashed red line shows
the projected trajectory if the current rate continues.
</p>
)}
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@ -0,0 +1,248 @@
'use client';
import { useMemo, useState } from 'react';
import { CartesianGrid, Line, LineChart, ReferenceLine, XAxis, YAxis } from 'recharts';
import type { SuperJSONResult } from 'superjson';
import type { SteoProjectionRow } from '@/actions/conflict.js';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js';
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart.js';
import { deserialize } from '@/lib/superjson.js';
import { cn } from '@/lib/utils.js';
interface SteoProjectionsChartProps {
data: SuperJSONResult;
}
type Category = 'electricity' | 'fuel' | 'demand';
const CATEGORY_LABELS: Record<Category, string> = {
electricity: 'Electricity Prices',
fuel: 'Fuel Prices',
demand: 'Electricity Demand',
};
const SERIES_COLORS: Record<string, string> = {
ELWHU_TX: 'hsl(25, 90%, 55%)',
ELWHU_PJ: 'hsl(210, 90%, 55%)',
ELWHU_CA: 'hsl(140, 70%, 45%)',
ELWHU_NY: 'hsl(280, 70%, 55%)',
ELWHU_NE: 'hsl(340, 70%, 55%)',
ELWHU_MW: 'hsl(55, 80%, 50%)',
ELWHU_SP: 'hsl(180, 60%, 45%)',
ESTCU_US: 'hsl(0, 70%, 55%)',
ELCOTWH: 'hsl(210, 80%, 55%)',
NGHHUUS: 'hsl(142, 60%, 50%)',
BREPUUS: 'hsl(0, 70%, 55%)',
};
// War start month
const WAR_MONTH = '2026-02';
interface PivotedRow {
[key: string]: number | string | boolean | undefined;
period: string;
label: string;
isForecast: boolean;
}
export function SteoProjectionsChart({ data }: SteoProjectionsChartProps) {
const rows = useMemo(() => deserialize<SteoProjectionRow[]>(data), [data]);
const [activeCategory, setActiveCategory] = useState<Category>('electricity');
const [hiddenSeries, setHiddenSeries] = useState<Set<string>>(new Set());
// Group by category
const seriesByCategory = useMemo(() => {
const map = new Map<string, Map<string, string>>(); // category -> seriesId -> label
for (const row of rows) {
if (!map.has(row.category)) map.set(row.category, new Map());
map.get(row.category)!.set(row.seriesId, row.label);
}
return map;
}, [rows]);
const activeSeries = useMemo(
() => Array.from(seriesByCategory.get(activeCategory)?.entries() ?? []),
[seriesByCategory, activeCategory],
);
// Pivot: one row per period, one column per series
const chartData = useMemo(() => {
const byPeriod = new Map<string, PivotedRow>();
for (const row of rows) {
if (row.category !== activeCategory) continue;
if (!byPeriod.has(row.period)) {
const d = new Date(row.timestamp);
byPeriod.set(row.period, {
period: row.period,
label: d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' }),
isForecast: row.isForecast,
});
}
byPeriod.get(row.period)![row.seriesId] = row.value;
}
return Array.from(byPeriod.values()).sort((a, b) => a.period.localeCompare(b.period));
}, [rows, activeCategory]);
const chartConfig: ChartConfig = useMemo(
() =>
Object.fromEntries(
activeSeries.map(([id, label]) => [id, { label, color: SERIES_COLORS[id] ?? 'hsl(0, 0%, 50%)' }]),
),
[activeSeries],
);
// Find the first forecast period for visual split
const firstForecast = chartData.find(d => d.isForecast)?.label;
const toggleSeries = (id: string) => {
setHiddenSeries(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
if (rows.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Energy Projections (STEO)</CardTitle>
<CardDescription>No STEO projection data available.</CardDescription>
</CardHeader>
</Card>
);
}
const categories = Array.from(seriesByCategory.keys()).filter(
(k): k is Category => k === 'electricity' || k === 'fuel' || k === 'demand',
);
return (
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>Energy Projections (STEO)</CardTitle>
<CardDescription>
EIA Short-Term Energy Outlook historical + 18-month forecast. Dashed region = projected.
</CardDescription>
</div>
<div className="flex gap-1.5">
{categories.map(cat => (
<button
key={cat}
onClick={() => {
setActiveCategory(cat);
setHiddenSeries(new Set());
}}
className={cn(
'rounded-full border px-3 py-1 text-xs font-medium transition-colors',
cat === activeCategory
? 'border-blue-500/30 bg-blue-500/10 text-blue-300'
: 'border-border text-muted-foreground',
)}>
{CATEGORY_LABELS[cat] ?? cat}
</button>
))}
</div>
</div>
<div className="mt-2 flex flex-wrap gap-1">
{activeSeries.map(([id, label]) => (
<button
key={id}
onClick={() => toggleSeries(id)}
className={cn(
'rounded-full border px-2 py-0.5 text-[10px] font-medium transition-colors',
hiddenSeries.has(id) ? 'border-border/30 text-muted-foreground/40 line-through' : 'border-border',
)}
style={{
color: hiddenSeries.has(id) ? undefined : SERIES_COLORS[id],
borderColor: `${SERIES_COLORS[id] ?? 'gray'}40`,
}}>
{label}
</button>
))}
</div>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig} className="h-[45vh] max-h-[550px] min-h-[300px] w-full">
<LineChart data={chartData} margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" />
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 11, fill: 'var(--color-muted-foreground)' }}
tickLine={false}
axisLine={false}
domain={['auto', 'auto']}
tickFormatter={(v: number) => {
if (activeCategory === 'demand') return `${v}`;
return `$${v}`;
}}
/>
{firstForecast && (
<ReferenceLine
x={firstForecast}
stroke="hsl(var(--border))"
strokeDasharray="4 4"
strokeWidth={1.5}
label={{
value: 'Forecast →',
position: 'insideTopRight',
style: { fontSize: 10, fill: 'var(--color-muted-foreground)' },
}}
/>
)}
<ReferenceLine
x={chartData.find(d => d.period >= WAR_MONTH)?.label}
stroke="hsl(0, 70%, 50%)"
strokeDasharray="4 4"
strokeWidth={1}
label={{
value: 'War Start',
position: 'insideTopLeft',
style: { fontSize: 9, fill: 'hsl(0, 70%, 50%)' },
}}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value, _name) => {
const v = Number(value);
if (activeCategory === 'demand') return [`${v.toFixed(1)} billion kWh`, undefined];
return [`$${v.toFixed(2)}`, undefined];
}}
/>
}
/>
{activeSeries.map(([id]) => (
<Line
key={id}
type="monotone"
dataKey={id}
stroke={SERIES_COLORS[id]}
strokeWidth={id === 'ESTCU_US' || id === 'ELCOTWH' || id === 'BREPUUS' ? 2.5 : 1.5}
dot={false}
connectNulls
hide={hiddenSeries.has(id)}
/>
))}
</LineChart>
</ChartContainer>
<p className="mt-3 rounded-md border border-blue-500/20 bg-blue-500/5 px-3 py-2 text-xs leading-relaxed text-blue-300/80">
Data source: EIA Short-Term Energy Outlook (STEO). Projections are model-based estimates updated monthly.
Values past the &quot;Forecast&quot; line are EIA projections, not observed data. The war started Feb 28
compare pre-war projections with post-war actuals to see the conflict&apos;s impact on forecasts.
</p>
</CardContent>
</Card>
);
}

View File

@ -13,9 +13,20 @@ const SPIKE_WARNING_SIGMA = 1.8;
const JUMP_PCT_THRESHOLD = 0.15; const JUMP_PCT_THRESHOLD = 0.15;
const CHECK_INTERVAL_MS = 60_000; const CHECK_INTERVAL_MS = 60_000;
/**
* Generate a unique key for each alert type.
* Alerts are deduplicated per session once shown, the same alert won't fire again
* unless the underlying condition changes (different sigma band or price direction).
*/
function alertKey(type: 'spike' | 'warning' | 'jump', regionCode: string): string {
return `${type}:${regionCode}`;
}
export function PriceAlertMonitor() { export function PriceAlertMonitor() {
const previousPricesRef = useRef<Map<string, number>>(new Map()); const previousPricesRef = useRef<Map<string, number>>(new Map());
const initialLoadRef = useRef(true); const initialLoadRef = useRef(true);
/** Set of alert keys already shown this session */
const shownAlertsRef = useRef<Set<string>>(new Set());
useEffect(() => { useEffect(() => {
async function checkForSpikes() { async function checkForSpikes() {
@ -24,6 +35,7 @@ export function PriceAlertMonitor() {
const prices = deserialize<getLatestPrices.Result[]>(result.data); const prices = deserialize<getLatestPrices.Result[]>(result.data);
const prevPrices = previousPricesRef.current; const prevPrices = previousPricesRef.current;
const shownAlerts = shownAlertsRef.current;
for (const p of prices) { for (const p of prices) {
const prevPrice = prevPrices.get(p.region_code); const prevPrice = prevPrices.get(p.region_code);
@ -36,24 +48,43 @@ export function PriceAlertMonitor() {
const sigmas = (p.price_mwh - avg) / sd; const sigmas = (p.price_mwh - avg) / sd;
if (sigmas >= SPIKE_CRITICAL_SIGMA) { if (sigmas >= SPIKE_CRITICAL_SIGMA) {
toast.error(`Price Spike: ${p.region_code}`, { const key = alertKey('spike', p.region_code);
description: `$${p.price_mwh.toFixed(2)}/MWh — ${sigmas.toFixed(1)}σ above 7-day avg ($${avg.toFixed(0)})`, if (!shownAlerts.has(key)) {
duration: 8000, shownAlerts.add(key);
}); toast.error(`Price Spike: ${p.region_code}`, {
description: `$${p.price_mwh.toFixed(2)}/MWh — ${sigmas.toFixed(1)}σ above 7-day avg ($${avg.toFixed(0)})`,
duration: 8000,
});
}
} else if (sigmas >= SPIKE_WARNING_SIGMA) { } else if (sigmas >= SPIKE_WARNING_SIGMA) {
toast.warning(`Elevated Price: ${p.region_code}`, { const key = alertKey('warning', p.region_code);
description: `$${p.price_mwh.toFixed(2)}/MWh — ${sigmas.toFixed(1)}σ above 7-day avg ($${avg.toFixed(0)})`, if (!shownAlerts.has(key)) {
duration: 6000, shownAlerts.add(key);
}); toast.warning(`Elevated Price: ${p.region_code}`, {
description: `$${p.price_mwh.toFixed(2)}/MWh — ${sigmas.toFixed(1)}σ above 7-day avg ($${avg.toFixed(0)})`,
duration: 6000,
});
}
} else {
// Price returned to normal — clear alerts so they can fire again if it spikes later
shownAlerts.delete(alertKey('spike', p.region_code));
shownAlerts.delete(alertKey('warning', p.region_code));
} }
} }
if (prevPrice !== undefined && prevPrice > 0 && p.price_mwh > prevPrice * (1 + JUMP_PCT_THRESHOLD)) { if (prevPrice !== undefined && prevPrice > 0 && p.price_mwh > prevPrice * (1 + JUMP_PCT_THRESHOLD)) {
const jumpPct = ((p.price_mwh - prevPrice) / prevPrice) * 100; const key = alertKey('jump', p.region_code);
toast.warning(`Price Jump: ${p.region_code}`, { if (!shownAlerts.has(key)) {
description: `$${p.price_mwh.toFixed(2)}/MWh — up ${jumpPct.toFixed(1)}% from last reading`, shownAlerts.add(key);
duration: 6000, const jumpPct = ((p.price_mwh - prevPrice) / prevPrice) * 100;
}); toast.warning(`Price Jump: ${p.region_code}`, {
description: `$${p.price_mwh.toFixed(2)}/MWh — up ${jumpPct.toFixed(1)}% from last reading`,
duration: 6000,
});
}
} else {
// No jump — allow future jump alerts for this region
shownAlerts.delete(alertKey('jump', p.region_code));
} }
} }

View File

@ -102,7 +102,6 @@ const COMMODITY_LABELS: Record<string, string> = {
vix: 'VIX', vix: 'VIX',
dxy: 'DXY', dxy: 'DXY',
financial_stress: 'Fin Stress', financial_stress: 'Fin Stress',
dubai_crude: 'Dubai',
ng_futures_1: 'NG F1', ng_futures_1: 'NG F1',
ng_futures_2: 'NG F2', ng_futures_2: 'NG F2',
ng_futures_3: 'NG F3', ng_futures_3: 'NG F3',

View File

@ -593,3 +593,93 @@ export async function getRetailElectricityPrices(options: GetRetailPriceOptions
return results; return results;
} }
// ---------------------------------------------------------------------------
// EIA STEO — Short-Term Energy Outlook (monthly projections)
// ---------------------------------------------------------------------------
/** STEO series IDs we fetch for energy price/demand projections */
export const STEO_SERIES: Record<string, { label: string; unit: string; category: string }> = {
// Wholesale electricity prices by region
ELWHU_TX: { label: 'Wholesale Elec — ERCOT', unit: '$/MWh', category: 'electricity' },
ELWHU_PJ: { label: 'Wholesale Elec — PJM', unit: '$/MWh', category: 'electricity' },
ELWHU_CA: { label: 'Wholesale Elec — CAISO', unit: '$/MWh', category: 'electricity' },
ELWHU_NY: { label: 'Wholesale Elec — NYISO', unit: '$/MWh', category: 'electricity' },
ELWHU_NE: { label: 'Wholesale Elec — ISONE', unit: '$/MWh', category: 'electricity' },
ELWHU_MW: { label: 'Wholesale Elec — Midwest', unit: '$/MWh', category: 'electricity' },
ELWHU_SP: { label: 'Wholesale Elec — SPP', unit: '$/MWh', category: 'electricity' },
// Retail electricity
ESTCU_US: { label: 'Retail Elec — All Sectors', unit: 'cents/kWh', category: 'electricity' },
// Demand
ELCOTWH: { label: 'US Electricity Consumption', unit: 'billion kWh', category: 'demand' },
// Fuel
NGHHUUS: { label: 'Henry Hub Gas Price', unit: '$/MMBtu', category: 'fuel' },
BREPUUS: { label: 'Brent Crude Price', unit: '$/barrel', category: 'fuel' },
};
const steoRowSchema = z.object({
period: z.string(),
seriesId: z.string(),
seriesDescription: z.string(),
value: z.union([z.string(), z.number(), z.null()]),
unit: z.string(),
});
const steoResponseSchema = z.object({
response: z.object({
total: z.coerce.number(),
data: z.array(steoRowSchema),
}),
});
export interface SteoDataPoint {
period: string; // YYYY-MM
seriesId: string;
description: string;
value: number;
unit: string;
timestamp: Date;
}
/**
* Fetch STEO monthly projections for key energy series.
* Returns historical + forecast data (typically 18 months out from current).
*/
export async function getSteoProjections(options: { start?: string; end?: string } = {}): Promise<SteoDataPoint[]> {
const seriesIds = Object.keys(STEO_SERIES);
const params: EiaQueryParams = {
frequency: 'monthly',
start: options.start ?? '2025-01',
end: options.end,
facets: { seriesId: seriesIds },
sort: [{ column: 'period', direction: 'asc' }],
};
const rows = await fetchAllPages('/steo/data/', params, json => {
const parsed = steoResponseSchema.parse(json);
return { total: parsed.response.total, data: parsed.response.data };
});
const results: SteoDataPoint[] = [];
for (const row of rows) {
const val = row.value;
const numVal = typeof val === 'number' ? val : typeof val === 'string' ? parseFloat(val) : NaN;
if (isNaN(numVal)) continue;
// Period is YYYY-MM, convert to first of month
const [year, month] = row.period.split('-').map(Number);
if (!year || !month) continue;
results.push({
period: row.period,
seriesId: row.seriesId,
description: row.seriesDescription,
value: numVal,
unit: row.unit,
timestamp: new Date(Date.UTC(year, month - 1, 1)),
});
}
return results;
}