Reconciling Unit Identifiers Across Agency CAD Systems

Two engines vanish from the operating picture and reappear as one marker that jumps four kilometres every thirty seconds. County Engine 11 reports as 11; City Engine 11 reports as E11; the ingest normaliser strips the type prefix, both become UNIT-0011, and each fix overwrites the other’s position.

Root Cause and Operational Impact

Unit identifiers are unique within the agency that issues them and nowhere else. That is not a defect in any agency’s scheme — a county has no reason to coordinate its engine numbering with a neighbouring city — but it means that the moment two agencies contribute to one incident, identifier uniqueness is a property nobody owns.

Four agencies, four unit-identifier conventions, one collision Four agencies contributing to one incident use incompatible unit identifiers. County fire uses a bare number such as 11. City fire uses a type prefix such as E11 for engine eleven. The state agency uses a dispatch code with a region prefix such as NM-3-E11. A federal team uses a resource order number unrelated to the vehicle. County's 11 and city's E11 are the same string once a naive normaliser strips the prefix, so two different engines collide into one record, and whichever reports last overwrites the other's position. The collision is silent because both are legitimate identifiers in their own systems, and neither agency can see the other's namespace. four conventions, and one of them collides county fire 11 bare number — no type, no agency unique within the county only city fire E11 type prefix — collides with county 11 once stripped state NM-3-E11 region-scoped — globally unique already federal team O-4471 resource order number — unrelated to the vehicle County 11 and city E11 become one record, and whichever reports last overwrites the other's position.

The failure is worse than a display glitch because it is bidirectional and silent. Both agencies see a unit behaving impossibly, each concludes the other’s feed is broken, and the record that a supervisor uses to decide whether a division is covered is now the interleaving of two vehicles. Nothing errors, because both identifiers are legitimate.

Tiered Resolution Strategy

  1. Scope, never normalise (definitive). Keep each agency’s identifier exactly as issued and prefix it with the agency’s own namespace. The compound key cannot collide by construction, and no information is destroyed.
  2. Separate the key from the display name. A short label on the map is a presentation concern; the key is a correctness one. Conflating them is what makes normalising attractive.
  3. Crosswalk to an incident-scoped resource identifier, with validity windows. Resource order numbers are reused between operational periods, so the mapping must be time-bounded.
  4. Reject an unscoped identifier at ingest (safe default). A feed that cannot say which agency a unit belongs to cannot be safely merged; hold it for configuration rather than guessing.
  5. Audit every crosswalk resolution. Which scoped identifier a resource order resolved to, and at what time, is what makes an after-action reconstruction possible.
Scoping rather than normalising Two approaches to reconciling unit identifiers. Normalising strips prefixes and pads numbers to produce one canonical string, which is compact and destroys the information that distinguishes two agencies' units, so collisions are inevitable and silent. Scoping keeps each agency's identifier exactly as issued and prefixes it with the agency's own namespace, producing a compound key that cannot collide by construction. The scoped key is longer and never wrong. A display name derived from the scoped key can still be short, because the display is a presentation concern and the key is a correctness one — conflating those two is what makes normalisation attractive. scope the identifier; never normalise it normalise scope 11 → UNIT-0011 E11 → UNIT-0011 county-fire/11 city-fire/E11 compact, and destroys what distinguished them longer, and cannot collide by construction collisions are silent display name stays short A short label on the map is a presentation concern. The key is a correctness one. Conflating the two is exactly what makes normalising look attractive.

Tier one is the whole fix and it is usually resisted on the grounds that the keys get long. They do. county-fire/11 is longer than 11, and it is also correct — and because the display name is derived separately, nothing a responder reads gets longer.

The crosswalk that survives a unit being reassigned A crosswalk maps agency-scoped identifiers to an incident-scoped resource identifier, and it is time-bounded rather than permanent. County engine eleven is assigned to the incident at 06:00 under resource order O-4471 and released at 22:00. A different engine takes the same order number the following operational period. Because each crosswalk entry carries a validity window, a position reported at 23:10 under O-4471 resolves to the second engine and not the first, and an after-action query about O-4471 at 14:00 resolves to the first. A crosswalk without validity windows silently attributes one unit's movements to another as soon as a resource order is reused, which happens on every multi-day incident. the crosswalk is time-bounded, because resource orders get reused 06:0014:0022:0006:00 +1 county-fire/11 → O-4471 city-fire/E7 → O-4471 assigned 06:00, released 22:00 same order, next period a query about O-4471 at 14:00 → county-fire/11 a fix at 23:10 → city-fire/E7 Without validity windows, one unit's movements are silently attributed to another the moment an order is reused — which happens on every multi-day incident.

Tier three is the part teams discover late. A resource order number identifies an assignment, not a vehicle, and on a multi-day incident the same order is filled by a different unit in a later operational period. A crosswalk without validity windows silently attributes the first unit’s movements to the second, which is the same failure as the identifier collision with a longer fuse.

Production Python Implementation

python
from __future__ import annotations

import logging
from bisect import bisect_right
from dataclasses import dataclass
from datetime import datetime

logger = logging.getLogger("incidentgis.unit_ids")


class UnscopedIdentifierError(ValueError):
    """A feed supplied a unit identifier with no owning agency."""


