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.

KeyPurpose
identityStable IDs for the pole and a pointer back to the source file. Required.
geometryPole height, class, species, set depth, lat/lon, plus structure type (single_pole / h_frame / monopole_transmission) and steel geometry for transmission structures.
crossarmsCrossarms with their insulators and the spans (wires) attached to each insulator.
pole_mounted_insulatorsInsulators attached directly to the pole rather than to a crossarm.
guysDown guys to a ground anchor.
span_guysGuys that run to a neighboring pole instead of to ground.
anchorsGround anchor locations and capacities.
attachmentsFlat envelope of every attachment point — useful when you want one list to iterate.
equipmentTransformers, regulators, cutouts, riser bracket assemblies, capacitors, switches.
framingDetected framing configuration per circuit (horizontal / vertical / triangular).
clearanceCached clearance findings, if a clearance pass has already run.
load_caseCode (GO-95 / NESC / CSA), grade, district, design temperatures, ice/wind state.
environmentAmbient design conditions for thermal calcs (IEEE 738, C57.91).
groundingGrounding rod configuration (IEEE 142).
vegetationTree clearance state.
avianAvian protection zone membership and mitigation status.
service_networksSecondary / service-drop networks attached to the pole.
metadataFree-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:

ServiceRequired UPM fields
Clearanceidentity, geometry.pole_class, crossarms[].insulators[], attachments[], load_case (or it will be inferred).
Loadingidentity, geometry, plus either load_case or a lat/lon in geometry so it can be derived.
Sagidentity, crossarms[].insulators[].spans[], load_case.
Classification (framing)identity, geometry.pole_class, crossarms[], pole_mounted_insulators[].
Guying validationidentity, geometry, crossarms[], guys[], anchors[], load_case.
Joint-useidentity, 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_si is the canonical numeric value. SI units, every time. This is what calculators inside the services use.
  • unit_si is the SI unit label (m, kg, N, m/s, degC).
  • source_value and source_unit are the original number and unit as it appeared in the source file or the caller’s input.

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:

  1. identity.pole_id is yours — pick anything stable across edits and keep using it. Analyzers key findings by this ID, so changing it mid-workflow loses prior results.
  2. Cross-pole conductor IDs (the wire.externalId style identifier inside crossarms[].insulators[].spans[]) are how sag and midspan-clearance match up conductors that span between poles. Preserve these IDs if you mutate a UPM document and rewrite spans.

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

  1. Parse a source file with the appropriate parser API (/parse) to get a UPM document.
  2. Enrich, if needed — pull in lat/lon from your GIS, pin a load_case, tag attachments with owners for joint-use.
  3. Analyze by POSTing the UPM to each analyzer you care about. Each call returns the same UPM plus findings.
  4. Apply the human-approved fix proposals on your side and update the relevant fields on the UPM document.
  5. 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.

See also