Skip to content

Commit 443cfc6

Browse files
authored
feat: add wholesale electricity pricing nodes dataset (#29)
* feat: add wholesale electricity pricing nodes dataset - 4,065 pricing nodes across all 7 US ISOs/RTOs (CAISO, PJM, ERCOT, MISO, NYISO, ISO-NE, SPP) - Trading hubs, load zones, SUBLAPs (CAISO), LAPs, and generation pricing nodes - Generation nodes cross-referenced with EIA-860 power plant data for coordinates - Zone/hub centroids from ISO public reference data - Full pipeline: sync script, copy, GeoJSON prep, tippecanoe tiles, tile API route - List page with search, ISO/type/state filters, sortable table - Detail page with overview, location, and interactive map - ExplorerMap layer color-coded by ISO/RTO with tooltips - Homepage card with count, about page data sources updated - DATA_CATALOG.md updated with pricing nodes entry - Monthly sync GitHub Actions workflow - Also fixes pre-existing BrandProvider darkVariables type error in Providers.tsx * fix: remove Pricing Nodes from top nav
1 parent 4f87b0c commit 443cfc6

File tree

18 files changed

+115188
-5
lines changed

18 files changed

+115188
-5
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: Sync Pricing Nodes Data
2+
3+
on:
4+
schedule:
5+
- cron: '0 12 1 * *' # 1st of each month at 12pm UTC
6+
workflow_dispatch: {} # Manual trigger
7+
8+
jobs:
9+
sync:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
- uses: actions/setup-node@v4
14+
with:
15+
node-version: 20
16+
cache: npm
17+
- run: npm ci
18+
- name: Sync pricing nodes
19+
run: npx tsx scripts/sync-pricing-nodes.ts
20+
- name: Copy pricing nodes to public/data
21+
run: node scripts/copy-pricing-nodes.mjs
22+
- name: Install tippecanoe
23+
run: |
24+
sudo apt-get update -qq
25+
sudo apt-get install -y -qq libsqlite3-dev zlib1g-dev
26+
git clone --depth 1 https://github.com/felt/tippecanoe.git /tmp/tippecanoe
27+
cd /tmp/tippecanoe && make -j$(nproc) && sudo make install
28+
- name: Install pmtiles CLI
29+
run: |
30+
curl -sL https://github.com/protomaps/go-pmtiles/releases/latest/download/go-pmtiles_Linux_x86_64.tar.gz | tar -xz -C /usr/local/bin pmtiles
31+
- name: Rebuild tiles
32+
run: bash scripts/build-tiles.sh
33+
- name: Commit & push if changed
34+
run: |
35+
git config user.name "github-actions[bot]"
36+
git config user.email "github-actions[bot]@users.noreply.github.com"
37+
git add data/pricing-nodes.json public/data/pricing-nodes.json public/tiles/pricing-nodes.pmtiles
38+
git diff --staged --quiet || (git commit -m "data: update pricing nodes (monthly sync)" && git push)

app/(shell)/about/page.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ const dataSources = [
99
{ name: "HIFLD", description: "Homeland Infrastructure Foundation-Level Data — electric service territory boundaries and 52,000+ transmission line segments" },
1010
{ name: "DOE AFDC", description: "Alternative Fuels Data Center — 85,000+ US EV charging stations with network, connector, and access data. Updated weekly." },
1111
{ name: "CEC", description: "California Energy Commission — CCA territory data and California-specific utility information" },
12+
{ name: "CAISO OASIS", description: "California ISO Open Access Same-time Information System — pricing node definitions and wholesale market reference data" },
13+
{ name: "ISO/RTO Public Data", description: "Public pricing node, zone, and hub data from CAISO, PJM, ERCOT, MISO, NYISO, ISO-NE, and SPP" },
1214
{ name: "FERC", description: "Federal Energy Regulatory Commission — ISO/RTO boundaries and wholesale market data" },
1315
{ name: "State PUC Records", description: "State Public Utility Commission filings — rate structures and regulatory data" },
1416
];
@@ -18,6 +20,7 @@ const dataHighlights = [
1820
{ label: "Power Plants", value: "15,082", icon: "🏭" },
1921
{ label: "Transmission Lines", value: "52,000+", icon: "🔌" },
2022
{ label: "EV Charging Stations", value: "85,425", icon: "🔋" },
23+
{ label: "Pricing Nodes", value: "4,065", icon: "💰" },
2124
{ label: "Territory Boundaries", value: "3,000+ GeoJSON", icon: "🗺️" },
2225
];
2326

app/(shell)/page.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ const POWER_PLANT_COUNT = 15082;
1111
const TRANSMISSION_LINE_COUNT = 52244;
1212
// EV charging station count is hardcoded for the same reason. Updated by sync-ev-charging script.
1313
const EV_STATION_COUNT = 85425;
14+
// Pricing node count — updated by sync-pricing-nodes script.
15+
const pricingNodeCount = 4065;
1416
const RATE_SCHEDULE_COUNT = "~12k";
1517
const TERRITORY_COUNT = 4841;
1618

@@ -111,6 +113,18 @@ const ENTITY_CARDS = [
111113
countKey: "evStations" as const,
112114
countLabel: "stations",
113115
},
116+
{
117+
id: "pricing-nodes",
118+
href: "/pricing-nodes",
119+
iconBg: "bg-yellow-50",
120+
iconColor: "text-yellow-600",
121+
iconName: "Lightning" as const,
122+
title: "Pricing Nodes",
123+
description:
124+
"Wholesale electricity market nodes — trading hubs, load zones, SUBLAPs, and generation pricing points across 7 ISOs/RTOs.",
125+
countKey: "pricingNodes" as const,
126+
countLabel: "nodes",
127+
},
114128
];
115129

