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 . .
# 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.
ENV DATABASE_URL="postgresql://placeholder:placeholder@localhost:5432/placeholder"
ARG DATABASE_URL
ARG NEXT_PUBLIC_GOOGLE_MAPS_API_KEY
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_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
WORKDIR /app

View File

@ -1,4 +1,5 @@
-- 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 (
SELECT
d.name AS dc_name,
@ -32,6 +33,26 @@ price_after AS (
AND ep.timestamp >= de.event_date
AND ep.timestamp < de.event_date + INTERVAL '6 months'
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
de.dc_name,
@ -45,10 +66,19 @@ SELECT
WHEN pb.avg_price_before > 0
THEN ((pa.avg_price_after - pb.avg_price_before) / pb.avg_price_before * 100)
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
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 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
AND pa.avg_price_after IS NOT NULL
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)
// ---------------------------------------------------------------------------

View File

@ -18,6 +18,317 @@ interface 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(
regionCode: string,
): 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}
/>
<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="Oil Volatility" value={metrics.ovxLevel} unit="OVX" alertThreshold={40} />
<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';
/** Compute percentile-based thresholds from historical data */
function computeThresholds(prices: number[], p50 = 0.6, p90 = 0.85): [number, number] {
if (prices.length < 5) return [0, 0];
const sorted = [...prices].sort((a, b) => a - b);
const idx50 = Math.floor(sorted.length * p50);
const idx90 = Math.floor(sorted.length * p90);
return [sorted[idx50]!, sorted[idx90]!];
}
/**
* Absolute thresholds based on well-established historical interpretation scales.
* Percentile-based thresholds are unreliable with limited/skewed data windows.
*
* VIX: <20 calm, 20-30 elevated, >30 extreme (>40 = panic)
* OVX: <25 calm, 25-40 elevated, >40 extreme
* 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() {
const result = await fetchMarketIndicators('1y');
@ -20,14 +27,6 @@ export async function MarketFearSection() {
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)
const latestByType = new Map<string, number>();
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)
// Fall back to static thresholds if insufficient data
const ovxThresholds = historyByType.has('ovx')
? computeThresholds(historyByType.get('ovx')!)
: ([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]);
const ovxThresholds = ABSOLUTE_THRESHOLDS['ovx']!;
const vixThresholds = ABSOLUTE_THRESHOLDS['vix']!;
const dxyThresholds = ABSOLUTE_THRESHOLDS['dxy']!;
const stressThresholds = ABSOLUTE_THRESHOLDS['financial_stress']!;
return (
<Card>
<CardHeader>
<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>
<CardContent>
<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 { NormalizedSection } from './_sections/normalized-section.js';
import { OilSection } from './_sections/oil-section.js';
import { SteoSection } from './_sections/steo-section.js';
import { SupplySection } from './_sections/supply-section.js';
import { WarPremiumSection } from './_sections/war-premium-section.js';
@ -113,6 +114,11 @@ export default function ConflictPage() {
<CrackSpreadSection />
</Suspense>
{/* STEO Energy Projections — forecast vs actual */}
<Suspense fallback={<ChartSkeleton />}>
<SteoSection />
</Suspense>
{/* Market Fear Indicators */}
<Suspense fallback={<GaugeSkeleton />}>
<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 { 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';
export const metadata: Metadata = {
@ -30,9 +32,20 @@ export default function TrendsPage() {
<DcImpactSection />
</Suspense>
{/* Multivariate analysis: price residuals after controlling for fuel/demand */}
<Suspense fallback={<ChartSkeleton />}>
<MultivariateSection />
</Suspense>
{/* Simple bivariate correlation for comparison */}
<Suspense fallback={<ChartSkeleton />}>
<CorrelationSection />
</Suspense>
{/* Demand growth attribution: DC share of load growth */}
<Suspense fallback={<ChartSkeleton />}>
<DemandGrowthSection />
</Suspense>
</div>
);
}

View File

@ -33,6 +33,9 @@ interface RegionRow {
avg_price_after: number;
pct_change: number;
increased: boolean;
avg_gas_before: number | null;
avg_gas_after: number | null;
gas_pct_change: number | null;
}
interface DcRow {
@ -44,16 +47,38 @@ interface DcRow {
avg_price_after: number;
pct_change: number;
increased: boolean;
avg_gas_before: number | null;
avg_gas_after: number | null;
gas_pct_change: number | null;
}
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) {
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.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.capacity += r.capacity_mw;
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 avgAfter = data.after.reduce((s, v) => s + v, 0) / data.after.length;
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 {
region_code: code,
dc_count: data.count,
@ -72,6 +105,9 @@ function aggregateByRegion(rows: getDcPriceImpact.Result[]): RegionRow[] {
avg_price_after: avgAfter,
pct_change: pctChange,
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));
@ -89,10 +125,27 @@ function transformDcData(rows: getDcPriceImpact.Result[]): DcRow[] {
avg_price_after: r.avg_price_after!,
pct_change: r.pct_change ?? 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));
}
/** 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) {
const [viewMode, setViewMode] = useState<'region' | 'datacenter'>('region');
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;
}, [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) {
return (
<Card>
@ -184,16 +252,25 @@ export function DcPriceImpactBars({ data }: DcPriceImpactBarsProps) {
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={label => {
const labelStr = String(label);
labelFormatter={_label => {
const labelStr =
typeof _label === 'string' ? _label : typeof _label === 'number' ? String(_label) : '';
if (viewMode === 'region') {
const region = regionData.find(d => d.region_code === 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);
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) => {
const numVal = Number(value);
@ -214,7 +291,25 @@ export function DcPriceImpactBars({ data }: DcPriceImpactBarsProps) {
</BarChart>
</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">
<span className="h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: 'hsl(210, 70%, 50%)' }} />
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%)' }} />
After (Price Decreased)
</div>
<div className="flex items-center gap-1.5">
<span className="text-[10px]"></span>
Gas price change (same period)
</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">
{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,
weather, grid congestion, regulatory changes, and seasonal demand patterns.
</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: 'vix', label: 'VIX', color: 'hsl(320, 60%, 55%)' },
{ key: 'gpr_daily', label: 'GPR Index', color: 'hsl(45, 90%, 50%)' },
{ key: 'dubai_crude', label: 'Dubai Crude', color: 'hsl(160, 60%, 50%)' },
] as const;
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;
wti_crude?: number;
brent_crude?: number;
dubai_crude?: number;
spread?: number;
}
const chartConfig: ChartConfig = {
brent_crude: { label: 'Brent Crude', color: 'hsl(0, 70%, 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%)' },
};
@ -59,7 +57,6 @@ export function OilChart({ oilData, events }: OilChartProps) {
const pivot = byDay.get(dayKey)!;
if (row.commodity === 'wti_crude') pivot.wti_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);
@ -102,7 +99,7 @@ export function OilChart({ oilData, events }: OilChartProps) {
<div className="flex items-center justify-between">
<div>
<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>
<button
onClick={() => setShowSpread(prev => !prev)}
@ -183,15 +180,6 @@ export function OilChart({ oilData, events }: OilChartProps) {
dot={false}
connectNulls
/>
<Line
type="monotone"
dataKey="dubai_crude"
stroke="var(--color-dubai_crude)"
strokeWidth={1.5}
dot={false}
connectNulls
strokeDasharray="4 2"
/>
</LineChart>
)}
</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';
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { Area, AreaChart, CartesianGrid, ReferenceLine, XAxis, YAxis } from 'recharts';
import type { SuperJSONResult } from 'superjson';
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';
import type { SupplyMetricRow } from '@/actions/conflict.js';
@ -16,12 +17,58 @@ interface SPRChartProps {
const chartConfig: ChartConfig = {
spr_level: { label: 'SPR Level', color: 'hsl(210, 80%, 55%)' },
projected: { label: 'Projected', color: 'hsl(0, 70%, 55%)' },
};
/** Historical reference levels (million barrels) */
const SPR_CAPACITY = 714;
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) {
const rows = useMemo(() => {
const all = deserialize<SupplyMetricRow[]>(data);
@ -35,6 +82,40 @@ export function SPRChart({ data }: SPRChartProps) {
.sort((a, b) => a.timestamp - b.timestamp);
}, [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) {
return (
<Card>
@ -49,12 +130,53 @@ export function SPRChart({ data }: SPRChartProps) {
return (
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>Strategic Petroleum Reserve</CardTitle>
<CardDescription>US SPR stock levels (million barrels)</CardDescription>
<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>
<CardContent>
<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" />
<XAxis
dataKey="label"
@ -71,7 +193,16 @@ export function SPRChart({ data }: SPRChartProps) {
domain={[0, SPR_CAPACITY + 50]}
/>
<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
y={SPR_CAPACITY}
@ -90,6 +221,17 @@ export function SPRChart({ data }: SPRChartProps) {
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
type="monotone"
dataKey="spr_level"
@ -97,9 +239,28 @@ export function SPRChart({ data }: SPRChartProps) {
fillOpacity={0.2}
stroke="var(--color-spr_level)"
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>
</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>
</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 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() {
const previousPricesRef = useRef<Map<string, number>>(new Map());
const initialLoadRef = useRef(true);
/** Set of alert keys already shown this session */
const shownAlertsRef = useRef<Set<string>>(new Set());
useEffect(() => {
async function checkForSpikes() {
@ -24,6 +35,7 @@ export function PriceAlertMonitor() {
const prices = deserialize<getLatestPrices.Result[]>(result.data);
const prevPrices = previousPricesRef.current;
const shownAlerts = shownAlertsRef.current;
for (const p of prices) {
const prevPrice = prevPrices.get(p.region_code);
@ -36,25 +48,44 @@ export function PriceAlertMonitor() {
const sigmas = (p.price_mwh - avg) / sd;
if (sigmas >= SPIKE_CRITICAL_SIGMA) {
const key = alertKey('spike', p.region_code);
if (!shownAlerts.has(key)) {
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) {
const key = alertKey('warning', p.region_code);
if (!shownAlerts.has(key)) {
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)) {
const key = alertKey('jump', p.region_code);
if (!shownAlerts.has(key)) {
shownAlerts.add(key);
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));
}
}
prevPrices.set(p.region_code, p.price_mwh);

View File

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

View File

@ -593,3 +593,93 @@ export async function getRetailElectricityPrices(options: GetRetailPriceOptions
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;
}