Energy — electrical grid telemetry
The problem. A distribution operator runs a region of substations, each stepping power down through transformers onto feeders that serve neighbourhoods. Operators need second-by-second load, voltage and frequency; planners need hourly and daily roll-ups to spot trends and size upgrades; and any feeder pushed past its rating must raise an overload alarm.
This scenario centres on aggregation — turning a firehose of telemetry into the roll-ups a SCADA dashboard and a planning report actually consume.
Set up demo data
New workspace? Run this once (Python) to create a feeder's load series with a day of hourly readings including an overload peak — so the roll-up and overload event below have data. The region/substation graph is created by step 1. Safe to re-run.
import datahub_sdk, numpy as np, pandas as pd
client = datahub_sdk.DataHubClient.from_env()
client.timeseries.create([datahub_sdk.TimeSeries(external_id="feeder_f12_load_mw", name="Feeder F12 load", unit="mw")])
idx = pd.date_range(end=pd.Timestamp.now(tz="UTC"), periods=24, freq="1h")
load = 8 + 4 * np.sin(np.arange(24) / 24 * 2 * np.pi) + np.random.normal(0, 0.5, 24)
load[18] = 14.8 # an evening overload past the 12 MW rating
client.timeseries.insert_from_lists(timestamps=idx, values=load, ts="feeder_f12_load_mw")
1. Model the network
A region contains substations, a substation contains transformers, a transformer feeds feeders. The same graph pattern as any asset hierarchy.
- Java
- Python
- Rust
ResourceForm region = new ResourceForm();
region.setExternalId("grid_region_east");
region.setName("Eastern region");
region.setLabels(List.of("Region"));
ResourceForm substation = new ResourceForm();
substation.setExternalId("substation_oslo_1");
substation.setName("Oslo substation 1");
substation.setLabels(List.of("Substation"));
RelForm contains = new RelForm();
contains.setName("contains");
contains.setFromExternalId("grid_region_east");
contains.setToExternalId("substation_oslo_1");
client.resources().create(List.of(region, substation), List.of(contains));
import datahub_sdk
client.resources.create(
[datahub_sdk.Resource(external_id="grid_region_east", name="Eastern region", labels=["Region"]),
datahub_sdk.Resource(external_id="substation_oslo_1", name="Oslo substation 1", labels=["Substation"])],
[datahub_sdk.RelForm.by_external_ids("grid_region_east", "substation_oslo_1", "contains")])
use dataplatform_rust_sdk::resources::Resource;
use dataplatform_rust_sdk::relations::RelForm;
let mut region = Resource::new();
region.external_id = "grid_region_east".into();
region.name = "Eastern region".into();
region.labels = Some(vec!["Region".into()]);
let mut substation = Resource::new();
substation.external_id = "substation_oslo_1".into();
substation.name = "Oslo substation 1".into();
substation.labels = Some(vec!["Substation".into()]);
api.resources.create(
vec![region, substation],
vec![RelForm::by_external_ids("grid_region_east", "substation_oslo_1", "contains")],
).await?;
Per-feeder series follow the same naming: feeder_f12_load_mw, feeder_f12_voltage_kv,
substation_oslo_1_frequency_hz.
2. Roll telemetry up for the dashboard
Raw load is far too dense to chart directly. Ask for hourly averages and peaks and the platform returns one value per bucket. The full mechanics are in Query & aggregate.
- Java
- Python
- Rust
var filter = new RetrieveFilter();
filter.setExternalId("feeder_f12_load_mw");
filter.setStart(ZonedDateTime.now().minusDays(1));
filter.setEnd(ZonedDateTime.now());
filter.setAggregates(List.of("avg", "max"));
filter.setGranularity("1h");
var request = new DataRetriever<RetrieveFilter>();
request.setItems(List.of(filter));
client.timeseries().retrieve(request).getItems().get(0).getDatapoints()
.forEach(p -> chart(p.getTimestamp(), p.getValue())); // hourly load
rf = datahub_sdk.RetrieveFilter(
ts="feeder_f12_load_mw",
start=pd.Timestamp.now(tz="UTC") - pd.Timedelta(days=1),
end=pd.Timestamp.now(tz="UTC"),
aggregates=["avg", "max"], granularity="1h")
for dp in client.timeseries.retrieve_datapoints(rf)[0].get_datapoints():
chart(dp.timestamp, dp.average, dp.max)
use chrono::Utc;
let filter = RetrieveFilter {
external_id: Some("feeder_f12_load_mw".into()),
start: Some(Utc::now() - chrono::Duration::days(1)),
end: Some(Utc::now()),
aggregates: Some(vec!["avg".into(), "max".into()]),
granularity: Some("1h".into()),
..Default::default()
};
let series = api.time_series
.retrieve_datapoints(&DataWrapper::from(vec![filter])).await?
.get_items().remove(0);
for p in &series.datapoints { chart(&p.timestamp, p.average, p.max); }
3. Overload alarm
When a feeder's hourly peak exceeds its rating, record a feeder_overload event so
the planning team sees a ranked list of strained circuits.
- Java
- Python
- Rust
EventModel event = new EventModel();
event.setExternalId("feeder_overload_f12_" + System.currentTimeMillis());
event.setType("feeder_overload");
event.setStatus("open");
event.setMetadata(Map.of("feeder", "feeder_f12", "peak_mw", "14.8", "rating_mw", "12.0"));
event.setEventTime(ZonedDateTime.now());
client.events().create(List.of(event));
client.events.create([datahub_sdk.Event(
external_id=f"feeder_overload_f12_{int(pd.Timestamp.now().timestamp())}",
type="feeder_overload", status="open",
event_time=pd.Timestamp.now(tz="UTC"),
metadata={"feeder": "feeder_f12", "peak_mw": "14.8", "rating_mw": "12.0"})])
use chrono::Utc;
let mut event = Event::new(format!("feeder_overload_f12_{}", Utc::now().timestamp()));
event.r#type = Some("feeder_overload".into());
event.status = Some("open".into());
event.add_metadata("feeder".into(), "feeder_f12".into());
event.add_metadata("peak_mw".into(), "14.8".into());
event.add_metadata("rating_mw".into(), "12.0".into());
event.set_event_time(Utc::now());
api.events.create(&vec![event]).await?;
See the result
Chart the feeder's hourly peak load — one bar pokes above the 12 MW rating line, which is the overload the event flags:
import matplotlib.pyplot as plt
rf = datahub_sdk.RetrieveFilter(ts="feeder_f12_load_mw",
start=pd.Timestamp.now(tz="UTC") - pd.Timedelta(days=1), end=pd.Timestamp.now(tz="UTC"),
aggregates=["max"], granularity="1h")
peaks = [dp.max for dp in client.timeseries.retrieve_datapoints(rf)[0].get_datapoints()]
plt.bar(range(len(peaks)), peaks); plt.axhline(12, color="r", ls="--")
plt.title("Feeder F12 hourly peak load (MW)"); plt.show()
See also
- Query & aggregate time-series — granularity and aggregate options.
- Model assets as a graph — region/substation/feeder topology.
- Turn readings into events — the overload rule.