116130
const RECENT_UPDATES = [
@@ -235,6 +249,7 @@ export default function LandingPage() {
235249
powerPlants: POWER_PLANT_COUNT.toLocaleString(),
236250
transmissionLines: TRANSMISSION_LINE_COUNT.toLocaleString(),
237251
evStations: EV_STATION_COUNT.toLocaleString(),
252+
pricingNodes: pricingNodeCount.toLocaleString(),
238253
};
239254

240255
return (
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
"use client";
2+
3+
import {
4+
Badge,
5+
Card,
6+
InteractiveMap,
7+
layer,
8+
Loader,
9+
PageLayout,
10+
Section,
11+
} from "@texturehq/edges";
12+
import Link from "next/link";
13+
import { notFound, useParams } from "next/navigation";
14+
import { DataSourceLink } from "@/components/DataSourceLink";
15+
import { usePricingNode } from "@/lib/pricing-nodes";
16+
import {
17+
type IsoRto,
18+
type PricingNodeType,
19+
ISO_LABELS,
20+
ISO_FULL_NAMES,
21+
NODE_TYPE_LABELS,
22+
getIsoColor,
23+
} from "@/types/pricing-nodes";
24+
25+
function getNodeTypeBadgeVariant(type: PricingNodeType): "success" | "info" | "warning" | "neutral" {
26+
switch (type) {
27+
case "hub": return "warning";
28+
case "zone": return "info";
29+
case "sublap": return "info";
30+
case "lap": return "info";
31+
case "gen": return "success";
32+
case "load": return "neutral";
33+
case "interface": return "neutral";
34+
default: return "neutral";
35+
}
36+
}
37+
38+
export default function PricingNodeDetailPage() {
39+
const params = useParams<{ slug: string }>();
40+
const { node, isLoading } = usePricingNode(params.slug);
41+
42+
if (isLoading) {
43+
return (
44+
<PageLayout>
45+
<PageLayout.Header
46+
title="Pricing Node"
47+
breadcrumbs={[{ label: "Pricing Nodes", href: "/pricing-nodes" }]}
48+
/>
49+
<div className="flex items-center justify-center py-24">
50+
<Loader size={32} />
51+
</div>
52+
</PageLayout>
53+
);
54+
}
55+
56+
if (!node) {
57+
notFound();
58+
}
59+
60+
const isoColor = getIsoColor(node.iso);
61+
62+
const pointGeoJSON = {
63+
type: "FeatureCollection" as const,
64+
features: [
65+
{
66+
type: "Feature" as const,
67+
properties: { name: node.name },
68+
geometry: {
69+
type: "Point" as const,
70+
coordinates: [node.longitude, node.latitude],
71+
},
72+
},
73+
],
74+
};
75+
76+
const mapboxToken = process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN;
77+
78+
return (
79+
<PageLayout>
80+
<PageLayout.Header
81+
title={node.name}
82+
breadcrumbs={[
83+
{ label: "Pricing Nodes", href: "/pricing-nodes" },
84+
{ label: node.slug, copyable: true, copyValue: node.slug },
85+
]}
86+
/>
87+
<DataSourceLink paths={["data/pricing-nodes.json"]} className="px-4 sm:px-6 pb-2" />
88+
<PageLayout.Content>
89+
{/* Overview */}
90+
<Section id="overview" navLabel="Overview" title="Overview" withDivider>
91+
<Card variant="outlined">
92+
<Card.Content>
93+
<div className="flex items-center gap-3 mb-6">
94+
<span
95+
className="w-4 h-4 rounded-full flex-shrink-0"
96+
style={{ backgroundColor: isoColor }}
97+
/>
98+
<div>
99+
<div className="text-lg font-semibold">{node.name}</div>
100+
<div className="text-sm text-text-muted">
101+
{ISO_LABELS[node.iso]} · {NODE_TYPE_LABELS[node.nodeType]}
102+
</div>
103+
</div>
104+
</div>
105+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
106+
<div>
107+
<div className="text-sm text-text-muted mb-1">ISO/RTO</div>
108+
<div className="font-medium">{ISO_FULL_NAMES[node.iso]}</div>
109+
</div>
110+
<div>
111+
<div className="text-sm text-text-muted mb-1">Node Type</div>
112+
<Badge size="sm" shape="pill" variant={getNodeTypeBadgeVariant(node.nodeType)}>
113+
{NODE_TYPE_LABELS[node.nodeType]}
114+
</Badge>
115+
</div>
116+
<div>
117+
<div className="text-sm text-text-muted mb-1">Zone</div>
118+
<div className="font-medium">{node.zone ?? "—"}</div>
119+
</div>
120+
<div>
121+
<div className="text-sm text-text-muted mb-1">State</div>
122+
<div className="font-medium">{node.state ?? "—"}</div>
123+
</div>
124+
</div>
125+
</Card.Content>
126+
</Card>
127+
</Section>
128+
129+
{/* Location Details */}
130+
<Section id="location" navLabel="Location" title="Location" withDivider>
131+
<Card variant="outlined">
132+
<Card.Content>
133+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
134+
<div>
135+
<div className="text-sm text-text-muted mb-1">Latitude</div>
136+
<div className="font-medium font-mono text-sm">{node.latitude.toFixed(4)}</div>
137+
</div>
138+
<div>
139+
<div className="text-sm text-text-muted mb-1">Longitude</div>
140+
<div className="font-medium font-mono text-sm">{node.longitude.toFixed(4)}</div>
141+
</div>
142+
{node.voltageKv && (
143+
<div>
144+
<div className="text-sm text-text-muted mb-1">Voltage</div>
145+
<div className="font-medium">{node.voltageKv} kV</div>
146+
</div>
147+
)}
148+
{node.eiaPlantCode && (
149+
<div>
150+
<div className="text-sm text-text-muted mb-1">EIA Plant Code</div>
151+
<div className="font-medium font-mono text-sm">
152+
<Link
153+
href={`/power-plants/${node.eiaPlantCode}`}
154+
className="text-brand-primary hover:underline"
155+
>
156+
{node.eiaPlantCode}
157+
</Link>
158+
</div>
159+
</div>
160+
)}
161+
</div>
162+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
163+
<div>
164+
<div className="text-sm text-text-muted mb-1">Data Source</div>
165+
<div className="font-medium">{node.source}</div>
166+
</div>
167+
<div>
168+
<div className="text-sm text-text-muted mb-1">Node ID</div>
169+
<div className="font-medium font-mono text-sm">{node.id}</div>
170+
</div>
171+
</div>
172+
</Card.Content>
173+
</Card>
174+
</Section>
175+
176+
{/* Map */}
177+
{mapboxToken && (
178+
<Section id="map" navLabel="Map" title="Map" withDivider>
179+
<Card variant="outlined">
180+
<Card.Content className="p-0 overflow-hidden rounded-lg">
181+
<div style={{ height: 400 }}>
182+
<InteractiveMap
183+
mapboxAccessToken={mapboxToken}
184+
initialViewState={{
185+
longitude: node.longitude,
186+
latitude: node.latitude,
187+
zoom: node.nodeType === "zone" || node.nodeType === "hub" ? 6 : 10,
188+
}}
189+
mapType="neutral"
190+
controls={[{ type: "navigation", position: "bottom-right" }]}
191+
layers={[
192+
layer.geojson({
193+
id: "node-point",
194+
data: pointGeoJSON,
195+
renderAs: "circle",
196+
style: {
197+
color: { hex: isoColor },
198+
radius: 8,
199+
borderWidth: 2,
200+
borderColor: { hex: "#ffffff" },
201+
},
202+
tooltip: {
203+
trigger: "hover",
204+
content: () => (
205+
<div className="text-sm font-medium">{node.name}</div>
206+
),
207+
},
208+
}),
209+
]}
210+
/>
211+
</div>
212+
</Card.Content>
213+
</Card>
214+
</Section>
215+
)}
216+
</PageLayout.Content>
217+
</PageLayout>
218+
);
219+
}

0 commit comments

Comments
 (0)