The Universal Pole Model (UPM) is the JSON document that flows between every overhead service on the platform. Parsers (SPIDA, O-Calc, PLS, field capture) produce it. Analyzers (clearance, sag, loading, classification, joint-use) consume it. Exporters write it back out into the source format.
If you’re integrating with more than one API, you’ll almost always be moving UPM documents between them — so it’s worth understanding the shape once instead of per-endpoint.
The Universal Vault Model is the underground equivalent. It follows the same conventions (top-level keys, measured-value envelope, additive metadata bucket) and is documented alongside the underground gateway.
Where it shows up
Source file ──► parser API ──► UPM ──► analyzer API ──► UPM + findings ──► exporter API ──► source file
.spida spida-api clearance-api spida-api .spida
.pplx ocalc-api loading-api ocalc-api .pplx
.pol pls-api sag-api pls-api .pol
drone / field-capture-api classification-api esri-api feature layer
ESRI joint-use-api
Every analyzer accepts a UPM document on the request body and returns the same document, optionally enriched with findings, proposals, or computed fields. The gateways do this in a loop for you; if you’re calling services directly, you carry the document across calls.
Top-level keys
A UPM document is a flat JSON object with these top-level keys. Only identity is required — everything else is optional, and analyzers degrade gracefully when fields they care about are missing.
| Key | Purpose |
|---|---|
identity | Stable IDs for the pole and a pointer back to the source file. Required. |
geometry | Pole height, class, species, set depth, lat/lon. |
crossarms | Crossarms with their insulators and the spans (wires) attached to each insulator. |
pole_mounted_insulators | Insulators attached directly to the pole rather than to a crossarm. |
guys | Down guys to a ground anchor. |
span_guys | Guys that run to a neighboring pole instead of to ground. |
anchors | Ground anchor locations and capacities. |
attachments | Flat envelope of every attachment point — useful when you want one list to iterate. |
equipment | Transformers, regulators, cutouts, riser bracket assemblies, capacitors, switches. |
framing | Detected framing configuration per circuit (horizontal / vertical / triangular). |
clearance | Cached clearance findings, if a clearance pass has already run. |
load_case | Code, grade, district, design temperatures, ice/wind state. |
environment | Ambient design conditions for thermal calcs (IEEE 738, C57.91). |
grounding | Grounding rod configuration (IEEE 142). |
vegetation | Tree clearance state. |
avian | Avian protection zone membership and mitigation status. |
service_networks | Secondary / service-drop networks attached to the pole. |
metadata | Free-form extension bucket. Round-trips through every service untouched. |
What each analyzer actually reads
You don’t have to populate every field for every call. The minimum that each service needs:
| Service | Required UPM fields |
|---|---|
| Clearance | identity, geometry.pole_class, crossarms[].insulators[], attachments[], load_case (or it will be inferred). |
| Loading | identity, geometry, plus either load_case or a lat/lon in geometry so it can be derived. |
| Sag | identity, crossarms[].insulators[].spans[], load_case. |
| Classification (framing) | identity, geometry.pole_class, crossarms[], pole_mounted_insulators[]. |
| Guying validation | identity, geometry, crossarms[], guys[], anchors[], load_case. |
| Joint-use | identity, geometry, attachments[] with owner tags. |
Missing data turns into a “could not evaluate” finding rather than an error — the service tells you exactly which field it needed.
Measured-value convention
Every dimensional field on a UPM document is wrapped in a MeasuredValue envelope:
{
"value_si": 13.716,
"unit_si": "m",
"source_value": 45.0,
"source_unit": "ft"
}
value_siis the canonical numeric value. SI units, every time. This is what calculators inside the services use.unit_siis the SI unit label (m,kg,N,m/s,degC).source_valueandsource_unitare the original number and unit as it appeared in the source file or the caller’s input. They’re optional, but they’re how round-trip exporters preserve “the customer drew this in feet.”
When you read values back out of a UPM document, read value_si and convert rather than trusting any _ft or _in keys that may sit alongside for convenience. The SI value is the source of truth.
Identifiers and round-trip stability
The two ID rules that matter:
identity.pole_idis yours — pick anything stable across edits and keep using it. Analyzers key findings by this ID, so changing it mid-workflow loses prior results.- Cross-pole conductor IDs (the
wire.externalIdstyle identifier insidecrossarms[].insulators[].spans[]) are how sag and midspan-clearance match up conductors that span between poles. If you mutate a UPM document and rewrite spans, preserve these IDs or those analyzers can’t reassemble the line.
Everything else can be regenerated.
Extending the model
The metadata bucket at the top level (and on most nested objects) is a free-form Record<string, any>. Services don’t validate it, they don’t strip it, and exporters carry it through. Use it for:
- Caller-specific tags (“project_id”, “work_order”, “crew”)
- Intermediate state your pipeline wants to remember across calls
- Vendor-specific fields that don’t have a home in the canonical schema yet
If you need a field promoted into the canonical schema (so other services start reading it), email hello@epcstudio.io.
Minimal example
The smallest UPM document an analyzer will accept:
{
"identity": {
"pole_id": "demo-pole-001",
"source_ref": { "source_system": "spida", "source_key": "loc_3" }
},
"geometry": {
"manufactured_height": {
"value_si": 13.716, "unit_si": "m",
"source_value": 45.0, "source_unit": "ft"
},
"pole_class": "3",
"pole_species": "Douglas Fir",
"location": { "lat": 38.5816, "lon": -121.4944 }
},
"load_case": {
"code": "GO-95",
"grade": "C",
"district": "LIGHT"
},
"crossarms": [
{
"crossarm_id": "xa-1",
"attachment_height": { "value_si": 11.5, "unit_si": "m" },
"direction_deg": 90.0,
"insulators": [
{
"insulator_id": "ins-1a",
"insulator_type": "pin",
"attachment_height": { "value_si": 11.5, "unit_si": "m" },
"spans": [
{
"span_id": "span-1",
"wire_external_id": "primary-phase-a",
"size": "1/0 ACSR",
"length": { "value_si": 39.6, "unit_si": "m" }
}
]
}
]
}
],
"attachments": [
{
"attachment_id": "att-1a",
"attachment_type": "insulator",
"attachment_height": { "value_si": 11.5, "unit_si": "m" },
"voltage_v": 12470.0
}
]
}
Pass this to clearance, loading, sag, or classification and you’ll get a verdict back.
A typical integration
If you’re wiring the platform into your own pipeline, the canonical loop is:
- Parse a source file with the appropriate parser API (
/parse) to get a UPM document. - Enrich, if needed — pull in lat/lon from your GIS, pin a
load_case, tag attachments with owners for joint-use. - Analyze by POSTing the UPM to each analyzer you care about. Each call returns the same UPM plus findings.
- Apply the human-approved fix proposals on your side (most callers do this in their own UI) and update the relevant fields on the UPM document.
- Export back to the source format via the exporter API (
/generate) when you’re ready to round-trip.
The conversational gateway is this same loop with an LLM driving it. Use the gateway when a human is in the loop; call the services directly when you’re inside a pipeline.
See also
- SPIDA API → — the most mature parser/exporter
- Clearance API → — first analyzer most callers run
- Loading API → — code-aligned operating points everything else depends on
- Overhead gateway → — same loop, LLM-driven