- Add 7 new grid regions (BPA, DUKE, SOCO, TVA, FPC, WAPA, NWMT) to cover entire continental US - Expand datacenters from 108 to 292 facilities across 39 operators - Add EIA power plant pipeline: download script, 3,546 plants >= 50 MW with diamond map markers - Rewrite backfill script for 10-year data (2015-07-01) with quarterly/monthly chunking, 3-region parallelism, resumability - Add materialized views (daily/weekly) with server-side granularity selection for chart performance - Fix map UX: z-index tooltips, disable POI clicks, move legend via MapControl
320 lines
9.8 KiB
TypeScript
320 lines
9.8 KiB
TypeScript
import { PrismaPg } from '@prisma/adapter-pg';
|
|
import { randomUUID } from 'crypto';
|
|
import { readFileSync } from 'fs';
|
|
import { resolve } from 'path';
|
|
import { z } from 'zod/v4';
|
|
import { PrismaClient } from '../src/generated/prisma/client.js';
|
|
|
|
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
|
|
const prisma = new PrismaClient({ adapter });
|
|
|
|
const PointGeometrySchema = z.object({
|
|
type: z.literal('Point'),
|
|
coordinates: z.tuple([z.number(), z.number()]),
|
|
});
|
|
|
|
const MultiPolygonGeometrySchema = z.object({
|
|
type: z.literal('MultiPolygon'),
|
|
coordinates: z.array(z.array(z.array(z.tuple([z.number(), z.number()])))),
|
|
});
|
|
|
|
const RegionPropertiesSchema = z.object({
|
|
name: z.string(),
|
|
code: z.string(),
|
|
iso: z.string(),
|
|
});
|
|
|
|
const DatacenterPropertiesSchema = z.object({
|
|
name: z.string(),
|
|
operator: z.string(),
|
|
capacity_mw: z.number(),
|
|
status: z.string(),
|
|
year_opened: z.number(),
|
|
region: z.string(),
|
|
});
|
|
|
|
const RegionFeatureSchema = z.object({
|
|
type: z.literal('Feature'),
|
|
geometry: MultiPolygonGeometrySchema,
|
|
properties: RegionPropertiesSchema,
|
|
});
|
|
|
|
const DatacenterFeatureSchema = z.object({
|
|
type: z.literal('Feature'),
|
|
geometry: PointGeometrySchema,
|
|
properties: DatacenterPropertiesSchema,
|
|
});
|
|
|
|
const RegionCollectionSchema = z.object({
|
|
type: z.literal('FeatureCollection'),
|
|
features: z.array(RegionFeatureSchema),
|
|
});
|
|
|
|
const DatacenterCollectionSchema = z.object({
|
|
type: z.literal('FeatureCollection'),
|
|
features: z.array(DatacenterFeatureSchema),
|
|
});
|
|
|
|
const PowerPlantPropertiesSchema = z.object({
|
|
Plant_Name: z.string(),
|
|
Plant_Code: z.number(),
|
|
Utility_Na: z.string(),
|
|
State: z.string(),
|
|
PrimSource: z.string(),
|
|
Total_MW: z.number(),
|
|
});
|
|
|
|
const PowerPlantFeatureSchema = z.object({
|
|
type: z.literal('Feature'),
|
|
geometry: PointGeometrySchema,
|
|
properties: PowerPlantPropertiesSchema,
|
|
});
|
|
|
|
const PowerPlantCollectionSchema = z.object({
|
|
type: z.literal('FeatureCollection'),
|
|
features: z.array(PowerPlantFeatureSchema),
|
|
});
|
|
|
|
const AIMilestoneSchema = z.object({
|
|
date: z.string(),
|
|
title: z.string(),
|
|
description: z.string(),
|
|
category: z.string(),
|
|
});
|
|
|
|
function readAndParse<T>(relativePath: string, schema: z.ZodType<T>): T {
|
|
const fullPath = resolve(import.meta.dirname, '..', relativePath);
|
|
const raw: unknown = JSON.parse(readFileSync(fullPath, 'utf-8'));
|
|
return schema.parse(raw);
|
|
}
|
|
|
|
async function seedGridRegions() {
|
|
console.log('Seeding grid regions...');
|
|
|
|
const geojson = readAndParse('data/grid-regions.geojson', RegionCollectionSchema);
|
|
|
|
// Delete only static seed data that doesn't have time-series FK references
|
|
await prisma.$executeRawUnsafe('DELETE FROM datacenters');
|
|
|
|
// Upsert grid_regions by code to preserve FK references from time-series tables
|
|
for (const feature of geojson.features) {
|
|
const id = randomUUID();
|
|
const geojsonStr = JSON.stringify(feature.geometry);
|
|
|
|
await prisma.$executeRawUnsafe(
|
|
`INSERT INTO grid_regions (id, name, code, iso, boundary, created_at)
|
|
VALUES ($1::uuid, $2, $3, $4, ST_GeomFromGeoJSON($5)::geography, NOW())
|
|
ON CONFLICT (code) DO UPDATE SET
|
|
name = EXCLUDED.name,
|
|
iso = EXCLUDED.iso,
|
|
boundary = EXCLUDED.boundary`,
|
|
id,
|
|
feature.properties.name,
|
|
feature.properties.code,
|
|
feature.properties.iso,
|
|
geojsonStr,
|
|
);
|
|
|
|
console.log(` Upserted region: ${feature.properties.code}`);
|
|
}
|
|
}
|
|
|
|
async function seedDatacenters() {
|
|
console.log('Seeding datacenters...');
|
|
|
|
const geojson = readAndParse('data/datacenters.geojson', DatacenterCollectionSchema);
|
|
|
|
// Get all region codes from DB
|
|
const regions = await prisma.$queryRawUnsafe<Array<{ id: string; code: string }>>(
|
|
'SELECT id, code FROM grid_regions',
|
|
);
|
|
const regionMap = new Map(regions.map(r => [r.code, r.id]));
|
|
|
|
let inserted = 0;
|
|
let skipped = 0;
|
|
|
|
for (const feature of geojson.features) {
|
|
const props = feature.properties;
|
|
const regionId = regionMap.get(props.region);
|
|
|
|
if (!regionId) {
|
|
// Try to find the region by spatial containment
|
|
const [lng, lat] = feature.geometry.coordinates;
|
|
const spatialMatch = await prisma.$queryRawUnsafe<Array<{ id: string; code: string }>>(
|
|
`SELECT id, code FROM grid_regions
|
|
WHERE ST_Contains(boundary::geometry, ST_SetSRID(ST_MakePoint($1, $2), 4326))
|
|
LIMIT 1`,
|
|
lng,
|
|
lat,
|
|
);
|
|
|
|
if (spatialMatch.length > 0) {
|
|
const match = spatialMatch[0]!;
|
|
const id = randomUUID();
|
|
const geojsonStr = JSON.stringify(feature.geometry);
|
|
|
|
await prisma.$executeRawUnsafe(
|
|
`INSERT INTO datacenters (id, name, operator, location, capacity_mw, status, year_opened, region_id, created_at)
|
|
VALUES ($1::uuid, $2, $3, ST_GeomFromGeoJSON($4)::geography, $5, $6, $7, $8::uuid, NOW())`,
|
|
id,
|
|
props.name,
|
|
props.operator,
|
|
geojsonStr,
|
|
props.capacity_mw,
|
|
props.status,
|
|
props.year_opened,
|
|
match.id,
|
|
);
|
|
console.log(` Inserted DC: ${props.name} (spatial match -> ${match.code})`);
|
|
inserted++;
|
|
} else {
|
|
console.log(` Skipped DC: ${props.name} (no matching region for "${props.region}")`);
|
|
skipped++;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const id = randomUUID();
|
|
const geojsonStr = JSON.stringify(feature.geometry);
|
|
|
|
await prisma.$executeRawUnsafe(
|
|
`INSERT INTO datacenters (id, name, operator, location, capacity_mw, status, year_opened, region_id, created_at)
|
|
VALUES ($1::uuid, $2, $3, ST_GeomFromGeoJSON($4)::geography, $5, $6, $7, $8::uuid, NOW())`,
|
|
id,
|
|
props.name,
|
|
props.operator,
|
|
geojsonStr,
|
|
props.capacity_mw,
|
|
props.status,
|
|
props.year_opened,
|
|
regionId,
|
|
);
|
|
console.log(` Inserted DC: ${props.name} (${props.region})`);
|
|
inserted++;
|
|
}
|
|
|
|
console.log(` Total: ${inserted.toString()} inserted, ${skipped.toString()} skipped`);
|
|
}
|
|
|
|
/** Normalize ArcGIS PrimSource strings to consistent capitalized fuel types. */
|
|
function normalizeFuelType(primSource: string): string {
|
|
const lower = primSource.toLowerCase().trim();
|
|
if (lower === 'natural gas') return 'Natural Gas';
|
|
if (lower === 'coal') return 'Coal';
|
|
if (lower === 'nuclear') return 'Nuclear';
|
|
if (lower.includes('hydro') || lower === 'hydroelectric conventional' || lower === 'pumped storage')
|
|
return 'Hydroelectric';
|
|
if (lower === 'wind') return 'Wind';
|
|
if (lower === 'solar') return 'Solar';
|
|
if (lower.includes('petroleum') || lower === 'petroleum') return 'Petroleum';
|
|
if (lower.includes('biomass') || lower === 'wood' || lower === 'wood and wood derived fuels') return 'Biomass';
|
|
if (lower === 'geothermal') return 'Geothermal';
|
|
return 'Other';
|
|
}
|
|
|
|
async function seedPowerPlants() {
|
|
console.log('Seeding power plants...');
|
|
|
|
const geojson = readAndParse('data/power-plants.geojson', PowerPlantCollectionSchema);
|
|
|
|
let upserted = 0;
|
|
let skipped = 0;
|
|
|
|
for (const feature of geojson.features) {
|
|
const props = feature.properties;
|
|
const [lng, lat] = feature.geometry.coordinates;
|
|
|
|
// Skip features with invalid coordinates
|
|
if (!lng || !lat || lat === 0 || lng === 0) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
const id = randomUUID();
|
|
const fuelType = normalizeFuelType(props.PrimSource);
|
|
|
|
await prisma.$executeRawUnsafe(
|
|
`INSERT INTO power_plants (id, plant_code, name, operator, location, capacity_mw, fuel_type, state, created_at)
|
|
VALUES ($1::uuid, $2, $3, $4, ST_SetSRID(ST_MakePoint($5, $6), 4326)::geography, $7, $8, $9, NOW())
|
|
ON CONFLICT (plant_code) DO UPDATE SET
|
|
name = EXCLUDED.name,
|
|
operator = EXCLUDED.operator,
|
|
location = EXCLUDED.location,
|
|
capacity_mw = EXCLUDED.capacity_mw,
|
|
fuel_type = EXCLUDED.fuel_type,
|
|
state = EXCLUDED.state`,
|
|
id,
|
|
props.Plant_Code,
|
|
props.Plant_Name,
|
|
props.Utility_Na,
|
|
lng,
|
|
lat,
|
|
props.Total_MW,
|
|
fuelType,
|
|
props.State,
|
|
);
|
|
upserted++;
|
|
}
|
|
|
|
console.log(` Total: ${upserted.toString()} upserted, ${skipped.toString()} skipped`);
|
|
}
|
|
|
|
function validateAIMilestones() {
|
|
console.log('Validating AI milestones...');
|
|
const milestones = readAndParse('data/ai-milestones.json', z.array(AIMilestoneSchema));
|
|
console.log(` Valid JSON with ${milestones.length.toString()} milestones`);
|
|
|
|
for (const m of milestones) {
|
|
if (isNaN(Date.parse(m.date))) {
|
|
throw new Error(`Invalid date in milestone: ${m.date}`);
|
|
}
|
|
}
|
|
console.log(' All milestones valid');
|
|
}
|
|
|
|
async function main() {
|
|
console.log('Starting seed...\n');
|
|
|
|
await seedGridRegions();
|
|
console.log('');
|
|
|
|
await seedDatacenters();
|
|
console.log('');
|
|
|
|
await seedPowerPlants();
|
|
console.log('');
|
|
|
|
validateAIMilestones();
|
|
|
|
// Print summary
|
|
console.log('\n--- Seed Summary ---');
|
|
const regionCount = await prisma.$queryRawUnsafe<Array<{ count: bigint }>>(
|
|
'SELECT count(*) as count FROM grid_regions',
|
|
);
|
|
const dcCount = await prisma.$queryRawUnsafe<Array<{ count: bigint }>>('SELECT count(*) as count FROM datacenters');
|
|
const ppCount = await prisma.$queryRawUnsafe<Array<{ count: bigint }>>('SELECT count(*) as count FROM power_plants');
|
|
console.log(`Grid regions: ${regionCount[0]!.count.toString()}`);
|
|
console.log(`Datacenters: ${dcCount[0]!.count.toString()}`);
|
|
console.log(`Power plants: ${ppCount[0]!.count.toString()}`);
|
|
|
|
// Show sample spatial data
|
|
const sample = await prisma.$queryRawUnsafe<Array<{ name: string; location_text: string }>>(
|
|
'SELECT name, ST_AsText(location) as location_text FROM datacenters LIMIT 3',
|
|
);
|
|
console.log('\nSample datacenter locations:');
|
|
for (const s of sample) {
|
|
console.log(` ${s.name}: ${s.location_text}`);
|
|
}
|
|
|
|
console.log('\nSeed complete!');
|
|
}
|
|
|
|
main()
|
|
.catch((e: unknown) => {
|
|
console.error('Seed failed:', e);
|
|
process.exit(1);
|
|
})
|
|
.finally(() => {
|
|
void prisma.$disconnect();
|
|
});
|