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 . .
|
||||
# Prisma client + TypedSQL must be pre-generated locally (needs live DB for --sql).
|
||||
# src/generated/ is gitignored but included in Docker context from local dev.
|
||||
ENV DATABASE_URL="postgresql://placeholder:placeholder@localhost:5432/placeholder"
|
||||
ARG DATABASE_URL
|
||||
ARG NEXT_PUBLIC_GOOGLE_MAPS_API_KEY
|
||||
ARG NEXT_PUBLIC_GOOGLE_MAP_ID
|
||||
ARG EIA_API_KEY
|
||||
ARG FRED_API_KEY
|
||||
ENV DATABASE_URL=$DATABASE_URL
|
||||
ENV NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=$NEXT_PUBLIC_GOOGLE_MAPS_API_KEY
|
||||
ENV NEXT_PUBLIC_GOOGLE_MAP_ID=$NEXT_PUBLIC_GOOGLE_MAP_ID
|
||||
RUN npx next build
|
||||
ENV EIA_API_KEY=$EIA_API_KEY
|
||||
ENV FRED_API_KEY=$FRED_API_KEY
|
||||
RUN --network=host npx next build
|
||||
|
||||
FROM node:22-slim AS runner
|
||||
WORKDIR /app
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
-- Before/after price comparison for each datacenter opening event (year_opened >= 2019)
|
||||
-- Includes natural gas price context for the same time windows
|
||||
WITH dc_events AS (
|
||||
SELECT
|
||||
d.name AS dc_name,
|
||||
@ -32,6 +33,26 @@ price_after AS (
|
||||
AND ep.timestamp >= de.event_date
|
||||
AND ep.timestamp < de.event_date + INTERVAL '6 months'
|
||||
GROUP BY de.dc_name
|
||||
),
|
||||
gas_before AS (
|
||||
SELECT
|
||||
de.dc_name,
|
||||
AVG(cp.price) AS avg_gas_before
|
||||
FROM dc_events de
|
||||
JOIN commodity_prices cp ON cp.commodity = 'natural_gas'
|
||||
AND cp.timestamp >= de.event_date - INTERVAL '6 months'
|
||||
AND cp.timestamp < de.event_date
|
||||
GROUP BY de.dc_name
|
||||
),
|
||||
gas_after AS (
|
||||
SELECT
|
||||
de.dc_name,
|
||||
AVG(cp.price) AS avg_gas_after
|
||||
FROM dc_events de
|
||||
JOIN commodity_prices cp ON cp.commodity = 'natural_gas'
|
||||
AND cp.timestamp >= de.event_date
|
||||
AND cp.timestamp < de.event_date + INTERVAL '6 months'
|
||||
GROUP BY de.dc_name
|
||||
)
|
||||
SELECT
|
||||
de.dc_name,
|
||||
@ -45,10 +66,19 @@ SELECT
|
||||
WHEN pb.avg_price_before > 0
|
||||
THEN ((pa.avg_price_after - pb.avg_price_before) / pb.avg_price_before * 100)
|
||||
ELSE NULL
|
||||
END AS pct_change
|
||||
END AS pct_change,
|
||||
gb.avg_gas_before,
|
||||
ga.avg_gas_after,
|
||||
CASE
|
||||
WHEN gb.avg_gas_before > 0
|
||||
THEN ((ga.avg_gas_after - gb.avg_gas_before) / gb.avg_gas_before * 100)
|
||||
ELSE NULL
|
||||
END AS gas_pct_change
|
||||
FROM dc_events de
|
||||
LEFT JOIN price_before pb ON pb.dc_name = de.dc_name
|
||||
LEFT JOIN price_after pa ON pa.dc_name = de.dc_name
|
||||
LEFT JOIN gas_before gb ON gb.dc_name = de.dc_name
|
||||
LEFT JOIN gas_after ga ON ga.dc_name = de.dc_name
|
||||
WHERE pb.avg_price_before IS NOT NULL
|
||||
AND pa.avg_price_after IS NOT NULL
|
||||
ORDER BY ABS(COALESCE(pa.avg_price_after - pb.avg_price_before, 0)) DESC
|
||||
|
||||
38
scripts/build-and-push.sh
Executable file
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -18,6 +18,317 @@ interface ActionError {
|
||||
|
||||
type ActionResult<T> = ActionSuccess<T> | ActionError;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multivariate Regression: DC Capacity effect on electricity prices
|
||||
// controlling for fuel costs, demand, and generation mix
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MultivariateDataPoint {
|
||||
region_code: string;
|
||||
region_name: string;
|
||||
/** Total DC capacity in the region (MW) */
|
||||
dc_capacity_mw: number;
|
||||
/** Average electricity price ($/MWh) */
|
||||
avg_price: number;
|
||||
/** Average natural gas price during same period ($/MMBtu) */
|
||||
avg_gas_price: number;
|
||||
/** Average demand (MW) */
|
||||
avg_demand: number;
|
||||
/** Gas generation share (fraction 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(
|
||||
regionCode: string,
|
||||
): Promise<ActionResult<getCapacityPriceTimeline.Result[]>> {
|
||||
@ -54,3 +365,122 @@ export async function fetchDcPriceImpact(): Promise<ActionResult<getDcPriceImpac
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Demand Growth Attribution — DC share of regional load growth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DemandGrowthRow {
|
||||
region_code: string;
|
||||
region_name: string;
|
||||
/** Average demand in earliest available year (MW) */
|
||||
baseline_demand_mw: number;
|
||||
/** Average demand in most recent year (MW) */
|
||||
current_demand_mw: number;
|
||||
/** Total demand growth (MW) */
|
||||
demand_growth_mw: number;
|
||||
/** Total DC capacity added in this period (MW) */
|
||||
dc_capacity_added_mw: number;
|
||||
/** Estimated DC share of demand growth (fraction 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}
|
||||
/>
|
||||
<MetricCard label="WTI Crude" value={metrics.wtiPrice} unit="$/bbl" alertThreshold={100} />
|
||||
<MetricCard label="Dubai Crude" value={metrics.dubaiPrice} unit="$/bbl" alertThreshold={100} />
|
||||
<MetricCard label="WTI-Brent Spread" value={metrics.wtiBrentSpread} unit="$/bbl" alertThreshold={5} />
|
||||
<MetricCard label="Oil Volatility" value={metrics.ovxLevel} unit="OVX" alertThreshold={40} />
|
||||
<MetricCard label="US Gasoline" value={metrics.gasolinePrice} unit="$/gal" alertThreshold={4.0} />
|
||||
|
||||
@ -5,14 +5,21 @@ import { deserialize } from '@/lib/superjson.js';
|
||||
|
||||
import type { MarketIndicatorRow } from '@/actions/conflict.js';
|
||||
|
||||
/** Compute percentile-based thresholds from historical data */
|
||||
function computeThresholds(prices: number[], p50 = 0.6, p90 = 0.85): [number, number] {
|
||||
if (prices.length < 5) return [0, 0];
|
||||
const sorted = [...prices].sort((a, b) => a - b);
|
||||
const idx50 = Math.floor(sorted.length * p50);
|
||||
const idx90 = Math.floor(sorted.length * p90);
|
||||
return [sorted[idx50]!, sorted[idx90]!];
|
||||
}
|
||||
/**
|
||||
* Absolute thresholds based on well-established historical interpretation scales.
|
||||
* Percentile-based thresholds are unreliable with limited/skewed data windows.
|
||||
*
|
||||
* VIX: <20 calm, 20-30 elevated, >30 extreme (>40 = panic)
|
||||
* OVX: <25 calm, 25-40 elevated, >40 extreme
|
||||
* DXY: <100 weak, 100-110 elevated, >110 strong (inverted — strong $ hurts commodities)
|
||||
* STLFSI: <0 below-avg stress, 0-1.5 elevated, >1.5 extreme (>3 = crisis)
|
||||
*/
|
||||
const ABSOLUTE_THRESHOLDS: Record<string, [number, number]> = {
|
||||
ovx: [25, 40],
|
||||
vix: [20, 30],
|
||||
dxy: [100, 110],
|
||||
financial_stress: [0, 1.5],
|
||||
};
|
||||
|
||||
export async function MarketFearSection() {
|
||||
const result = await fetchMarketIndicators('1y');
|
||||
@ -20,14 +27,6 @@ export async function MarketFearSection() {
|
||||
|
||||
const rows = deserialize<MarketIndicatorRow[]>(result.data);
|
||||
|
||||
// Collect all values per commodity for threshold computation
|
||||
const historyByType = new Map<string, number[]>();
|
||||
for (const row of rows) {
|
||||
const arr = historyByType.get(row.commodity) ?? [];
|
||||
arr.push(row.price);
|
||||
historyByType.set(row.commodity, arr);
|
||||
}
|
||||
|
||||
// Get latest value for each indicator (data is sorted by timestamp asc)
|
||||
const latestByType = new Map<string, number>();
|
||||
for (let i = rows.length - 1; i >= 0; i--) {
|
||||
@ -37,26 +36,18 @@ export async function MarketFearSection() {
|
||||
}
|
||||
}
|
||||
|
||||
// Compute data-driven thresholds (60th and 85th percentile of 1-year data)
|
||||
// Fall back to static thresholds if insufficient data
|
||||
const ovxThresholds = historyByType.has('ovx')
|
||||
? computeThresholds(historyByType.get('ovx')!)
|
||||
: ([25, 40] as [number, number]);
|
||||
const vixThresholds = historyByType.has('vix')
|
||||
? computeThresholds(historyByType.get('vix')!)
|
||||
: ([20, 35] as [number, number]);
|
||||
const dxyThresholds = historyByType.has('dxy')
|
||||
? computeThresholds(historyByType.get('dxy')!)
|
||||
: ([100, 120] as [number, number]);
|
||||
const stressThresholds = historyByType.has('financial_stress')
|
||||
? computeThresholds(historyByType.get('financial_stress')!)
|
||||
: ([1, 3] as [number, number]);
|
||||
const ovxThresholds = ABSOLUTE_THRESHOLDS['ovx']!;
|
||||
const vixThresholds = ABSOLUTE_THRESHOLDS['vix']!;
|
||||
const dxyThresholds = ABSOLUTE_THRESHOLDS['dxy']!;
|
||||
const stressThresholds = ABSOLUTE_THRESHOLDS['financial_stress']!;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Market Fear Indicators</CardTitle>
|
||||
<CardDescription>Gauges colored by 1-year percentile trends (60th = elevated, 85th = extreme)</CardDescription>
|
||||
<CardDescription>
|
||||
Gauges colored by historical absolute thresholds (VIX >30, OVX >40, STLFSI >1.5 = extreme)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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 { NormalizedSection } from './_sections/normalized-section.js';
|
||||
import { OilSection } from './_sections/oil-section.js';
|
||||
import { SteoSection } from './_sections/steo-section.js';
|
||||
import { SupplySection } from './_sections/supply-section.js';
|
||||
import { WarPremiumSection } from './_sections/war-premium-section.js';
|
||||
|
||||
@ -113,6 +114,11 @@ export default function ConflictPage() {
|
||||
<CrackSpreadSection />
|
||||
</Suspense>
|
||||
|
||||
{/* STEO Energy Projections — forecast vs actual */}
|
||||
<Suspense fallback={<ChartSkeleton />}>
|
||||
<SteoSection />
|
||||
</Suspense>
|
||||
|
||||
{/* Market Fear Indicators */}
|
||||
<Suspense fallback={<GaugeSkeleton />}>
|
||||
<MarketFearSection />
|
||||
|
||||
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 { DcImpactSection } from './_sections/dc-impact-section.js';
|
||||
import { DemandGrowthSection } from './_sections/demand-growth-section.js';
|
||||
import { MultivariateSection } from './_sections/multivariate-section.js';
|
||||
import { PriceChartSection } from './_sections/price-chart-section.js';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@ -30,9 +32,20 @@ export default function TrendsPage() {
|
||||
<DcImpactSection />
|
||||
</Suspense>
|
||||
|
||||
{/* Multivariate analysis: price residuals after controlling for fuel/demand */}
|
||||
<Suspense fallback={<ChartSkeleton />}>
|
||||
<MultivariateSection />
|
||||
</Suspense>
|
||||
|
||||
{/* Simple bivariate correlation for comparison */}
|
||||
<Suspense fallback={<ChartSkeleton />}>
|
||||
<CorrelationSection />
|
||||
</Suspense>
|
||||
|
||||
{/* Demand growth attribution: DC share of load growth */}
|
||||
<Suspense fallback={<ChartSkeleton />}>
|
||||
<DemandGrowthSection />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -33,6 +33,9 @@ interface RegionRow {
|
||||
avg_price_after: number;
|
||||
pct_change: number;
|
||||
increased: boolean;
|
||||
avg_gas_before: number | null;
|
||||
avg_gas_after: number | null;
|
||||
gas_pct_change: number | null;
|
||||
}
|
||||
|
||||
interface DcRow {
|
||||
@ -44,16 +47,38 @@ interface DcRow {
|
||||
avg_price_after: number;
|
||||
pct_change: number;
|
||||
increased: boolean;
|
||||
avg_gas_before: number | null;
|
||||
avg_gas_after: number | null;
|
||||
gas_pct_change: number | null;
|
||||
}
|
||||
|
||||
function aggregateByRegion(rows: getDcPriceImpact.Result[]): RegionRow[] {
|
||||
const byRegion = new Map<string, { before: number[]; after: number[]; count: number; capacity: number }>();
|
||||
const byRegion = new Map<
|
||||
string,
|
||||
{
|
||||
before: number[];
|
||||
after: number[];
|
||||
gasBefore: number[];
|
||||
gasAfter: number[];
|
||||
count: number;
|
||||
capacity: number;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const r of rows) {
|
||||
if (r.avg_price_before === null || r.avg_price_after === null) continue;
|
||||
const existing = byRegion.get(r.region_code) ?? { before: [], after: [], count: 0, capacity: 0 };
|
||||
const existing = byRegion.get(r.region_code) ?? {
|
||||
before: [],
|
||||
after: [],
|
||||
gasBefore: [],
|
||||
gasAfter: [],
|
||||
count: 0,
|
||||
capacity: 0,
|
||||
};
|
||||
existing.before.push(r.avg_price_before);
|
||||
existing.after.push(r.avg_price_after);
|
||||
if (r.avg_gas_before !== null) existing.gasBefore.push(r.avg_gas_before);
|
||||
if (r.avg_gas_after !== null) existing.gasAfter.push(r.avg_gas_after);
|
||||
existing.count++;
|
||||
existing.capacity += r.capacity_mw;
|
||||
byRegion.set(r.region_code, existing);
|
||||
@ -64,6 +89,14 @@ function aggregateByRegion(rows: getDcPriceImpact.Result[]): RegionRow[] {
|
||||
const avgBefore = data.before.reduce((s, v) => s + v, 0) / data.before.length;
|
||||
const avgAfter = data.after.reduce((s, v) => s + v, 0) / data.after.length;
|
||||
const pctChange = avgBefore > 0 ? ((avgAfter - avgBefore) / avgBefore) * 100 : 0;
|
||||
const avgGasBefore =
|
||||
data.gasBefore.length > 0 ? data.gasBefore.reduce((s, v) => s + v, 0) / data.gasBefore.length : null;
|
||||
const avgGasAfter =
|
||||
data.gasAfter.length > 0 ? data.gasAfter.reduce((s, v) => s + v, 0) / data.gasAfter.length : null;
|
||||
const gasPctChange =
|
||||
avgGasBefore && avgGasBefore > 0 && avgGasAfter !== null
|
||||
? ((avgGasAfter - avgGasBefore) / avgGasBefore) * 100
|
||||
: null;
|
||||
return {
|
||||
region_code: code,
|
||||
dc_count: data.count,
|
||||
@ -72,6 +105,9 @@ function aggregateByRegion(rows: getDcPriceImpact.Result[]): RegionRow[] {
|
||||
avg_price_after: avgAfter,
|
||||
pct_change: pctChange,
|
||||
increased: avgAfter >= avgBefore,
|
||||
avg_gas_before: avgGasBefore,
|
||||
avg_gas_after: avgGasAfter,
|
||||
gas_pct_change: gasPctChange,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => Math.abs(b.pct_change) - Math.abs(a.pct_change));
|
||||
@ -89,10 +125,27 @@ function transformDcData(rows: getDcPriceImpact.Result[]): DcRow[] {
|
||||
avg_price_after: r.avg_price_after!,
|
||||
pct_change: r.pct_change ?? 0,
|
||||
increased: (r.avg_price_after ?? 0) >= (r.avg_price_before ?? 0),
|
||||
avg_gas_before: r.avg_gas_before,
|
||||
avg_gas_after: r.avg_gas_after,
|
||||
gas_pct_change: r.gas_pct_change,
|
||||
}))
|
||||
.sort((a, b) => Math.abs(b.pct_change) - Math.abs(a.pct_change));
|
||||
}
|
||||
|
||||
/** Small inline indicator showing gas price movement direction */
|
||||
function GasContext({ gasPct, elecPct }: { gasPct: number | null; elecPct: number }) {
|
||||
if (gasPct === null) return null;
|
||||
const sameDirection = (gasPct >= 0 && elecPct >= 0) || (gasPct < 0 && elecPct < 0);
|
||||
return (
|
||||
<span
|
||||
className={cn('ml-1 text-[9px] tabular-nums', sameDirection ? 'text-muted-foreground/60' : 'text-amber-400/80')}
|
||||
title={`Gas ${gasPct >= 0 ? '+' : ''}${gasPct.toFixed(1)}% in same period${sameDirection ? ' (moved same direction — confounded)' : ' (moved opposite direction — DC effect likely real)'}`}>
|
||||
⛽{gasPct >= 0 ? '+' : ''}
|
||||
{gasPct.toFixed(0)}%
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function DcPriceImpactBars({ data }: DcPriceImpactBarsProps) {
|
||||
const [viewMode, setViewMode] = useState<'region' | 'datacenter'>('region');
|
||||
const rawRows = useMemo(() => deserialize<getDcPriceImpact.Result[]>(data), [data]);
|
||||
@ -107,6 +160,21 @@ export function DcPriceImpactBars({ data }: DcPriceImpactBarsProps) {
|
||||
return barData.reduce((sum, r) => sum + r.pct_change, 0) / barData.length;
|
||||
}, [barData]);
|
||||
|
||||
// Count how many entries have gas moving same direction vs opposite
|
||||
const confoundedCount = useMemo(() => {
|
||||
let same = 0;
|
||||
let opposite = 0;
|
||||
for (const row of barData) {
|
||||
if (row.gas_pct_change === null) continue;
|
||||
if ((row.gas_pct_change >= 0 && row.pct_change >= 0) || (row.gas_pct_change < 0 && row.pct_change < 0)) {
|
||||
same++;
|
||||
} else {
|
||||
opposite++;
|
||||
}
|
||||
}
|
||||
return { same, opposite };
|
||||
}, [barData]);
|
||||
|
||||
if (barData.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
@ -184,16 +252,25 @@ export function DcPriceImpactBars({ data }: DcPriceImpactBarsProps) {
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
labelFormatter={label => {
|
||||
const labelStr = String(label);
|
||||
labelFormatter={_label => {
|
||||
const labelStr =
|
||||
typeof _label === 'string' ? _label : typeof _label === 'number' ? String(_label) : '';
|
||||
if (viewMode === 'region') {
|
||||
const region = regionData.find(d => d.region_code === labelStr);
|
||||
if (!region) return labelStr;
|
||||
return `${region.region_code} — ${region.dc_count} DCs, ${region.total_capacity_mw} MW total`;
|
||||
const gasTxt =
|
||||
region.gas_pct_change !== null
|
||||
? ` | Gas: ${region.gas_pct_change >= 0 ? '+' : ''}${region.gas_pct_change.toFixed(1)}%`
|
||||
: '';
|
||||
return `${region.region_code} — ${region.dc_count} DCs, ${region.total_capacity_mw} MW${gasTxt}`;
|
||||
}
|
||||
const item = dcData.find(d => d.dc_name === labelStr);
|
||||
if (!item) return labelStr;
|
||||
return `${item.dc_name} (${item.region_code}, ${item.year_opened}) — ${item.capacity_mw} MW`;
|
||||
const gasTxt =
|
||||
item.gas_pct_change !== null
|
||||
? ` | Gas: ${item.gas_pct_change >= 0 ? '+' : ''}${item.gas_pct_change.toFixed(1)}%`
|
||||
: '';
|
||||
return `${item.dc_name} (${item.region_code}, ${item.year_opened}) — ${item.capacity_mw} MW${gasTxt}`;
|
||||
}}
|
||||
formatter={(value, name) => {
|
||||
const numVal = Number(value);
|
||||
@ -214,7 +291,25 @@ export function DcPriceImpactBars({ data }: DcPriceImpactBarsProps) {
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
|
||||
{/* Gas price context annotations below chart */}
|
||||
{viewMode === 'region' && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{regionData.map(row => (
|
||||
<div
|
||||
key={row.region_code}
|
||||
className="flex items-center gap-1 rounded border border-border/30 px-2 py-0.5 text-[10px]">
|
||||
<span className="font-medium">{row.region_code}</span>
|
||||
<span className={row.pct_change >= 0 ? 'text-red-400/80' : 'text-emerald-400/80'}>
|
||||
⚡{row.pct_change >= 0 ? '+' : ''}
|
||||
{row.pct_change.toFixed(0)}%
|
||||
</span>
|
||||
<GasContext gasPct={row.gas_pct_change} elecPct={row.pct_change} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: 'hsl(210, 70%, 50%)' }} />
|
||||
Before DC Opening
|
||||
@ -227,9 +322,20 @@ export function DcPriceImpactBars({ data }: DcPriceImpactBarsProps) {
|
||||
<span className="h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: 'hsl(145, 60%, 45%)' }} />
|
||||
After (Price Decreased)
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px]">⛽</span>
|
||||
Gas price change (same period)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 rounded-md border border-amber-500/20 bg-amber-500/5 px-3 py-2 text-xs leading-relaxed text-amber-300/80">
|
||||
{confoundedCount.same > 0 && (
|
||||
<>
|
||||
In {confoundedCount.same} of {confoundedCount.same + confoundedCount.opposite} cases, gas prices moved in
|
||||
the same direction as electricity prices — the apparent DC effect may be confounded by fuel cost
|
||||
changes.{' '}
|
||||
</>
|
||||
)}
|
||||
Correlation does not imply causation. Electricity prices are influenced by many factors including fuel costs,
|
||||
weather, grid congestion, regulatory changes, and seasonal demand patterns.
|
||||
</p>
|
||||
|
||||
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: 'vix', label: 'VIX', color: 'hsl(320, 60%, 55%)' },
|
||||
{ key: 'gpr_daily', label: 'GPR Index', color: 'hsl(45, 90%, 50%)' },
|
||||
{ key: 'dubai_crude', label: 'Dubai Crude', color: 'hsl(160, 60%, 50%)' },
|
||||
] as const;
|
||||
|
||||
const chartConfig: ChartConfig = Object.fromEntries(ALL_SERIES.map(s => [s.key, { label: s.label, color: s.color }]));
|
||||
|
||||
@ -29,14 +29,12 @@ interface PivotedRow {
|
||||
label: string;
|
||||
wti_crude?: number;
|
||||
brent_crude?: number;
|
||||
dubai_crude?: number;
|
||||
spread?: number;
|
||||
}
|
||||
|
||||
const chartConfig: ChartConfig = {
|
||||
brent_crude: { label: 'Brent Crude', color: 'hsl(0, 70%, 55%)' },
|
||||
wti_crude: { label: 'WTI Crude', color: 'hsl(210, 80%, 55%)' },
|
||||
dubai_crude: { label: 'Dubai Crude', color: 'hsl(160, 60%, 50%)' },
|
||||
spread: { label: 'WTI-Brent Spread', color: 'hsl(45, 80%, 55%)' },
|
||||
};
|
||||
|
||||
@ -59,7 +57,6 @@ export function OilChart({ oilData, events }: OilChartProps) {
|
||||
const pivot = byDay.get(dayKey)!;
|
||||
if (row.commodity === 'wti_crude') pivot.wti_crude = row.price;
|
||||
if (row.commodity === 'brent_crude') pivot.brent_crude = row.price;
|
||||
if (row.commodity === 'dubai_crude') pivot.dubai_crude = row.price;
|
||||
}
|
||||
|
||||
const result = Array.from(byDay.values()).sort((a, b) => a.timestamp - b.timestamp);
|
||||
@ -102,7 +99,7 @@ export function OilChart({ oilData, events }: OilChartProps) {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Oil Prices</CardTitle>
|
||||
<CardDescription>WTI, Brent & Dubai crude benchmarks with geopolitical event annotations</CardDescription>
|
||||
<CardDescription>WTI & Brent crude benchmarks with geopolitical event annotations</CardDescription>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowSpread(prev => !prev)}
|
||||
@ -183,15 +180,6 @@ export function OilChart({ oilData, events }: OilChartProps) {
|
||||
dot={false}
|
||||
connectNulls
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="dubai_crude"
|
||||
stroke="var(--color-dubai_crude)"
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
connectNulls
|
||||
strokeDasharray="4 2"
|
||||
/>
|
||||
</LineChart>
|
||||
)}
|
||||
</ChartContainer>
|
||||
|
||||
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';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Area, AreaChart, CartesianGrid, ReferenceLine, XAxis, YAxis } from 'recharts';
|
||||
import type { SuperJSONResult } from 'superjson';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js';
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart.js';
|
||||
import { deserialize } from '@/lib/superjson.js';
|
||||
import { cn } from '@/lib/utils.js';
|
||||
|
||||
import type { SupplyMetricRow } from '@/actions/conflict.js';
|
||||
|
||||
@ -16,12 +17,58 @@ interface SPRChartProps {
|
||||
|
||||
const chartConfig: ChartConfig = {
|
||||
spr_level: { label: 'SPR Level', color: 'hsl(210, 80%, 55%)' },
|
||||
projected: { label: 'Projected', color: 'hsl(0, 70%, 55%)' },
|
||||
};
|
||||
|
||||
/** Historical reference levels (million barrels) */
|
||||
const SPR_CAPACITY = 714;
|
||||
const SPR_1982_LOW = 264;
|
||||
|
||||
/** Minimum operational SPR level — below this, deliverability degrades */
|
||||
const SPR_MIN_OPERATIONAL = 100;
|
||||
|
||||
interface SPRDataPoint {
|
||||
timestamp: number;
|
||||
label: string;
|
||||
spr_level?: number;
|
||||
projected?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate drawdown rate and project days remaining.
|
||||
* Uses last 30 days of data (or all available if less) to compute rate.
|
||||
*/
|
||||
function computeDrawdownMetrics(rows: SPRDataPoint[]) {
|
||||
if (rows.length < 2) return null;
|
||||
|
||||
const lastRow = rows[rows.length - 1]!;
|
||||
const current = lastRow.spr_level;
|
||||
if (current === undefined) return null;
|
||||
|
||||
// Find the level 30 days ago (or closest)
|
||||
const thirtyDaysAgo = lastRow.timestamp - 30 * 24 * 60 * 60 * 1000;
|
||||
const recentStart = rows.find(r => r.timestamp >= thirtyDaysAgo) ?? rows[0]!;
|
||||
if (recentStart.spr_level === undefined) return null;
|
||||
const daysBetween = (lastRow.timestamp - recentStart.timestamp) / (24 * 60 * 60 * 1000);
|
||||
|
||||
if (daysBetween < 7) return null; // Need at least a week of data
|
||||
|
||||
const drawdownMbbl = recentStart.spr_level - current;
|
||||
const dailyRate = drawdownMbbl / daysBetween;
|
||||
|
||||
if (dailyRate <= 0) return { current, dailyRate: 0, daysToEmpty: null, daysToMinOperational: null };
|
||||
|
||||
const daysToEmpty = current / dailyRate;
|
||||
const daysToMinOperational = (current - SPR_MIN_OPERATIONAL) / dailyRate;
|
||||
|
||||
return {
|
||||
current,
|
||||
dailyRate,
|
||||
daysToEmpty: Math.max(0, daysToEmpty),
|
||||
daysToMinOperational: Math.max(0, daysToMinOperational),
|
||||
};
|
||||
}
|
||||
|
||||
export function SPRChart({ data }: SPRChartProps) {
|
||||
const rows = useMemo(() => {
|
||||
const all = deserialize<SupplyMetricRow[]>(data);
|
||||
@ -35,6 +82,40 @@ export function SPRChart({ data }: SPRChartProps) {
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
}, [data]);
|
||||
|
||||
const metrics = useMemo(() => computeDrawdownMetrics(rows), [rows]);
|
||||
|
||||
/** How many days old the latest SPR data point is */
|
||||
const [now] = useState(() => Date.now());
|
||||
const latestTs = rows.length > 0 ? rows[rows.length - 1]!.timestamp : null;
|
||||
const dataAgeDays = latestTs !== null ? Math.floor((now - latestTs) / (24 * 60 * 60 * 1000)) : null;
|
||||
const isStale = dataAgeDays !== null && dataAgeDays > 7;
|
||||
|
||||
// Generate projected drawdown line (extend 180 days from last data point)
|
||||
const chartData: SPRDataPoint[] = useMemo(() => {
|
||||
if (!metrics || metrics.dailyRate <= 0) return rows;
|
||||
|
||||
const lastRow = rows[rows.length - 1]!;
|
||||
const projectedPoints: SPRDataPoint[] = [];
|
||||
|
||||
// Add the transition point (last real + projected start at same value)
|
||||
const extendedRows = rows.map(r => ({ ...r, projected: undefined as number | undefined }));
|
||||
extendedRows[extendedRows.length - 1]!.projected = lastRow.spr_level;
|
||||
|
||||
for (let dayOffset = 30; dayOffset <= 180; dayOffset += 30) {
|
||||
const projTs = lastRow.timestamp + dayOffset * 24 * 60 * 60 * 1000;
|
||||
const projDate = new Date(projTs);
|
||||
const projLevel = Math.max(0, lastRow.spr_level - metrics.dailyRate * dayOffset);
|
||||
projectedPoints.push({
|
||||
timestamp: projTs,
|
||||
label: projDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: '2-digit' }),
|
||||
spr_level: undefined,
|
||||
projected: Number(projLevel.toFixed(1)),
|
||||
});
|
||||
}
|
||||
|
||||
return [...extendedRows, ...projectedPoints];
|
||||
}, [rows, metrics]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
@ -49,12 +130,53 @@ export function SPRChart({ data }: SPRChartProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Strategic Petroleum Reserve</CardTitle>
|
||||
<CardDescription>US SPR stock levels (million barrels)</CardDescription>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<CardTitle>Strategic Petroleum Reserve</CardTitle>
|
||||
<CardDescription>US SPR stock levels (million barrels) with drawdown projection</CardDescription>
|
||||
{isStale && (
|
||||
<p className="mt-1 text-[10px] text-amber-400/80">
|
||||
⚠ Latest data is {dataAgeDays} days old — EIA weekly report may be delayed. Will update automatically.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{metrics && metrics.dailyRate > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<div className="rounded-lg border border-border/50 bg-muted/30 px-3 py-2 text-center">
|
||||
<span className="block text-[10px] text-muted-foreground">Drawdown Rate</span>
|
||||
<span className="text-sm font-bold text-red-400 tabular-nums">
|
||||
{metrics.dailyRate.toFixed(1)}M bbl/day
|
||||
</span>
|
||||
</div>
|
||||
{metrics.daysToMinOperational !== null && (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border px-3 py-2 text-center',
|
||||
metrics.daysToMinOperational < 180
|
||||
? 'border-red-500/30 bg-red-500/10'
|
||||
: 'border-border/50 bg-muted/30',
|
||||
)}>
|
||||
<span className="block text-[10px] text-muted-foreground">Days to Min Operational</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-bold tabular-nums',
|
||||
metrics.daysToMinOperational < 180 ? 'text-red-400' : 'text-foreground',
|
||||
)}>
|
||||
{Math.round(metrics.daysToMinOperational)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-lg border border-border/50 bg-muted/30 px-3 py-2 text-center">
|
||||
<span className="block text-[10px] text-muted-foreground">Current Level</span>
|
||||
<span className="text-sm font-bold text-foreground tabular-nums">{metrics.current.toFixed(0)}M</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-[30vh] max-h-80 min-h-50 w-full">
|
||||
<AreaChart data={rows} margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
|
||||
<AreaChart data={chartData} margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border/30" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
@ -71,7 +193,16 @@ export function SPRChart({ data }: SPRChartProps) {
|
||||
domain={[0, SPR_CAPACITY + 50]}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={<ChartTooltipContent formatter={value => [`${Number(value).toFixed(1)}M barrels`, undefined]} />}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value, name) => {
|
||||
const v = Number(value);
|
||||
const nameStr = String(name);
|
||||
if (nameStr === 'projected') return [`${v.toFixed(1)}M bbl (projected)`, undefined];
|
||||
return [`${v.toFixed(1)}M barrels`, undefined];
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ReferenceLine
|
||||
y={SPR_CAPACITY}
|
||||
@ -90,6 +221,17 @@ export function SPRChart({ data }: SPRChartProps) {
|
||||
fill: 'hsl(0, 60%, 60%)',
|
||||
}}
|
||||
/>
|
||||
<ReferenceLine
|
||||
y={SPR_MIN_OPERATIONAL}
|
||||
stroke="hsl(0, 90%, 45%)"
|
||||
strokeDasharray="3 3"
|
||||
label={{
|
||||
value: 'Min Operational (100M)',
|
||||
position: 'insideBottomRight',
|
||||
fontSize: 10,
|
||||
fill: 'hsl(0, 90%, 55%)',
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="spr_level"
|
||||
@ -97,9 +239,28 @@ export function SPRChart({ data }: SPRChartProps) {
|
||||
fillOpacity={0.2}
|
||||
stroke="var(--color-spr_level)"
|
||||
strokeWidth={2}
|
||||
connectNulls={false}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="projected"
|
||||
fill="hsl(0, 70%, 55%)"
|
||||
fillOpacity={0.08}
|
||||
stroke="hsl(0, 70%, 55%)"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="6 3"
|
||||
connectNulls
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
{metrics && metrics.dailyRate > 0 && (
|
||||
<p className="mt-3 rounded-md border border-red-500/20 bg-red-500/5 px-3 py-2 text-xs leading-relaxed text-red-300/80">
|
||||
At the current drawdown rate of {metrics.dailyRate.toFixed(1)}M bbl/day, the SPR will reach minimum
|
||||
operational levels (~100M bbl) in approximately {Math.round(metrics.daysToMinOperational ?? 0)} days. Below
|
||||
this level, physical deliverability from salt dome caverns degrades significantly. The dashed red line shows
|
||||
the projected trajectory if the current rate continues.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
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 CHECK_INTERVAL_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Generate a unique key for each alert type.
|
||||
* Alerts are deduplicated per session — once shown, the same alert won't fire again
|
||||
* unless the underlying condition changes (different sigma band or price direction).
|
||||
*/
|
||||
function alertKey(type: 'spike' | 'warning' | 'jump', regionCode: string): string {
|
||||
return `${type}:${regionCode}`;
|
||||
}
|
||||
|
||||
export function PriceAlertMonitor() {
|
||||
const previousPricesRef = useRef<Map<string, number>>(new Map());
|
||||
const initialLoadRef = useRef(true);
|
||||
/** Set of alert keys already shown this session */
|
||||
const shownAlertsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
async function checkForSpikes() {
|
||||
@ -24,6 +35,7 @@ export function PriceAlertMonitor() {
|
||||
|
||||
const prices = deserialize<getLatestPrices.Result[]>(result.data);
|
||||
const prevPrices = previousPricesRef.current;
|
||||
const shownAlerts = shownAlertsRef.current;
|
||||
|
||||
for (const p of prices) {
|
||||
const prevPrice = prevPrices.get(p.region_code);
|
||||
@ -36,24 +48,43 @@ export function PriceAlertMonitor() {
|
||||
const sigmas = (p.price_mwh - avg) / sd;
|
||||
|
||||
if (sigmas >= SPIKE_CRITICAL_SIGMA) {
|
||||
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,
|
||||
});
|
||||
const key = alertKey('spike', p.region_code);
|
||||
if (!shownAlerts.has(key)) {
|
||||
shownAlerts.add(key);
|
||||
toast.error(`Price Spike: ${p.region_code}`, {
|
||||
description: `$${p.price_mwh.toFixed(2)}/MWh — ${sigmas.toFixed(1)}σ above 7-day avg ($${avg.toFixed(0)})`,
|
||||
duration: 8000,
|
||||
});
|
||||
}
|
||||
} else if (sigmas >= SPIKE_WARNING_SIGMA) {
|
||||
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,
|
||||
});
|
||||
const key = alertKey('warning', p.region_code);
|
||||
if (!shownAlerts.has(key)) {
|
||||
shownAlerts.add(key);
|
||||
toast.warning(`Elevated Price: ${p.region_code}`, {
|
||||
description: `$${p.price_mwh.toFixed(2)}/MWh — ${sigmas.toFixed(1)}σ above 7-day avg ($${avg.toFixed(0)})`,
|
||||
duration: 6000,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Price returned to normal — clear alerts so they can fire again if it spikes later
|
||||
shownAlerts.delete(alertKey('spike', p.region_code));
|
||||
shownAlerts.delete(alertKey('warning', p.region_code));
|
||||
}
|
||||
}
|
||||
|
||||
if (prevPrice !== undefined && prevPrice > 0 && p.price_mwh > prevPrice * (1 + JUMP_PCT_THRESHOLD)) {
|
||||
const 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,
|
||||
});
|
||||
const key = alertKey('jump', p.region_code);
|
||||
if (!shownAlerts.has(key)) {
|
||||
shownAlerts.add(key);
|
||||
const jumpPct = ((p.price_mwh - prevPrice) / prevPrice) * 100;
|
||||
toast.warning(`Price Jump: ${p.region_code}`, {
|
||||
description: `$${p.price_mwh.toFixed(2)}/MWh — up ${jumpPct.toFixed(1)}% from last reading`,
|
||||
duration: 6000,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// No jump — allow future jump alerts for this region
|
||||
shownAlerts.delete(alertKey('jump', p.region_code));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -102,7 +102,6 @@ const COMMODITY_LABELS: Record<string, string> = {
|
||||
vix: 'VIX',
|
||||
dxy: 'DXY',
|
||||
financial_stress: 'Fin Stress',
|
||||
dubai_crude: 'Dubai',
|
||||
ng_futures_1: 'NG F1',
|
||||
ng_futures_2: 'NG F2',
|
||||
ng_futures_3: 'NG F3',
|
||||
|
||||
@ -593,3 +593,93 @@ export async function getRetailElectricityPrices(options: GetRetailPriceOptions
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EIA STEO — Short-Term Energy Outlook (monthly projections)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** STEO series IDs we fetch for energy price/demand projections */
|
||||
export const STEO_SERIES: Record<string, { label: string; unit: string; category: string }> = {
|
||||
// Wholesale electricity prices by region
|
||||
ELWHU_TX: { label: 'Wholesale Elec — ERCOT', unit: '$/MWh', category: 'electricity' },
|
||||
ELWHU_PJ: { label: 'Wholesale Elec — PJM', unit: '$/MWh', category: 'electricity' },
|
||||
ELWHU_CA: { label: 'Wholesale Elec — CAISO', unit: '$/MWh', category: 'electricity' },
|
||||
ELWHU_NY: { label: 'Wholesale Elec — NYISO', unit: '$/MWh', category: 'electricity' },
|
||||
ELWHU_NE: { label: 'Wholesale Elec — ISONE', unit: '$/MWh', category: 'electricity' },
|
||||
ELWHU_MW: { label: 'Wholesale Elec — Midwest', unit: '$/MWh', category: 'electricity' },
|
||||
ELWHU_SP: { label: 'Wholesale Elec — SPP', unit: '$/MWh', category: 'electricity' },
|
||||
// Retail electricity
|
||||
ESTCU_US: { label: 'Retail Elec — All Sectors', unit: 'cents/kWh', category: 'electricity' },
|
||||
// Demand
|
||||
ELCOTWH: { label: 'US Electricity Consumption', unit: 'billion kWh', category: 'demand' },
|
||||
// Fuel
|
||||
NGHHUUS: { label: 'Henry Hub Gas Price', unit: '$/MMBtu', category: 'fuel' },
|
||||
BREPUUS: { label: 'Brent Crude Price', unit: '$/barrel', category: 'fuel' },
|
||||
};
|
||||
|
||||
const steoRowSchema = z.object({
|
||||
period: z.string(),
|
||||
seriesId: z.string(),
|
||||
seriesDescription: z.string(),
|
||||
value: z.union([z.string(), z.number(), z.null()]),
|
||||
unit: z.string(),
|
||||
});
|
||||
|
||||
const steoResponseSchema = z.object({
|
||||
response: z.object({
|
||||
total: z.coerce.number(),
|
||||
data: z.array(steoRowSchema),
|
||||
}),
|
||||
});
|
||||
|
||||
export interface SteoDataPoint {
|
||||
period: string; // YYYY-MM
|
||||
seriesId: string;
|
||||
description: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch STEO monthly projections for key energy series.
|
||||
* Returns historical + forecast data (typically 18 months out from current).
|
||||
*/
|
||||
export async function getSteoProjections(options: { start?: string; end?: string } = {}): Promise<SteoDataPoint[]> {
|
||||
const seriesIds = Object.keys(STEO_SERIES);
|
||||
|
||||
const params: EiaQueryParams = {
|
||||
frequency: 'monthly',
|
||||
start: options.start ?? '2025-01',
|
||||
end: options.end,
|
||||
facets: { seriesId: seriesIds },
|
||||
sort: [{ column: 'period', direction: 'asc' }],
|
||||
};
|
||||
|
||||
const rows = await fetchAllPages('/steo/data/', params, json => {
|
||||
const parsed = steoResponseSchema.parse(json);
|
||||
return { total: parsed.response.total, data: parsed.response.data };
|
||||
});
|
||||
|
||||
const results: SteoDataPoint[] = [];
|
||||
for (const row of rows) {
|
||||
const val = row.value;
|
||||
const numVal = typeof val === 'number' ? val : typeof val === 'string' ? parseFloat(val) : NaN;
|
||||
if (isNaN(numVal)) continue;
|
||||
|
||||
// Period is YYYY-MM, convert to first of month
|
||||
const [year, month] = row.period.split('-').map(Number);
|
||||
if (!year || !month) continue;
|
||||
|
||||
results.push({
|
||||
period: row.period,
|
||||
seriesId: row.seriesId,
|
||||
description: row.seriesDescription,
|
||||
value: numVal,
|
||||
unit: row.unit,
|
||||
timestamp: new Date(Date.UTC(year, month - 1, 1)),
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user