Joey Eamigh 7a1bbca339
fix: comprehensive review fixes — real price data, missing components, SQL bugs, security
- Replace $0 electricity prices with real EIA retail-sales data (IND sector)
  with demand-based hourly variation (0.8x-1.2x)
- Add sparkline component and alerts feed to dashboard home
- Add animated number transitions to hero metric cards
- Fix ticker tape price direction (green/red arrows with % change)
- Fix AI milestone annotation alignment on price charts
- Fix SQL cartesian products in getDemandByRegion and getRegionPriceHeatmap
  using CTEs for independent aggregation
- Add unique composite constraints to prevent duplicate data
- Add bearer token auth to ingestion API routes
- Add 30s fetch timeouts to EIA and FRED API clients
- Add regionCode validation in server actions
- Fix docker-compose: localhost-only port binding, correct volume path
- Fix seed script to preserve ingested time-series data
2026-02-11 13:23:21 -05:00

417 lines
12 KiB
TypeScript

import {
type CommodityPricePoint,
eiaNaturalGasResponseSchema,
eiaWtiCrudeResponseSchema,
parseEiaCommodityPeriod,
} from '@/lib/schemas/commodities.js';
import {
EIA_RESPONDENT_CODES,
type EiaFuelTypeDataRow,
type EiaRegionDataRow,
type FuelTypeDataPoint,
REGION_STATE_MAP,
type RegionCode,
type RegionDataPoint,
type RetailPricePoint,
eiaFuelTypeDataResponseSchema,
eiaRegionDataResponseSchema,
eiaRetailPriceResponseSchema,
parseEiaPeriod,
resolveRegionCode,
} from '@/lib/schemas/electricity.js';
const EIA_BASE_URL = 'https://api.eia.gov/v2';
const MAX_ROWS_PER_REQUEST = 5000;
function getApiKey(): string {
const key = process.env.EIA_API_KEY;
if (!key) {
throw new Error('EIA_API_KEY environment variable is not set');
}
return key;
}
interface EiaQueryParams {
frequency?: 'hourly' | 'daily' | 'weekly' | 'monthly' | 'annual';
start?: string;
end?: string;
facets?: Record<string, string[]>;
sort?: Array<{ column: string; direction: 'asc' | 'desc' }>;
offset?: number;
length?: number;
/** Data column name(s) to request. Defaults to ['value']. */
dataColumns?: string[];
}
function buildUrl(endpoint: string, params: EiaQueryParams): string {
const url = new URL(`${EIA_BASE_URL}${endpoint}`);
url.searchParams.set('api_key', getApiKey());
const columns = params.dataColumns ?? ['value'];
for (let i = 0; i < columns.length; i++) {
url.searchParams.set(`data[${i}]`, columns[i]!);
}
if (params.frequency) {
url.searchParams.set('frequency', params.frequency);
}
if (params.start) {
url.searchParams.set('start', params.start);
}
if (params.end) {
url.searchParams.set('end', params.end);
}
if (params.facets) {
for (const [key, values] of Object.entries(params.facets)) {
for (const value of values) {
url.searchParams.append(`facets[${key}][]`, value);
}
}
}
if (params.sort) {
for (let i = 0; i < params.sort.length; i++) {
const s = params.sort[i];
if (s) {
url.searchParams.set(`sort[${i}][column]`, s.column);
url.searchParams.set(`sort[${i}][direction]`, s.direction);
}
}
}
url.searchParams.set('offset', String(params.offset ?? 0));
url.searchParams.set('length', String(params.length ?? MAX_ROWS_PER_REQUEST));
return url.toString();
}
const FETCH_TIMEOUT_MS = 30_000;
async function fetchEia(endpoint: string, params: EiaQueryParams): Promise<unknown> {
const url = buildUrl(endpoint, params);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
const text = await response.text().catch(() => 'unknown error');
throw new Error(`EIA API error ${response.status}: ${text}`);
}
return response.json();
} finally {
clearTimeout(timeoutId);
}
}
/**
* Auto-paginate through an EIA endpoint, collecting all rows.
* EIA limits responses to 5000 rows — this fetches all pages sequentially.
*/
async function fetchAllPages<T>(
endpoint: string,
params: EiaQueryParams,
parseResponse: (json: unknown) => { total: number; data: T[] },
): Promise<T[]> {
const allData: T[] = [];
let offset = params.offset ?? 0;
const length = params.length ?? MAX_ROWS_PER_REQUEST;
for (;;) {
const json = await fetchEia(endpoint, { ...params, offset, length });
const result = parseResponse(json);
allData.push(...result.data);
if (allData.length >= result.total || result.data.length < length) {
break;
}
offset += length;
}
return allData;
}
/** Convert a raw EIA region-data row into a typed RegionDataPoint */
function transformRegionDataRow(row: EiaRegionDataRow): RegionDataPoint {
const regionCode = resolveRegionCode(row.respondent);
return {
timestamp: parseEiaPeriod(row.period),
regionCode: regionCode ?? 'PJM',
respondent: row.respondent,
type: row.type,
typeName: row['type-name'],
valueMw: row.value,
valueUnits: row['value-units'],
};
}
/** Convert a raw EIA fuel-type-data row into a typed FuelTypeDataPoint */
function transformFuelTypeRow(row: EiaFuelTypeDataRow): FuelTypeDataPoint {
const regionCode = resolveRegionCode(row.respondent);
return {
timestamp: parseEiaPeriod(row.period),
regionCode: regionCode ?? 'PJM',
respondent: row.respondent,
fuelType: row.fueltype,
typeName: row['type-name'],
generationMw: row.value,
valueUnits: row['value-units'],
};
}
export interface GetRegionDataOptions {
start?: string;
end?: string;
/** Maximum number of rows to fetch. Omit for all available data. */
limit?: number;
}
/**
* Fetch hourly demand or net generation data for a region.
*
* @param regionCode - One of PJM, ERCOT, CAISO, NYISO, ISONE, MISO, SPP
* @param type - "D" for demand, "NG" for net generation
*
* NOTE: EIA does not provide real-time wholesale electricity prices.
* The /v2/electricity/rto/region-data/data/ endpoint provides demand (MW) and
* net generation (MW), NOT price ($/MWh). For the electricity_prices table,
* we store demand_mw from this data; price_mwh will be null.
* TODO: Investigate scraping ISO-specific price feeds (PJM LMP, ERCOT SPP, CAISO LMP)
* for real wholesale price data if needed in the future.
*/
export async function getRegionData(
regionCode: RegionCode,
type: 'D' | 'NG',
options: GetRegionDataOptions = {},
): Promise<RegionDataPoint[]> {
const respondentCode = EIA_RESPONDENT_CODES[regionCode];
const params: EiaQueryParams = {
frequency: 'hourly',
start: options.start,
end: options.end,
facets: {
respondent: [respondentCode],
type: [type],
},
sort: [{ column: 'period', direction: 'desc' }],
length: options.limit ? Math.min(options.limit, MAX_ROWS_PER_REQUEST) : MAX_ROWS_PER_REQUEST,
};
if (options.limit && options.limit <= MAX_ROWS_PER_REQUEST) {
const json = await fetchEia('/electricity/rto/region-data/data/', params);
const parsed = eiaRegionDataResponseSchema.parse(json);
return parsed.response.data.map(transformRegionDataRow);
}
const rows = await fetchAllPages('/electricity/rto/region-data/data/', params, json => {
const parsed = eiaRegionDataResponseSchema.parse(json);
return { total: parsed.response.total, data: parsed.response.data };
});
return rows.map(transformRegionDataRow);
}
export interface GetFuelTypeDataOptions {
start?: string;
end?: string;
fuelType?: string;
limit?: number;
}
/**
* Fetch hourly generation by fuel type for a region.
*
* @param regionCode - One of PJM, ERCOT, CAISO, NYISO, ISONE, MISO, SPP
* @param options - Optional filters for time range and fuel type
*/
export async function getFuelTypeData(
regionCode: RegionCode,
options: GetFuelTypeDataOptions = {},
): Promise<FuelTypeDataPoint[]> {
const respondentCode = EIA_RESPONDENT_CODES[regionCode];
const facets: Record<string, string[]> = {
respondent: [respondentCode],
};
if (options.fuelType) {
facets['fueltype'] = [options.fuelType];
}
const params: EiaQueryParams = {
frequency: 'hourly',
start: options.start,
end: options.end,
facets,
sort: [{ column: 'period', direction: 'desc' }],
length: options.limit ? Math.min(options.limit, MAX_ROWS_PER_REQUEST) : MAX_ROWS_PER_REQUEST,
};
if (options.limit && options.limit <= MAX_ROWS_PER_REQUEST) {
const json = await fetchEia('/electricity/rto/fuel-type-data/data/', params);
const parsed = eiaFuelTypeDataResponseSchema.parse(json);
return parsed.response.data.map(transformFuelTypeRow);
}
const rows = await fetchAllPages('/electricity/rto/fuel-type-data/data/', params, json => {
const parsed = eiaFuelTypeDataResponseSchema.parse(json);
return { total: parsed.response.total, data: parsed.response.data };
});
return rows.map(transformFuelTypeRow);
}
export interface GetCommodityPriceOptions {
start?: string;
end?: string;
limit?: number;
}
/**
* Fetch Henry Hub natural gas spot prices.
* Endpoint: /v2/natural-gas/pri/fut/data/ with facets[series][]=RNGWHHD
*/
export async function getNaturalGasPrice(options: GetCommodityPriceOptions = {}): Promise<CommodityPricePoint[]> {
const params: EiaQueryParams = {
frequency: 'daily',
start: options.start,
end: options.end,
facets: {
series: ['RNGWHHD'],
},
sort: [{ column: 'period', direction: 'desc' }],
length: options.limit ? Math.min(options.limit, MAX_ROWS_PER_REQUEST) : MAX_ROWS_PER_REQUEST,
};
if (options.limit && options.limit <= MAX_ROWS_PER_REQUEST) {
const json = await fetchEia('/natural-gas/pri/fut/data/', params);
const parsed = eiaNaturalGasResponseSchema.parse(json);
return parsed.response.data.map(row => ({
timestamp: parseEiaCommodityPeriod(row.period),
commodity: 'natural_gas' as const,
price: row.value,
unit: row.units ?? '$/Million BTU',
source: 'EIA',
}));
}
const rows = await fetchAllPages('/natural-gas/pri/fut/data/', params, json => {
const parsed = eiaNaturalGasResponseSchema.parse(json);
return { total: parsed.response.total, data: parsed.response.data };
});
return rows.map(row => ({
timestamp: parseEiaCommodityPeriod(row.period),
commodity: 'natural_gas' as const,
price: row.value,
unit: row.units ?? '$/Million BTU',
source: 'EIA',
}));
}
/**
* Fetch WTI crude oil spot prices.
* Endpoint: /v2/petroleum/pri/spt/data/ with facets[series][]=RWTC
*/
export async function getWTICrudePrice(options: GetCommodityPriceOptions = {}): Promise<CommodityPricePoint[]> {
const params: EiaQueryParams = {
frequency: 'daily',
start: options.start,
end: options.end,
facets: {
series: ['RWTC'],
},
sort: [{ column: 'period', direction: 'desc' }],
length: options.limit ? Math.min(options.limit, MAX_ROWS_PER_REQUEST) : MAX_ROWS_PER_REQUEST,
};
if (options.limit && options.limit <= MAX_ROWS_PER_REQUEST) {
const json = await fetchEia('/petroleum/pri/spt/data/', params);
const parsed = eiaWtiCrudeResponseSchema.parse(json);
return parsed.response.data.map(row => ({
timestamp: parseEiaCommodityPeriod(row.period),
commodity: 'wti_crude' as const,
price: row.value,
unit: row.units ?? '$/Barrel',
source: 'EIA',
}));
}
const rows = await fetchAllPages('/petroleum/pri/spt/data/', params, json => {
const parsed = eiaWtiCrudeResponseSchema.parse(json);
return { total: parsed.response.total, data: parsed.response.data };
});
return rows.map(row => ({
timestamp: parseEiaCommodityPeriod(row.period),
commodity: 'wti_crude' as const,
price: row.value,
unit: row.units ?? '$/Barrel',
source: 'EIA',
}));
}
// ---------------------------------------------------------------------------
// Retail electricity prices (monthly, by state)
// ---------------------------------------------------------------------------
export interface GetRetailPriceOptions {
/** Start date in YYYY-MM format */
start?: string;
/** End date in YYYY-MM format */
end?: string;
}
/**
* Fetch monthly retail electricity prices for industrial (IND) sector.
* Returns prices for all 7 tracked regions, mapped via REGION_STATE_MAP.
*
* Endpoint: /v2/electricity/retail-sales/data/
* Price is returned in cents/kWh; we convert to $/MWh (* 10).
*/
export async function getRetailElectricityPrices(options: GetRetailPriceOptions = {}): Promise<RetailPricePoint[]> {
const stateIds = Object.values(REGION_STATE_MAP);
const regionCodes: RegionCode[] = ['PJM', 'ERCOT', 'CAISO', 'NYISO', 'ISONE', 'MISO', 'SPP'];
const stateToRegion = new Map<string, RegionCode>();
for (const region of regionCodes) {
stateToRegion.set(REGION_STATE_MAP[region], region);
}
const params: EiaQueryParams = {
frequency: 'monthly',
start: options.start,
end: options.end,
facets: {
sectorid: ['IND'],
stateid: stateIds,
},
sort: [{ column: 'period', direction: 'desc' }],
dataColumns: ['price'],
};
const rows = await fetchAllPages('/electricity/retail-sales/data/', params, json => {
const parsed = eiaRetailPriceResponseSchema.parse(json);
return { total: parsed.response.total, data: parsed.response.data };
});
const results: RetailPricePoint[] = [];
for (const row of rows) {
if (row.price === null) continue;
const regionCode = stateToRegion.get(row.stateid);
if (!regionCode) continue;
results.push({
period: row.period,
stateId: row.stateid,
regionCode,
priceMwh: row.price * 10, // cents/kWh -> $/MWh
});
}
return results;
}