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.
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, 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. 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:

  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. 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:

  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 (most callers do this in their own UI) 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. Use the gateway when a human is in the loop; call the services directly when you’re inside a pipeline.

See also