@dataclass(frozen=True)
class ScopedUnit:
    agency: str
    local_id: str

    @property
    def key(self) -> str:
        """Compound key — cannot collide across agencies by construction."""
        return f"{self.agency}/{self.local_id}"

    @property
    def display(self) -> str:
        """Short label for the map; presentation only, never a key."""
        return self.local_id


@dataclass(frozen=True)
class Assignment:
    unit: ScopedUnit
    resource_order: str
    valid_from: datetime
    valid_to: datetime | None      # None = still assigned


class Crosswalk:
    """Resolve a resource order to the unit that held it at a given time."""

    def __init__(self) -> None:
        self._by_order: dict[str, list[Assignment]] = {}

    def assign(self, assignment: Assignment) -> None:
        entries = self._by_order.setdefault(assignment.resource_order, [])
        # Close any open assignment for this order before opening a new one:
        # an order held by two units at once is a data defect, not a merge.
        for i, existing in enumerate(entries):
            if existing.valid_to is None:
                if existing.unit == assignment.unit:
                    return
                entries[i] = Assignment(
                    existing.unit, existing.resource_order,
                    existing.valid_from, assignment.valid_from,
                )
                logger.info("resource_order_reassigned", extra={
                    "order": assignment.resource_order,
                    "from_unit": existing.unit.key,
                    "to_unit": assignment.unit.key,
                })
        entries.append(assignment)
        entries.sort(key=lambda a: a.valid_from)

    def resolve(self, resource_order: str, at: datetime) -> ScopedUnit | None:
        """Which unit held this order at this instant?"""
        entries = self._by_order.get(resource_order, [])
        if not entries:
            return None
        starts = [a.valid_from for a in entries]
        idx = bisect_right(starts, at) - 1
        if idx < 0:
            return None
        candidate = entries[idx]
        if candidate.valid_to is not None and at >= candidate.valid_to:
            return None
        return candidate.unit


def scope_identifier(agency: str | None, local_id: str) -> ScopedUnit:
    """Refuse an unscoped identifier rather than guessing an owner."""
    if not agency:
        raise UnscopedIdentifierError(
            f"unit {local_id!r} arrived with no agency — hold for configuration"
        )
    return ScopedUnit(agency=agency.strip().lower(), local_id=local_id.strip())

Validation Checklist

  • Every unit key is agency/local_id; no code path constructs a key from local_id alone.
  • Display names are derived separately and are never used for lookup.
  • Local identifiers are preserved exactly as issued — no prefix stripping, no zero padding.
  • Crosswalk entries carry a validity window and a reassignment closes the previous one.
  • A resource order held by two units at the same instant is rejected as a defect.
  • An identifier arriving without an agency is held for configuration, never defaulted.
  • Every crosswalk resolution is logged with the order, the resolved unit and the timestamp.
  • A fixture reproduces the county-11 / city-E11 collision and asserts two distinct records.

Edge Cases and Gotchas

  • Agency names that are not stable either. “County Fire”, “county-fire” and “CoFD” are the same agency to a human. Fix the agency vocabulary once, in configuration, and normalise only that — it is a small closed set, unlike unit numbers.
  • A unit that changes agency mid-incident. Mutual aid can move a vehicle between commands. Treat it as a new scoped unit with a crosswalk entry linking the two, rather than mutating the key, so historical positions stay attributed correctly.
  • Feeds that embed the agency inconsistently. Some vendors put it in a field, some in a prefix, some nowhere. Extract it at the adapter boundary, exactly as the record contract requires of every other field.
  • Resource orders reused within one operational period. Rare and real, usually after a cancellation. The validity-window logic handles it; a crosswalk keyed only on the order does not.
  • Retroactive assignment corrections. A crosswalk edited after the fact changes what historical positions resolve to. Version the crosswalk rather than editing in place, or an after-action reconstruction will not match what the picture showed at the time.

Frequently Asked Questions

Why not just normalise unit identifiers to a canonical form? Because normalising destroys the only information that distinguishes two agencies’ units. County fire’s engine 11 reports as 11 and city fire’s as E11; a normaliser that strips the type prefix and pads the number turns both into the same string, so two vehicles collapse into one record and each position overwrites the other. The failure is silent and bidirectional: both agencies see a unit behaving impossibly and each concludes the other’s feed is broken. Scoping the identifier with the issuing agency’s namespace produces a longer key that cannot collide by construction.

Does scoping make the identifiers awkward for responders to read? No, because the key and the display name are different things. A scoped key like county-fire/11 exists for correctness — lookups, joins, deduplication — while the label drawn on the map is derived from it and can stay as short as the agency’s own convention. Conflating the two is exactly what makes normalisation look attractive: it optimises the key for human reading, which is not what a key is for. Once they are separated, there is no cost to a long key at all.

Why does the resource-order crosswalk need validity windows? Because a resource order identifies an assignment, not a vehicle. On a multi-day incident the same order number is filled by a different unit in a later operational period, so a crosswalk without time bounds silently attributes the first unit’s movements to the second from the moment the order is reused. With validity windows, a position reported at 23:10 resolves to the unit that held the order then, and an after-action query about 14:00 resolves to the one that held it earlier — which is the same failure as an identifier collision, with a longer fuse.

Up: AVL & Resource Tracking Feeds

Other guides in AVL & Resource Tracking Feeds