The business problem
Electric utilities must balance generation with demand in real time. Over-forecasting wastes money on unnecessary generation. Under-forecasting requires expensive spot-market purchases or, in extreme cases, rolling blackouts. A 1% improvement in forecast accuracy saves a large utility $10M+ annually in procurement costs. During extreme weather events, when demand spikes are correlated across the grid, accurate forecasting can prevent cascading failures.
Why flat ML fails
- Independent meter forecasts: Forecasting each meter independently ignores spatial correlations. Meters in the same zone experience the same weather, demographics, and economic conditions.
- No grid topology: Load on a transformer is the sum of downstream meters. Independent forecasts at different hierarchy levels are inconsistent. The graph enforces consistency.
- Weather correlation blindness: A heat wave affects all meters in a region, but the impact varies by building type and grid position. Flat models cannot propagate weather effects through the spatial network.
- Extreme event failure: Independent models fail most during extreme events (heat waves, cold snaps) when spatial correlation is highest and accurate forecasting is most critical.
The relational schema
Node types:
Meter (id, type, building_class, capacity)
Feeder (id, capacity, transformer_count)
Substation (id, capacity, voltage_level)
Weather (id, station_name, geo_lat, geo_lon)
Edge types:
Meter --[on_feeder]--> Feeder
Feeder --[at_substation]--> Substation
Meter --[near]--> Meter (distance_km)
Meter --[monitored_by]--> Weather
Weather --[correlated]--> Weather (distance_km)The hierarchical grid graph (meter-feeder-substation) plus spatial proximity and weather connections.
PyG architecture: SAGEConv + GRU for spatial-temporal load
import torch
import torch.nn.functional as F
from torch_geometric.nn import SAGEConv, HeteroConv, Linear
class EnergyGNN(torch.nn.Module):
def __init__(self, meter_dim, hidden_dim=128):
super().__init__()
self.meter_lin = Linear(meter_dim, hidden_dim)
self.feeder_lin = Linear(-1, hidden_dim)
self.weather_lin = Linear(-1, hidden_dim)
self.conv1 = HeteroConv({
('meter', 'near', 'meter'): SAGEConv(
hidden_dim, hidden_dim),
('meter', 'on_feeder', 'feeder'): SAGEConv(
hidden_dim, hidden_dim),
('meter', 'monitored_by', 'weather'): SAGEConv(
hidden_dim, hidden_dim),
}, aggr='mean')
self.conv2 = HeteroConv({
('meter', 'near', 'meter'): SAGEConv(
hidden_dim, hidden_dim),
('meter', 'on_feeder', 'feeder'): SAGEConv(
hidden_dim, hidden_dim),
}, aggr='mean')
# Temporal: GRU over hourly snapshots
self.gru = torch.nn.GRU(
hidden_dim, hidden_dim, batch_first=True)
# Forecast head: next 24 hours
self.head = torch.nn.Sequential(
Linear(hidden_dim, 64),
torch.nn.ReLU(),
Linear(64, 24), # 24-hour forecast
)
def forward(self, x_seq_dict, edge_index_dict):
# Process each hourly snapshot through spatial GNN
T = len(x_seq_dict['meter'])
embeddings = []
for t in range(T):
x_dict = {
'meter': self.meter_lin(x_seq_dict['meter'][t]),
'feeder': self.feeder_lin(x_seq_dict['feeder'][t]),
'weather': self.weather_lin(
x_seq_dict['weather'][t]),
}
x_dict = {k: F.relu(v) for k, v in
self.conv1(x_dict, edge_index_dict).items()}
x_dict = self.conv2(x_dict, edge_index_dict)
embeddings.append(x_dict['meter'])
h_seq = torch.stack(embeddings, dim=1)
_, h_final = self.gru(h_seq)
return self.head(h_final.squeeze(0))Spatial GNN per timestep + GRU for temporal patterns. Output: 24-hour load forecast per meter. Aggregate up the grid hierarchy for feeder and substation forecasts.
Expected performance
Load forecasting is a regression task. The standard metric is Mean Absolute Percentage Error (MAPE):
- ARIMA (per-meter): ~8% MAPE
- LightGBM (flat-table): ~6% MAPE
- GNN (spatial-temporal): ~4-5% MAPE
- KumoRFM (zero-shot): ~4% MAPE
Or use KumoRFM in one line
PREDICT hourly_load FOR meter
USING meter, feeder, substation, weather, reading_historyOne PQL query. KumoRFM captures spatial meter correlations and weather effects for hierarchical load forecasting.