Water utilities — distribution & quality
The problem. A water network flows from sources through trunk mains into district zones and out to meters. When two distant zones report the same quality problem — turbidity, low chlorine — at nearly the same time, the operator must find the common upstream source fast, because that's where to isolate and flush. With a flat sensor list it's guesswork. On the flow graph, it's the point where the two zones' upstream paths converge.
This scenario introduces the multi-source common-cause graph pattern: trace upstream from each affected zone and intersect to find where the trouble enters.
Set up demo data
New workspace? Run this once (Python) to create the flow-network graph (a source feeding a trunk main, which feeds two zones) and a turbidity reading out of range in each zone — so the upstream-convergence trace finds their shared main. Safe to re-run.
import datahub_sdk, pandas as pd
client = datahub_sdk.DataHubClient.from_env()
client.resources.create(
[datahub_sdk.Resource(external_id=x, name=x, labels=[x]) for x in
["source_lake_a", "main_trunk_north", "zone_west_12", "zone_east_07"]],
[datahub_sdk.RelForm.by_external_ids("source_lake_a", "main_trunk_north", "feeds"),
datahub_sdk.RelForm.by_external_ids("main_trunk_north", "zone_west_12", "feeds"),
datahub_sdk.RelForm.by_external_ids("main_trunk_north", "zone_east_07", "feeds")])
for s in ["zone_west_12_turbidity_ntu", "zone_east_07_turbidity_ntu"]:
client.timeseries.create([datahub_sdk.TimeSeries(external_id=s, name=s, unit="ntu", value_type="float")])
client.timeseries.insert_from_lists(timestamps=[pd.Timestamp.now(tz="UTC")], values=[7.5], ts=s)
1. Model the flow network
Each feeds edge points downstream: source → main → zone. Walking against the flow
(undirected traversal) recovers a zone's upstream supply.
- Java
- Python
- Rust
List<RelForm> flow = List.of(
rel("feeds", "source_lake_a", "main_trunk_north"),
rel("feeds", "main_trunk_north", "zone_west_12"),
rel("feeds", "main_trunk_north", "zone_east_07"));
client.resources().create(assets, flow);
import datahub_sdk
flow = [
datahub_sdk.RelForm.by_external_ids("source_lake_a", "main_trunk_north", "feeds"),
datahub_sdk.RelForm.by_external_ids("main_trunk_north", "zone_west_12", "feeds"),
datahub_sdk.RelForm.by_external_ids("main_trunk_north", "zone_east_07", "feeds"),
]
client.resources.create(assets, flow)
use dataplatform_rust_sdk::relations::RelForm;
let flow = vec![
RelForm::by_external_ids("source_lake_a", "main_trunk_north", "feeds"),
RelForm::by_external_ids("main_trunk_north", "zone_west_12", "feeds"),
RelForm::by_external_ids("main_trunk_north", "zone_east_07", "feeds"),
];
api.resources.create(assets, flow).await?;
2. Two zones report trouble — find where the flows meet
Trace upstream from each affected zone and intersect the two supply paths. The shared
node — here main_trunk_north, or beyond it source_lake_a — is where to isolate.
- Java
- Python
- Rust
ResourceNetwork west = client.resources().fetchRelated("zone_west_12", 20);
ResourceNetwork east = client.resources().fetchRelated("zone_east_07", 20);
Set<String> westUpstream = west.nodes().stream()
.map(Resource::getExternalId).collect(Collectors.toSet());
Set<String> common = east.nodes().stream()
.map(Resource::getExternalId).filter(westUpstream::contains).collect(Collectors.toSet());
// common holds main_trunk_north (and source_lake_a) → isolate there
west = client.resources.fetch_related(external_id="zone_west_12", depth=20)
east = client.resources.fetch_related(external_id="zone_east_07", depth=20)
common = ({n.external_id for n in west.nodes} & {n.external_id for n in east.nodes})
# 'main_trunk_north' in common → isolate there
use dataplatform_rust_sdk::resources::RelatedResourcesForm;
use std::collections::HashSet;
let west = api.resources.fetch_related(
&RelatedResourcesForm::from_external_id("zone_west_12").with_depth(20)).await?;
let east = api.resources.fetch_related(
&RelatedResourcesForm::from_external_id("zone_east_07").with_depth(20)).await?;
let w: HashSet<&str> = west.nodes().iter().map(|n| n.external_id.as_str()).collect();
let common: Vec<&str> = east.nodes().iter()
.map(|n| n.external_id.as_str()).filter(|id| w.contains(id)).collect();
// common holds "main_trunk_north" → isolate there
This is the same shared-node reasoning as alarm correlation, applied to a flow network to pinpoint a contamination source.
3. Detect the problem in the first place
Turbidity and chlorine are series per zone (zone_west_12_turbidity_ntu,
zone_west_12_chlorine_mg_l); a reading out of range raises a water_quality_alert
event that kicks off the trace above.
See the result
The two zones' upstream paths intersect at one node — where to isolate and flush:
main_trunk_north ← the shared supply behind both quality alerts
See also
- Correlate alarms with the graph — upstream-convergence intersection.
- Model assets as a graph — modeling the flow network.
- Turn readings into events — the quality alert.