Finding Orphaned Service Laterals with GeoPandas
A service lateral that is not connected to its main is a customer the network cannot find. It appears on every map, it is included in every asset count, and it is absent from every trace — which means absent from impact analysis during an outage, absent from an isolation boundary during a shutoff, and absent from the customer count in a regulatory filing. Laterals orphan in four distinct ways and only one of them is a snapping problem, so a repair queue that treats them all as gaps fixes a quarter of the population and quietly reports success. This guide builds the detection pass that separates the classes, using an exported snapshot and GeoPandas rather than a licensed engine, so it can run in continuous integration. It complements the repair workflow in network fragmentation and gap resolution.
Environment Prerequisites
- Python 3.11 in an isolated environment with
geopandas>=1.0,shapely>=2.0andpandas>=2.0; noarcpylicence is needed for detection. - An export from a reconciled snapshot carrying laterals, mains, service points and the association table, all in one projected coordinate reference.
- The network connectivity tolerance, which is what separates a geometric gap from a coincident endpoint.
- Pressure zone or subnetwork attribution on both laterals and mains, so a lateral attached to the wrong main can be distinguished from one attached correctly.
- A review queue for the classes that need an engineering decision rather than a repair.
- A run store, so the counts by class can be compared between sweeps and the trend measured.
Schema-Aware Validation Protocol — Run Before the Sweep
- Confirm one projected coordinate reference across all three layers. Distance comparisons across mixed frames produce a gap count that measures the frames rather than the network.
- Check the association export is complete. A partial association table makes every lateral look orphaned, which is a spectacular false positive and easy to spot.
- Distinguish a lateral terminus from a lateral endpoint. The end that meets the customer is not supposed to touch a main, and counting it as an orphan doubles every finding.
- Verify pressure zone attribution exists on both sides. The wrong-parent class depends on it, and where it is missing that class is simply undetectable.
- Sample twenty findings by hand before trusting a sweep. The first run of any detector on a new estate finds mostly its own misconfiguration.
Minimal Reproducible Implementation
from __future__ import annotations
import logging
from collections import Counter
from dataclasses import dataclass, field
import geopandas as gpd
from shapely.geometry import Point
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
LOG = logging.getLogger("orphan-laterals")
@dataclass
class Orphan:
lateral_id: str
kind: str # GAP, NO_ASSOCIATION, WRONG_PARENT, NO_SERVICE
detail: str
@dataclass
class SweepResult:
orphans: list[Orphan] = field(default_factory=list)
checked: int = 0
def by_kind(self) -> dict[str, int]:
return dict(Counter(o.kind for o in self.orphans))
def find_orphans(
laterals: gpd.GeoDataFrame,
mains: gpd.GeoDataFrame,
associations: set[tuple[str, str]],
service_points: set[str],
tolerance_m: float = 0.01,
) -> SweepResult:
"""Classify every lateral by how it fails to reach the network.
``associations`` holds (lateral_id, main_id) pairs that exist in the model. A
lateral whose upstream endpoint is coincident with a main but which appears in no
association pair is the class most estates never look for, and usually the largest.
"""
result = SweepResult()
sindex = mains.sindex
for _, lat in laterals.iterrows():
result.checked += 1
lateral_id = str(lat["asset_id"])
upstream = Point(lat.geometry.coords[0])
# Which mains are near the upstream endpoint?
candidates = mains.iloc[list(sindex.intersection(
upstream.buffer(max(tolerance_m * 50, 1.0)).bounds))]
distances = [(float(upstream.distance(m.geometry)), str(m["asset_id"]),
m.get("pressure_zone"))
for _, m in candidates.iterrows()]
distances.sort()
if not distances or distances[0][0] > tolerance_m:
nearest = f"{distances[0][0]:.3f} m" if distances else "no main nearby"
result.orphans.append(Orphan(lateral_id, "GAP",
f"nearest main at {nearest}"))
continue
_, main_id, zone = distances[0]
if (lateral_id, main_id) not in associations:
result.orphans.append(Orphan(
lateral_id, "NO_ASSOCIATION",
f"coincident with {main_id} but no association row"))
continue
if zone is not None and lat.get("pressure_zone") not in (None, zone):
result.orphans.append(Orphan(
lateral_id, "WRONG_PARENT",
f"lateral zone {lat.get('pressure_zone')} vs main zone {zone}"))
continue
if lateral_id not in service_points:
result.orphans.append(Orphan(lateral_id, "NO_SERVICE",
"no service point downstream of this lateral"))
LOG.info("%d lateral(s) checked, orphans by kind: %s",
result.checked, result.by_kind())
return result
The order of the checks is the design. A lateral is tested for a geometric gap first because that invalidates everything after it, then for a missing association, then for a wrong parent, then for a missing service point. Each test is only meaningful once the previous one has passed.
Production Deployment Pattern
- Run the sweep nightly against the reconciled snapshot and route by class: gaps to the repair pipeline, missing associations to a bulk fix, wrong parents to engineering review.
- Bulk-fix missing associations, carefully. Where geometry is coincident and the rule set permits the pair, creating the association is safe and can be done at volume — but write it in a version and validate before posting.
- Never bulk-fix a wrong parent. A lateral attached to the wrong main is usually a real modelling question about where the service is actually fed from.
- Track counts by class between runs. A rising
NO_ASSOCIATIONcount points at an import that writes geometry without relationships, which is a pipeline defect rather than a data one. - Cross-check against the customer system. A lateral with no service point may be a data gap on either side, and the reconciliation is what tells you which.
- Feed the confirmed orphan count into impact analysis. Until they are fixed, they are known customers absent from every trace, and a stated number is better than a silent one.
- Re-run the sweep after every bulk association fix. The fix and the detector disagree surprisingly often on the first attempt, usually because the rule set forbids a pair the geometry suggests, and finding that out in the same session is much cheaper than a week later.
Conclusion
Orphaned laterals are the difference between an asset count and a customer count. Separating them into four classes turns an undifferentiated repair queue into three work streams and one engineering question, and it reveals that the largest class is usually the one nobody was looking for: geometrically perfect laterals with no association behind them. Running the sweep on a snapshot with GeoPandas keeps it cheap enough to run nightly, and the trend by class is what shows whether the pipeline that creates laterals has been fixed.
Related
- Up to the parent topic: Network Fragmentation & Gap Resolution
- Up to the section: Topology & Tracing Workflows
- Detecting and Repairing Network Gaps with Python
- Batch Flagging Orphaned Junctions with NetworkX
- Computing Affected Customer Counts with NetworkX
For authoritative reference, consult the GeoPandas documentation and the Shapely manual.