← Home

Tracking the Closest Aircraft

Every five seconds, my ADS-B poller updates a dictionary of aircraft within 25 nautical miles. On a busy afternoon over southeast London that’s 30, sometimes 40 entries. The camera has one gimbal. It can only point at one thing at a time.

Deciding which aircraft to track is not as simple as it looks.

The Naive Version

My first attempt: find the aircraft with the smallest dist_km and point the camera at it.

Naive closest target (jitter)

This immediately produced a camera that couldn’t sit still. Two aircraft at similar distances, flying roughly parallel routes - say two Gatwick approaches stacked at different altitudes - would trade the “closest” slot back and forth with every ADS-B update. The gimbal would snap between them every five seconds. Unusable.

So closest is a starting point, not a complete answer. The real requirement is closest with hysteresis.

Visible First

Before picking between candidates I filter to aircraft that are actually pointable. Two conditions:

Visibility filter (elevation + pan limits)

The diagram above shows the filtered target set in context. The code below is the exact filter.

candidates = [
    ac for ac in aircraft.values()
    if ac["elevation"] > 0.5 and PAN_MIN <= ac["pan"] <= PAN_MAX
]

elevation > 0.5 drops aircraft that are at or below the horizon. An aircraft 40km away at 1,000ft barometric can compute to a negative elevation angle - it’s there on the map but there’s nothing to see. The 0.5° threshold gives a little margin above the rooftops.

PAN_MIN <= ac["pan"] <= PAN_MAX drops aircraft outside the camera’s physical sweep. My window faces northeast and there’s a wall on either side. Pan is constrained to -30° (left) through +80° (right). Aircraft to the south are real, they’re in the ADS-B data, but the camera can’t reach them. Filtering them out here means they never enter the selection competition.

Stickiness

The hysteresis logic lives in a single _autoplay_hex variable that remembers the current auto-selected aircraft:

Stickiness with hysteresis margin

The diagram above shows the lock behavior over time. The code below is the decision logic.

closest = min(candidates, key=lambda a: a["dist_km"])

if _autoplay_hex:
    cur = aircraft.get(_autoplay_hex)
    if cur and cur["elevation"] > 0.5 and PAN_MIN <= cur["pan"] <= PAN_MAX:
        if closest["dist_km"] < cur["dist_km"]:
            _autoplay_hex = closest["icao24"]
        return _autoplay_hex

_autoplay_hex = closest["icao24"]
return _autoplay_hex

The logic: if we already have a target and it’s still visible, keep it - unless a closer aircraft has appeared. We only switch when something definitively better shows up, not just when the current target drifts to being marginally further away than an alternative.

This means an aircraft can be tracked from the moment it enters the camera’s field of view until it leaves, without interruption. In practice I watch an aircraft appear on the map at the edge of the scan radius, the camera swings to it, and follows it smoothly across the sky until it exits the pan limits or drops below the horizon. No jitter.

The tradeoff: a faster aircraft that enters the frame and overtakes the current target will steal focus. A slower aircraft that was already being tracked won’t be dropped for something marginally closer that wanders into view. That felt like the right behaviour for watching aircraft - you want to finish watching one plane before picking up the next one.

Manual Override

There’s also a manual mode. The dashboard lets you click any aircraft on the map or in the sidebar list to force the camera onto it:

Manual override takes precedence

The diagram above shows manual selection taking priority. The snippet below is the override path.

if _tracked_hex:
    if _tracked_hex in aircraft:
        _autoplay_hex = None
        return _tracked_hex
    with _lock:
        _tracked_hex = None   # aircraft vanished from ADS-B

Manual selection takes precedence over everything. Once you pick something, _tracked_hex is set and _select_target returns it immediately, bypassing all the auto-play logic. The camera will follow that aircraft even if it’s not the closest, even if something more interesting flies past.

The only way manual selection clears itself is if the aircraft disappears from the ADS-B feed entirely - it’s landed, gone out of range, or its transponder stopped transmitting. At that point _tracked_hex is set to None and auto-play resumes.

Publishing the Target

_select_target just returns an ICAO hex code. The actual work of sending commands to the camera happens in a separate thread. The selection thread (tracking_updater) runs every second and publishes a snapshot of the target’s current state:

with _lock:
    _prediction = {
        "icao":        icao,
        "lat":         ac["lat"],
        "lon":         ac["lon"],
        "alt_m":       ac["alt_m"],
        "heading_rad": math.radians(heading) if heading is not None else 0.0,
        "speed_ms":    speed * 0.514444 if speed is not None else 0.0,
        "vrate_ms":    vrate * 0.00508 if vrate is not None else 0.0,
        "t":           fetch_time or time.time(),
    }

The camera controller thread reads this at 40 Hz and extrapolates the position forward in time. The selection thread and the camera thread are deliberately separated - selection runs at 1 Hz because it doesn’t need to be faster, and the camera loop runs at 40 Hz because smooth gimbal movement does. Mixing those update rates in one loop would mean either wasting CPU on selection logic or limiting gimbal smoothness to 1 Hz.

fetch_time is the timestamp of the last ADS-B poll, not time.time(). Using the poll time matters: the prediction in the camera controller uses time.time() - pred["t"] to know how far ahead to extrapolate. If I used the current time when publishing the prediction instead of when the data arrived, I’d lose up to a second of extrapolation accuracy - enough to matter at close range.

What “Disappeared” Actually Means

Aircraft don’t leave the ADS-B feed gracefully. They don’t send a goodbye packet. They just stop appearing in the response. The poller rebuilds _aircraft from scratch on every fetch - full snapshot each time, no incremental updates. So an aircraft is “gone” as soon as it’s absent from one poll response.

This means a brief ADS-B gap - network hiccup, the aircraft going quiet for one cycle - clears the manual selection. That’s probably wrong. A one-poll dropout shouldn’t reset the user’s explicit choice. I haven’t fixed it; the workaround is that auto-play will pick the aircraft back up on the next poll if it reappears, and usually does.


Get notified when I publish new writeups and progress updates.

← Back