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.
kraken_poller.pypulls the latest direction-of-arrival (DOA) sample from the Kraken Pi 4 over the onboard Ethernet cable and keeps only the new ones.agent_app_doa.pyis 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.