fixes and tweaks to iran war content
This commit is contained in:
parent
2846a1305e
commit
2be824405d
@ -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
|
||||||
|
|||||||
@ -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
38
scripts/build-and-push.sh
Executable 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!"
|
||||||
@ -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)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -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 0–1) */
|
||||||
|
gas_share: number;
|
||||||
|
/** Residual: price unexplained by fuel/demand model */
|
||||||
|
residual: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegressionCoefficient {
|
||||||
|
variable: string;
|
||||||
|
coefficient: number;
|
||||||
|
/** Standardized coefficient (beta weight) */
|
||||||
|
beta: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MultivariateResult {
|
||||||
|
points: MultivariateDataPoint[];
|
||||||
|
coefficients: RegressionCoefficient[];
|
||||||
|
r2_full: number;
|
||||||
|
r2_without_dc: number;
|
||||||
|
/** Partial R² attributable to DC capacity */
|
||||||
|
r2_partial_dc: number;
|
||||||
|
n: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OLS multiple regression using normal equations.
|
||||||
|
* X is a matrix of [n x k] predictors (with intercept column prepended).
|
||||||
|
* Returns coefficient vector, R², and predicted values.
|
||||||
|
*/
|
||||||
|
function olsRegression(
|
||||||
|
y: number[],
|
||||||
|
X: number[][],
|
||||||
|
varNames: string[],
|
||||||
|
): { coefficients: RegressionCoefficient[]; r2: number; predicted: number[] } | null {
|
||||||
|
const n = y.length;
|
||||||
|
const k = X[0]!.length;
|
||||||
|
if (n <= k) return null;
|
||||||
|
|
||||||
|
// Add intercept column
|
||||||
|
const Xa = X.map(row => [1, ...row]);
|
||||||
|
const ka = k + 1;
|
||||||
|
|
||||||
|
// X'X
|
||||||
|
const XtX: number[][] = Array.from({ length: ka }, () => Array.from<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 0–1) */
|
||||||
|
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)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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 >30, OVX >40, STLFSI >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">
|
||||||
|
|||||||
16
src/app/conflict/_sections/steo-section.tsx
Normal file
16
src/app/conflict/_sections/steo-section.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
@ -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 />
|
||||||
|
|||||||
16
src/app/trends/_sections/demand-growth-section.tsx
Normal file
16
src/app/trends/_sections/demand-growth-section.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
16
src/app/trends/_sections/multivariate-section.tsx
Normal file
16
src/app/trends/_sections/multivariate-section.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
164
src/components/charts/demand-growth-chart.tsx
Normal file
164
src/components/charts/demand-growth-chart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 }]));
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
302
src/components/charts/residual-chart.tsx
Normal file
302
src/components/charts/residual-chart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
248
src/components/charts/steo-projections-chart.tsx
Normal file
248
src/components/charts/steo-projections-chart.tsx
Normal 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 "Forecast" line are EIA projections, not observed data. The war started Feb 28 —
|
||||||
|
compare pre-war projections with post-war actuals to see the conflict's impact on forecasts.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user