AVL & Resource Tracking Feeds
A division supervisor looks at the operating picture and sees eleven engines on the north flank. Nine are reporting normally, one is parked at a water point, and one stopped reporting twenty-two minutes ago on the far side of a ridge. All eleven render as identical markers, because the display shows every unit at its last known position and nothing distinguishes a unit that is stationary from one that has stopped speaking.
Problem Framing
Automatic Vehicle Location (AVL) feeds look like the simplest layer in an incident system — a position, a unit identifier, a timestamp — and they carry more design decisions per byte than anything else on this site. The reason is that a position is not what the operating picture is actually asking for.
Only one of those four questions needs a precise position, and it is the least frequently asked. Most AVL usage is answered by the status field and a coarse location, which is why an architecture optimised for positional accuracy tends to be expensive and no more useful. What the picture needs, in descending order of frequency, is: is this unit available, roughly where is it, can it get somewhere, and only occasionally, is it inside a hazard.
The second problem is that AVL is the highest-volume feed in the response, and almost all of that volume carries no information.
Prerequisites
- A settled unit identifier scheme across every participating agency, or a reconciliation layer that produces one. Two agencies both running an “Engine 11” is the normal case, not an edge case.
- A message transport with per-message priority, as covered in WebSocket and MQTT for live incident feeds — an AVL flood must not delay a status change.
- Known radio coverage geometry for the incident area, even approximate. Without it, a silent unit and a unit in a dead zone are indistinguishable, and the display has to treat both as failures.
- A CRS and axis-order contract per the coordinate reference system standard. AVL vendors are a common source of latitude-first payloads.
Reporting Policy
The volume difference is not primarily a bandwidth argument, though on a saturated incident link it is that too. It is that interval-based reporting spends its entire budget on units that are not doing anything, and then has nothing left when thirty units start moving at once — which is exactly the moment the picture matters.
Movement-triggered reporting inverts that. A parked engine costs one message every two minutes; an engine driving a division boundary costs a message every 25 metres. The volume follows the information, and the heartbeat is what preserves the ability to say that a quiet unit is quiet on purpose.
Distinguishing Silence
The three states in that figure are the whole reason a heartbeat exists. Without one, “no message for four minutes” is ambiguous between healthy and failed, and a display that resolves the ambiguity by showing the last known position resolves it in the most dangerous direction — a unit whose radio died on the fire side of a ridge appears identical to one at a water point.
A projected position for the dead-zone case is worth the implementation cost. A unit that entered a known gap at 40 km/h on a known heading has a small and computable set of places it can be, and showing that as an expanding uncertainty ring is far more useful to a supervisor than a stale point. Crucially it also degrades honestly: the ring grows until it covers the whole gap, at which point the display is saying “somewhere in here”, which is true.
Step-by-Step Implementation
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import Enum
from shapely.geometry import Point
from shapely.geometry.base import BaseGeometry
logger = logging.getLogger("incidentgis.avl")
MOVEMENT_THRESHOLD_M = 25.0
HEARTBEAT_INTERVAL = timedelta(minutes=2)
# How long past a missed heartbeat before a unit is treated as a safety event.
SILENCE_ESCALATION = timedelta(minutes=5)
class UnitState(str, Enum):
REPORTING = "reporting"
STATIONARY = "stationary"
PROJECTED = "projected" # in a known coverage gap
SILENT = "silent" # unexplained — escalate
@dataclass(frozen=True)
class AvlFix:
unit_id: str
position: Point # in the incident's projected CRS
heading_deg: float
speed_mps: float
status: str
reported_at: datetime
def should_transmit(previous: AvlFix | None, current: AvlFix) -> bool:
"""Movement-triggered reporting with a heartbeat.
Volume follows information: a parked unit costs one message per heartbeat
interval, a moving one costs a message per threshold of travel.
"""
if previous is None:
return True
if current.status != previous.status:
return True # status changes are never suppressed
if current.position.distance(previous.position) >= MOVEMENT_THRESHOLD_M:
return True
return current.reported_at - previous.reported_at >= HEARTBEAT_INTERVAL
def classify_silence(
last: AvlFix,
now: datetime,
coverage_gaps: BaseGeometry,
) -> tuple[UnitState, BaseGeometry | None]:
"""Decide what a unit's silence means, and what to draw.
Returns the state and, for a projected unit, the region it could be in.
Never returns a bare last-known point for a unit that should be escalated.
"""
age = now - last.reported_at
if age < HEARTBEAT_INTERVAL * 1.5:
return (UnitState.STATIONARY if last.speed_mps < 1.0
else UnitState.REPORTING), None
in_gap = coverage_gaps.contains(last.position) or coverage_gaps.distance(
last.position
) < last.speed_mps * age.total_seconds()
if in_gap:
# The unit can only be somewhere within its travel radius, intersected
# with the gap. The ring grows honestly until it covers the whole gap.
reach_m = max(last.speed_mps, 1.0) * age.total_seconds()
possible = last.position.buffer(reach_m).intersection(coverage_gaps)
logger.info("avl_unit_projected", extra={
"unit_id": last.unit_id, "age_s": int(age.total_seconds()),
"reach_m": round(reach_m),
})
return UnitState.PROJECTED, possible
if age >= SILENCE_ESCALATION:
# Not in a known gap and past the escalation threshold: this is a
# safety event, not a display quirk.
logger.warning("avl_unit_silent", extra={
"unit_id": last.unit_id, "age_s": int(age.total_seconds()),
"last_status": last.status,
})
return UnitState.SILENT, None
return UnitState.STATIONARY, None
Configuration Reference
| Parameter | Env var | Default | Notes |
|---|---|---|---|
| Movement threshold | AVL_MOVEMENT_M |
25 |
Below the width of most road corridors, so a lane change does not report. |
| Heartbeat interval | AVL_HEARTBEAT_S |
120 |
The upper bound on how long a healthy quiet unit looks unexplained. |
| Silence escalation | AVL_SILENCE_S |
300 |
Past this, outside a known gap, it is a safety event. |
| Coverage gap layer | AVL_COVERAGE_GAPS |
unset | Without it every dead-zone unit escalates and the alert becomes noise. |
| Status priority topic | AVL_STATUS_TOPIC |
unit/+/status |
Status changes ride a separate topic so an AVL flood cannot delay them. |
| Position CRS | AVL_CRS |
incident UTM | Distances are metric; a geographic CRS makes the threshold meaningless. |
| Max projected radius | AVL_MAX_PROJECT_M |
3000 |
Beyond this the projection says nothing useful; fall back to escalation. |
Verification and Smoke Test
# A parked unit must produce heartbeats and nothing else.
assert sum(should_transmit(prev, fix) for prev, fix in parked_sequence) == expected_heartbeats
# A unit crossing a known dead zone must be PROJECTED, never SILENT.
state, region = classify_silence(last_fix, now, coverage_gaps)
assert state is UnitState.PROJECTED and region is not None
# A unit silent in good coverage must escalate.
state, _ = classify_silence(open_air_fix, now, coverage_gaps)
assert state is UnitState.SILENT
Integration With Adjacent Workflows
Unit positions feed the evacuation routing layer when computing which units can reach an address, and a projected position must be marked so the router does not treat it as surveyed. Status changes are validated by the same attribute validation rules as any other record, and AVL positions are subject to the same null-island and bounds checks — vendors are a common source of out-of-area fixes.
One organisational note that outlasts any of the parameters above. The reporting policy is a negotiation with the agencies whose vehicles are being tracked, not purely an engineering choice — a movement threshold that is comfortable for a fire district may be unacceptably coarse to a law-enforcement partner whose units are tracked for officer safety rather than for resource allocation. Agree the policy per agency, record it alongside the feed configuration, and expect it to differ across the participating organisations rather than trying to impose one number on all of them. A feed whose behaviour is documented and different is far easier to reason about during an incident than one that is uniform and quietly wrong for half its contributors.
Troubleshooting
Symptom: the message rate collapses when the incident gets busy. Movement-triggered reporting is working as designed and the transport is saturating. Shed by widening the movement threshold for available units, never for committed ones.
Symptom: every unit in a canyon escalates as silent. The coverage gap layer is missing or too coarse. Without it, dead zones are indistinguishable from failures and the alert becomes noise that gets muted.
Symptom: units jump between two positions while parked. Multipath at a fixed location, covered in handling GPS drift in urban canyons. Raising the movement threshold hides it; fixing it means detecting the directional bias.
Symptom: two agencies’ units overwrite each other. Unit identifiers collide across agencies. The identifier must be scoped by agency before it reaches the picture.
Symptom: positions are in the ocean. Latitude-first payload from the AVL vendor. Assert bounds at ingest rather than trusting the feed.
Frequently Asked Questions
Does an AVL feed need high positional accuracy? Rarely. Of the four questions the operating picture asks of a vehicle position, the most common — is this unit committed or available — is answered by the status field and needs no position at all, and which division it is working in is answered by any fix. Reaching an address in a given time needs roughly 100 metres and the road segment, because which side of a median a unit is on decides the answer. Only containment inside a hazard perimeter needs about 20 metres, and it is the least frequently asked. An architecture optimised for the rarest question tends to be expensive without being more useful.
Why report on movement instead of on a fixed interval? Because interval reporting spends its budget on units that are not doing anything. A 240-unit fleet reporting every five seconds produces about 2.07 million messages over a twelve-hour period, most of them parked units repeating the same position, and has nothing left when thirty units start moving at once. Reporting when a unit has moved more than about 25 metres or changed status produces roughly 210,000 messages concentrated on units that are actually moving, and adding a two-minute heartbeat brings it to about 245,000 — more useful information than the five-second policy at an eighth of the volume.
How should a display handle a unit that has stopped reporting? By distinguishing three states rather than showing a marker at the last known position. A stationary healthy unit is confirmed by its heartbeat and can be drawn solid. A unit inside a known radio coverage gap should be drawn as a projected region derived from its last heading and speed, growing until it covers the gap, which is an honest statement of what is known. A unit silent outside any known gap past an escalation threshold is a safety event and should be flagged and escalated. Rendering all three identically means a supervisor cannot tell a parked engine from one whose radio died on the fire side of a ridge.
Related
- WebSocket & MQTT for Live Incident Feeds — the transport an AVL flood must not be allowed to saturate.
- Evacuation Routing & Road Network Analysis — where unit positions are consumed, and why a projected position must be marked as such.
- Handling GPS Drift in Urban Canyon Environments — why a parked unit appears to move, and why raising the threshold only hides it.
- Automated Attribute Validation Rules — the bounds and null-island checks an AVL vendor’s payload must still pass.
Up: Incident Mapping & Multi-Agency Sync Workflows