Ingesting As-Built CAD Deliverables into a Utility Network
An as-built drawing is the most authoritative record of what was actually built and the least structured data a utility network ever ingests. It carries layer names instead of asset types, blocks instead of devices, annotation instead of attributes, and a coordinate frame that is often stated only in the title block. Importing it directly produces geometry that draws correctly and traces not at all. This guide sets out the crosswalk-driven ingestion that turns a deliverable into network features: frame and units first, layers mapped to asset types through versioned configuration, block attributes coerced into domains, and two gates that stop an unmapped layer from arriving as an untraceable asset. It applies the pipeline discipline from data ingestion pipelines for utility assets to the specific case of contractor drawings.
Environment Prerequisites
- Python 3.11 with
geopandas>=1.0,shapely>=2.0andfiona>=1.9for DXF reading, plusarcpywhere features are created directly in an enterprise geodatabase. - A versioned layer crosswalk mapping each expected CAD layer to an asset group and asset type, held in the repository beside the connectivity rules.
- A block-attribute map translating block attribute tags into feature fields and coded values.
- The submission standard the contractor is working to, including the required frame, units, layer naming and whether blocks must be exploded.
- A defined tolerance for geometry cleaning, taken from the precision standards rather than chosen per deliverable.
- A named version for the created features and a rejection route back to the submitter.
Schema-Aware Validation Protocol — Run Before Reading Geometry
- Confirm the units and the frame are declared, not inferred. A drawing authored in survey feet and read as metres produces a uniform scale error that looks like a plausible network.
- Diff the layer list against the crosswalk. Any layer present in the drawing and absent from the crosswalk stops the run. Importing it as “unknown” creates assets that no rule governs.
- Check whether blocks are exploded. An unexploded block imports as a single feature holding several devices, and the error is invisible until a trace refuses to enter it.
- Look for elevation carried as text. Where the vertical model matters, annotation is not a Z value, and the deliverable needs returning rather than interpreting.
- Count entities per layer against the drawing’s own schedule. A mismatch means the drawing and its schedule disagree, which is a question for the submitter and not something the importer should resolve.
Minimal Reproducible Implementation
from __future__ import annotations
import logging
from dataclasses import dataclass, field
import geopandas as gpd
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
LOG = logging.getLogger("cad-ingest")
@dataclass
class IngestReport:
accepted: int = 0
rejected: list[str] = field(default_factory=list)
unmapped_layers: set[str] = field(default_factory=set)
features: gpd.GeoDataFrame | None = None
@property
def ok(self) -> bool:
return not self.rejected and not self.unmapped_layers
def ingest_deliverable(
dxf_path: str,
crosswalk: dict[str, dict],
block_attrs: dict[str, str],
target_epsg: int,
source_units: str,
tolerance_m: float = 0.02,
) -> IngestReport:
"""Read a CAD deliverable into typed network-ready features.
``crosswalk`` maps a layer name to {"asset_group", "asset_type", "geometry"}.
``source_units`` must be stated by the submission rather than guessed; the function
refuses to proceed on an unknown unit because the alternative is a silent scale error.
"""
report = IngestReport()
if source_units not in {"m", "us-ft", "ft"}:
report.rejected.append(f"unsupported or unstated source units: {source_units!r}")
return report
gdf = gpd.read_file(dxf_path)
present = set(gdf["Layer"].unique())
report.unmapped_layers = present - set(crosswalk)
if report.unmapped_layers:
LOG.error("unmapped layer(s): %s", ", ".join(sorted(report.unmapped_layers)))
return report
scale = {"m": 1.0, "ft": 0.3048, "us-ft": 1200.0 / 3937.0}[source_units]
if scale != 1.0:
gdf["geometry"] = gdf.geometry.scale(xfact=scale, yfact=scale, origin=(0, 0))
gdf = gdf.set_crs(epsg=target_epsg, allow_override=True)
rows = []
for _, ent in gdf.iterrows():
spec = crosswalk[str(ent["Layer"])]
geom_kind = ent.geometry.geom_type if ent.geometry is not None else None
if geom_kind is None or ent.geometry.is_empty:
report.rejected.append(f"empty geometry on layer {ent['Layer']}")
continue
if spec["geometry"] not in geom_kind:
report.rejected.append(
f"layer {ent['Layer']} expects {spec['geometry']}, found {geom_kind}")
continue
row = {
"asset_group": spec["asset_group"],
"asset_type": spec["asset_type"],
"geometry": ent.geometry.simplify(tolerance_m, preserve_topology=True),
}
for tag, field_name in block_attrs.items():
if tag in ent and ent[tag] is not None:
row[field_name] = ent[tag]
rows.append(row)
report.features = gpd.GeoDataFrame(rows, crs=f"EPSG:{target_epsg}")
report.accepted = len(rows)
LOG.info("accepted %d feature(s), rejected %d", report.accepted, len(report.rejected))
return report
The unit check refusing to guess is the most important line in the routine. Every other defect in a CAD deliverable produces something visibly wrong; a unit error produces a network that is internally consistent, correctly shaped, and uniformly the wrong size.
Revisions, and Why They Decide the Design
A drawing is submitted, reviewed, corrected and resubmitted, often several times, and the second submission is where a naive importer breaks. The features created from revision one are already in the network, possibly already connected to existing infrastructure; revision two arrives as a complete drawing rather than as a delta, and importing it again duplicates everything.
Two properties make revisions manageable. The deliverable needs a stable identifier per entity that survives the revision — usually a handle or a tag the drafting standard requires — so the importer can match revision two against what revision one created. And the import has to be idempotent: an entity whose geometry and attributes are unchanged produces no edit, one that changed produces an update, and one that has disappeared produces a review item rather than a deletion, because a missing entity in a drawing is ambiguous in exactly the way a missing record in a data feed is.
Where the drafting standard cannot provide stable identifiers, the fallback is spatial matching inside a tight tolerance, with anything ambiguous routed to review. It works, and it is noticeably worse — which is the argument for putting the identifier requirement into the submission standard rather than solving it in code.
Production Deployment Pattern
- Run ingestion as a gate in the submission workflow, so a deliverable is accepted or returned before anyone schedules the work that depends on it.
- Return defects with entity identifiers. “Layer WTR_VLV_NEW is not in the crosswalk, 14 entities” is actionable; “import failed” is not.
- Never repair locally what the submitter can fix. A local repair has to be repeated for every revision of the same drawing, and revisions are the norm.
- Version the crosswalk with the submission standard. When the standard changes, the crosswalk change is the reviewable artefact that records it.
- Create features in a named version and validate topology before posting, because as-built geometry frequently connects to existing infrastructure and the connection is the point.
- Persist the run. The deliverable hash, the crosswalk version, units, tolerance and counts are what explain, years later, how a feature came to be shaped the way it is.
Conclusion
CAD ingestion works when the translation between drawing conventions and network semantics lives in versioned configuration rather than in the importer’s assumptions. Settling units and frame first removes the one defect that is invisible afterwards; gating on unmapped layers keeps untraceable assets out; returning defects to the submitter with entity identifiers is what gradually improves the deliverables themselves. What arrives after that is ordinary ingestion, and the incremental pipeline handles it from there.
Related
- Up to the parent topic: Data Ingestion Pipelines for Utility Assets
- Up to the section: Core Utility GIS Fundamentals & Network Models
- Building an Incremental Ingestion Pipeline for GIS Updates
- Best Practices for Handling Precision Drift in CAD-to-GIS Conversions
- Validating Field-Collected Assets Before Sync
For authoritative reference, consult the Fiona DXF driver notes and the Shapely simplify documentation.