← Home

Tracking the Closest Aircraft

The target chooser runs on a fresh ADS-B snapshot every five seconds. On a busy afternoon over southeast London, that snapshot usually contains 30 to 40 aircraft within 25 nautical miles. The camera can only follow one of them, so each poll has to collapse a noisy multi-target picture into a single control decision.

The Naive Version

My first attempt was exactly what it sounds like: find the aircraft with the smallest dist_km and point the camera at it.

Naive closest target (jitter)

It worked in the narrow sense that it always picked an aircraft. It failed in the more important sense that the camera behaviour was unstable. Two aircraft at similar distances and flying roughly parallel routes would swap the “closest” slot on alternating ADS-B updates. Two Gatwick approaches stacked at different altitudes are a common example. The gimbal snapped back and forth every five seconds. You could see the control law fighting the data.

So nearest-distance selection is only the starting point. To turn it into something operationally stable, I had to add constraints around visibility and continuity.

Visible First

The first layer is to stop considering aircraft the camera cannot plausibly track, so before choosing between candidates I filter to aircraft that are actually pointable. There are two conditions.

Visibility filter (elevation + pan limits)

Here 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 exists perfectly well in the data, but there is nothing to see from the window. 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 and present in the ADS-B snapshot, but the camera cannot reach them. Filtering them out here means they never enter the competition at all.

Stickiness

Visibility filtering is not enough. Once several visible aircraft remain, nearest-distance alone is still too twitchy. The next layer is stickiness. The logic lives in a single _autoplay_hex variable that remembers the current auto-selected aircraft.

Stickiness in target selection

Here 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 rule is simple. If I already have a target and it is still visible, keep it unless something closer appears. That is the shift from unstable to usable, with no extra margin in the comparison. The continuity comes from holding the current target until another visible aircraft is actually nearer.

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.

The tradeoff is intentional. A faster aircraft that enters the frame and overtakes the current target will steal focus, and so will any other visible aircraft that edges ahead on distance. What this buys me is not a margin against tiny differences, but a simple stay-with-it rule between updates. The camera keeps following the current target until another visible aircraft is nearer at the next decision point. That still produces longer, more continuous tracking segments than naive closest-target churn.

Manual Override

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

Manual override takes precedence

The override path is short.

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 _tracked_hex is set, _select_target returns it immediately and bypasses the auto-play logic. The camera follows that aircraft even if it is not the closest and 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 may have 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 only 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 two loops are deliberately separate. Selection runs at 1 Hz because it does not need to be faster. The camera loop runs at 40 Hz because smooth gimbal movement does. If I mixed those rates in one loop, I would either waste CPU on selection logic or limit gimbal smoothness to 1 Hz.

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

What “Disappeared” Actually Means

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

That means a brief ADS-B gap, whether from a network hiccup or the aircraft going quiet for one cycle, clears the manual selection. That is not really the behaviour I want. A one-poll dropout should not reset an explicit user choice.

I have not fixed it. Auto-play will usually pick the aircraft back up on the next poll if it reappears, and for now that is enough. But it is still a real edge in the system. Target selection is now stable enough to use, yet disappearance is still defined by a single missing snapshot.


Get notified when I publish new writeups and progress updates.

← Back