Skip to content

DOA app: agent_app_doa.py + kraken_poller.py

Lives on: the follower Pi 5, in ~/capstone/rfdf/. Source in this repo under agentcore/rfdf/.

What it does

This is the piece that turns a raw KrakenSDR bearing into a bearing on the map.

  1. kraken_poller.py pulls the latest direction-of-arrival (DOA) sample from the Kraken Pi 4 over the onboard Ethernet cable and keeps only the new ones.
  2. agent_app_doa.py is an AgentCore External Process app. Five times a second, AgentCore sends it the drone's position and heading from the Cube flight controller. The app pairs every Kraken bearing with where the drone was and which way it was pointing at the moment the bearing was measured, converts it to a true (compass) bearing, and reports the health of the Kraken link back to AgentCore.
Kraken (Pi 4) --Ethernet--> kraken_poller --> agent_app_doa <--WebSocket--> AgentCore --USB--> Cube
                                                   |
                                                   v
                                       logs/paired.csv (one row per bearing)

Walkthrough: the poller

The Kraken publishes its latest result as one comma-separated line on a small web page (DOA_value.html): Unix time (ms), bearing, confidence, RSSI. It produces a new sample every 436 ms (one data block). The poller checks every 200 ms:

POLL_PERIOD_S = 0.2        # must be < 0.436 s Kraken block time, or samples get dropped
BLOCK_MS = 436             # Kraken data block length, used for gap detection

Polling faster than the Kraken updates means we see most samples twice, so each one is identified by its timestamp and repeats are thrown away:

if s.t_ms == self.last_t:
    self.stats["dup"] += 1
    return
if self.last_t is not None and s.t_ms - self.last_t > 1.5 * BLOCK_MS:
    self.stats["gaps"] += 1
self.last_t = s.t_ms
self.last_new_mono = time.monotonic()
self.queue.append(s)

Polling slower than 436 ms would silently drop samples (at 2 Hz we lost about 1 in 8), which is why the earlier "poll at ~2 Hz" plan was dropped. The poller runs in its own thread with a 0.5 s timeout, so a slow or dead link can never freeze the app.

Walkthrough: health states

When the Kraken stops processing, its web page keeps serving the last sample. So "did a reply come back" isn't enough; the app tracks how long since a new sample and how long since any reply:

if http_age > STALE_LIMIT_S:
    state = "LINK_DOWN"      # Pi 4 not answering
elif stale > STALE_LIMIT_S:
    state = "DSP_STALE"      # answering, but no new samples
else:
    state = "OK"

STALE_LIMIT_S is 3.5 s. The Kraken pauses for about 2.8 s every ~5 minutes to recalibrate its five receivers (found with kraken_gaps.py); 3.5 s keeps those planned pauses from raising a false alarm while still catching a real stop quickly.

Walkthrough: pairing a bearing with the drone's pose

The Kraken stamps each bearing with the Pi 4's clock; the drone's pose arrives on the Pi 5. The two clocks are kept within a couple of milliseconds of each other (the Pi 4 syncs its time from the Pi 5), so the timestamps can be compared directly.

The app keeps the last 10 s of poses. For each bearing it waits until it has a pose from after the measurement, then interpolates between the two poses on either side:

f = (t_ms - t0) / (t1 - t0)
(la0, lo0, h0), (la1, lo1, h1) = self.p[i - 1], self.p[i]
return (la0 + f * (la1 - la0),
        lo0 + f * (lo1 - lo0),
        (h0 + f * _wrap180(h1 - h0)) % 360.0)   # heading wraps at 360

Heading needs the _wrap180 step: halfway between 350° and 10° is 0°, not 180°. Interpolating it like an ordinary number would send the drone the wrong way round the circle.

It also refuses to guess: if the two poses are more than 600 ms apart, or no newer pose shows up within 1.5 s, the bearing is dropped and counted as no_pose.

Why this matters: at 30°/s of yaw, being half a second off in time puts the bearing 15° off.

Walkthrough: true bearing

The Kraken reports bearing relative to the antenna array. To get a compass bearing, add the drone's heading and the array's mounting offset:

"bearing_true_deg": round((hdg + MOUNT_OFFSET_DEG
                           + BEARING_SIGN * s.bearing_deg) % 360.0, 2),

Three constants are still to be calibrated in the anechoic chamber:

Constant What it is Now
MOUNT_OFFSET_DEG Where the array's 0° points relative to the drone's nose 0
BEARING_SIGN +1 if the Kraken's bearing increases clockwise, −1 if not +1
DOA_TIME_OFFSET_MS Shift from the Kraken's timestamp to the middle of the measurement (expected ≈ −218 ms) 0

Every paired bearing is appended to logs/paired.csv: t_ms, lat, lon, hdg_deg, bearing_rel_deg, bearing_true_deg, confidence, rssi_db.

How AgentCore talks to it

AgentCore's External Process node sends each request over a local WebSocket on port 8770 and passes only the got part of the reply on to the rest of the graph, so every reply looks like:

return {"ok": True, "got": {
    "health": self._health(),
    "paired": paired,
    "pair_stats": dict(self.pair_stats),
    ...
}}

The WebSocket plumbing itself comes from AgentCore's Python starter kit, which isn't ours and isn't shown here. The graph that calls this app is described on the DOA_Pose graph page.