Smoothing AVL Jitter Without Hiding Real Movement
A supervisor watching the north flank sees eight engines drifting slowly around their staging positions all afternoon. The AVL smoother is turned up to stop it, and the next morning an engine creeping along a division boundary at walking pace shows as stationary for eleven minutes.
Root Cause and Operational Impact
Position jitter and slow real movement produce the same per-fix displacement. A parked engine’s fixes scatter by ten or fifteen metres between reports; an engine creeping along a fire line at walking pace also moves ten or fifteen metres between reports. Any filter that judges a fix by how far it moved cannot separate them.
What does separate them is the consistency of direction. Jitter reverses: each apparent movement is undone by the next. Real slow movement advances, and the bearings of successive displacements agree. That distinction is present in the data and is invisible to a displacement threshold, which is why tuning one produces the two failures above and nothing in between.
The operational cost of each is different and both are real. A wandering parked unit erodes trust in the whole display, so supervisors stop reading positions. A frozen moving unit is worse: it is a unit whose location the picture is actively misstating, at walking pace, along a fire line.
Tiered Resolution Strategy
- Gate on direction consistency, not displacement (definitive). Accept a fix when the bearings of the last few displacements agree within a tolerance; hold it when they do not.
- Keep the raw track alongside the smoothed one. The smoothed position is for display; the raw track is what an after-action review and any drift analysis need.
- Bypass smoothing for anything that could be an event (safe default). Status changes, displacements above a hard ceiling, and the first movement after a heartbeat-only period publish raw.
- Publish the smoothing state with the position. A consumer must be able to tell a held position from a fresh one; a router in particular should not treat a held fix as surveyed.
- Never smooth across a coverage gap. A unit that reappears after a dead zone has genuinely moved, and a filter that averages across the gap invents an intermediate path nobody drove.
The direction gate wins on both traces and is also the cheapest to implement and explain, which matters more here than it usually does — an operations chief who does not understand why a marker is where it is will stop trusting the marker.
Tier three is the part that has to be built in from the start rather than added after an incident. Every smoother suppresses the events that most need to be seen, because those events look exactly like the noise it was built to remove.
Production Python Implementation
from __future__ import annotations
import logging
import math
from collections import deque
from dataclasses import dataclass
from datetime import datetime, timedelta
logger = logging.getLogger("incidentgis.avl_smoothing")
BEARING_TOLERANCE_DEG = 45.0
WINDOW = 3
HARD_CEILING_M = 60.0 # above this, always publish — it is an event
HEARTBEAT_GAP = timedelta(minutes=2)
@dataclass(frozen=True)
class Fix:
x: float # projected CRS metres
y: float
status: str
at: datetime
def _bearing(a: Fix, b: Fix) -> float:
return math.degrees(math.atan2(b.y - a.y, b.x - a.x)) % 360.0
def _agree(bearings: list[float], tolerance: float) -> bool:
"""Do these bearings point the same way, allowing for wraparound?"""
if len(bearings) < 2:
return False
ref = bearings[0]
return all(
min(abs(b - ref), 360.0 - abs(b - ref)) <= tolerance for b in bearings[1:]
)
class DirectionGate:
"""Publish a fix when recent movement agrees in direction.
Distinguishes jitter from slow real movement by consistency of bearing,
which is the property that actually differs between them — displacement
magnitude is identical in both cases.
"""
def __init__(self) -> None:
self._recent: deque[Fix] = deque(maxlen=WINDOW + 1)
self._published: Fix | None = None
def offer(self, fix: Fix) -> tuple[Fix, str]:
"""Return the fix to display and why it was chosen."""
prev = self._published
self._recent.append(fix)
# Events always bypass the gate: a smoother that hides a sudden stop
# is hiding the thing most worth seeing.
if prev is not None and fix.status != prev.status:
return self._publish(fix, "status_change")
if prev is not None and math.dist((prev.x, prev.y), (fix.x, fix.y)) >= HARD_CEILING_M:
return self._publish(fix, "above_ceiling")
if prev is not None and fix.at - prev.at >= HEARTBEAT_GAP:
# First movement after a heartbeat-only period, or a reappearance
# after a coverage gap — never smooth across either.
return self._publish(fix, "after_gap")
if prev is None:
return self._publish(fix, "first_fix")
if len(self._recent) < WINDOW:
return prev, "held_warmup"
pts = list(self._recent)
bearings = [_bearing(pts[i], pts[i + 1]) for i in range(len(pts) - 1)]
if _agree(bearings, BEARING_TOLERANCE_DEG):
return self._publish(fix, "direction_consistent")
logger.debug("avl_fix_held", extra={"reason": "jitter"})
return prev, "held_jitter"
def _publish(self, fix: Fix, reason: str) -> tuple[Fix, str]:
self._published = fix
logger.info("avl_fix_published", extra={"reason": reason})
return fix, reason
Validation Checklist
- The gate keys on bearing agreement, not on displacement magnitude alone.
- The raw track is retained alongside the displayed one.
- Status changes, displacements above the hard ceiling, and post-gap fixes bypass the gate.
- The publish reason travels with the position so consumers can tell held from fresh.
- No smoothing is applied across a known coverage gap.
- A fixture of a parked unit asserts the displayed position does not move.
- A fixture of a unit creeping at walking pace asserts it is not held.
- A routing consumer refuses to treat a held position as a surveyed one.
Edge Cases and Gotchas
- A unit reversing along its own track. Bearings disagree by 180 degrees, so the gate reads it as jitter. The hard ceiling is what catches it, which is why the ceiling must be below a plausible reversal distance.
- Smoothing in a geographic CRS. Bearings and distances computed in degrees are meaningless. Work in the incident’s projected system, as everything metric on this site does.
- A stationary unit on a moving platform. A crew on a boat or a unit on a transporter is genuinely moving while doing nothing, and the gate will pass it correctly — the display should distinguish moved-under-power from carried, if the feed can.
- Warm-up after every hold. A naive implementation resets its window on each held fix and never accumulates enough agreement to publish. Keep the window over offered fixes, not published ones.
- Consumers that average again downstream. A second smoothing pass in the display layer compounds the lag and is usually invisible in code review. Publish the reason and have consumers assert they are not re-filtering.
Frequently Asked Questions
Why does a displacement threshold not work for AVL smoothing? Because a parked unit and a slowly moving one produce the same per-fix displacement. A parked engine’s fixes scatter by ten to fifteen metres between reports through multipath, and an engine creeping along a fire line at walking pace also advances ten to fifteen metres between reports. Set the threshold below that and the parked unit wanders; set it above and the creeping unit shows as stationary for minutes at a time. The property that actually differs is direction: jitter reverses, so successive displacements undo each other, while real movement advances with consistent bearings.
What must never be smoothed? Three things, because a smoother suppresses exactly the events that most need to be seen. A sudden stop is what a constant-velocity model overshoots by design, and a halt at speed can be a collision signature. A reversal along the unit’s own track looks like jitter to a direction gate unless the displacement is large, which is what the hard ceiling is for. And the first movement after a long stationary period is delayed by any filter with a warm-up window, at precisely the moment a unit is being committed. Status changes, displacements above a ceiling, and post-gap fixes should all publish raw.
Should the smoothed position replace the raw one? No — the smoothed track is a display artefact and the raw track is the record. After-action review needs what the receiver actually reported, multipath analysis needs the scatter that smoothing removes, and any consumer computing whether a unit was inside a hazard perimeter should use raw positions with their stated accuracy rather than a filtered estimate. Publishing the reason a position was held or released alongside it lets a routing layer refuse to treat a held fix as surveyed, which is the specific misuse worth preventing.
Related
- AVL & Resource Tracking Feeds — the reporting policy and heartbeat this filter sits behind.
- Handling GPS Drift in Urban Canyon Environments — why the scatter has a direction, and why averaging entrenches it.
- Reconciling Unit Identifiers Across Agency CAD Systems — making sure the track being smoothed belongs to one vehicle.
- Evacuation Routing & Road Network Analysis — the consumer that must not treat a held position as surveyed.
Up: AVL & Resource Tracking Feeds