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.
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
- 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.
- 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.
- 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.
- 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.
- Audit every crosswalk resolution. Which scoped identifier a resource order resolved to, and at what time, is what makes an after-action reconstruction possible.
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.
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
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 fromlocal_idalone. - 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.
Related
- AVL & Resource Tracking Feeds — the feed whose positions these keys have to keep apart.
- Resolving Duplicate Incident Reports Across Jurisdictions — the same cross-agency identity problem where no shared key exists at all.
- Conflict Resolution in Multi-Agency Edits — agency precedence as configuration agreed before an incident.
- Automated Attribute Validation Rules — the adapter boundary where an agency field is extracted before validation runs.
Up: AVL & Resource Tracking Feeds