Capability Map
What this vehicle can do, and how we know
Every row carries evidence — a file in one of our repositories, or a measurement with a date — and a state. The states are not blurred:
| state | meaning | |
|---|---|---|
| 🟢 | WATER | verified with the vehicle in water |
| 🟣 | BENCH | verified on the bench, on real recorded footage, or against the live board |
| 🟡 | BUILT | implemented and covered by tests, never flown |
| 🔴 | BLOCKED | waiting on hardware or on a firmware merge |
A capability map that hides the last two is marketing. The blocked rows are listed in full at the bottom, and no row in this document is 🟢 yet — this platform has not been in water.
New here? Read The Shift first — it explains the two halves of the system and why the split exists.
1. Control: keeping the vehicle where you put it
| Capability | How it works | Evidence | State |
|---|---|---|---|
| Inner control loop | 500 Hz, our source, on a core reserved for it | fw include/config.h (CONTROL_LOOP_HZ 500), core pinning in src/main.cpp |
🟣 BENCH |
| Attitude and heading hold | ours: angle → rate → torque, with gains we can change | fw src/control/attitude.*, task_control_loop.cpp |
🟣 BENCH |
| Heading stability at rest | drift < 0.01 °/min, peak-to-peak < 0.08° over 8 min | measured-bars.md (srot heading, 2026-09-07, at rest) |
🟣 BENCH |
| Thruster mixing | vectored-8 geometry, and we can read the matrix (⚠ the current CAD hull has five thrusters — see vehicle-spec.md) |
fw src/control/mixer.h, docs/THRUSTER_MAP.md |
🟣 BENCH |
| Saturation behaviour | the whole demand scales together so the shape of the move survives; the host also fits the demand yaw-first before sending | fw mixer.h; host mongla_control/allocation.py |
🟡 BUILT |
| Flight modes | eleven, including two tuning modes and a pattern runner | fw include/state_types.h |
🟣 BENCH |
| Movement primitives | ten, run and braked on the board: forward, back, strafe L/R, turn, dive, stop, hold, style, arc | srot_protocol.py (CMD_SROT_MOVE = 31000); fw src/comms/mav_commands.cpp |
🟡 BUILT |
| Automatic gain tuning | a relay self-tune on the board, driven from an operator command | fw src/control/autotune.h; host mongla_manager/srot_autotune.py |
🟡 BUILT |
| Depth hold | implemented, never closed in water — two bench checks gate every automatic move until it is | fw src/control/depth_control.cpp; .claude/context/platform/srot-integration.md |
🔴 BLOCKED |
| Link budget | one USB-C cable at 18.8 % of capacity; heartbeat margin 10× the failsafe | measured-bars.md §4 |
🟣 BENCH |
Failsafes — the vehicle protects itself without the Pi
| Trigger | What the board does | Evidence | State |
|---|---|---|---|
| Water detected inside | surface; translation and yaw zeroed | fw task_control_loop.cpp |
🟡 BUILT |
| Thruster battery sagging (held 3 s, armed) | surface | fw task_control_loop.cpp, config.h |
🟡 BUILT |
| Operator link silent for 5 s | surface | srot_protocol.py (GCS_FAILSAFE_MS = 5000) |
🟡 BUILT |
| Companion computer silent | surface — but only once it has been heard at least once | fw task_control_loop.cpp |
🟡 BUILT |
| Surfaced and idle | auto-disarm | fw config.h |
🟡 BUILT |
| The command stream stops mid-move | authority fades to zero between 1.0 s and 1.5 s instead of latching | measured-bars.md §4 |
🟣 BENCH |
| Firmware too old to trust | the host refuses to arm below behaviour revision 10, because revision 10 inverted yaw | srot_protocol.py (FW_BEHAVIOUR_REV_REQUIRED), guarded by test_doc_drift.py |
🟡 BUILT |
2. Sensing: what the vehicle knows about itself
Every sensor is on the board, on one clock. Most of this table is not new hardware — it was already inside the board and had nowhere to go until we asked.
| Capability | How it works | Evidence | State |
|---|---|---|---|
| Attitude and rotation rates | on the flight board, fused at up to 400 Hz, read at 500 Hz | fw src/drivers/bno085.* |
🟣 BENCH |
| Depth and water temperature | direct — plus the controller’s own error, output and command, published for inspection | fw src/drivers/bar30.cpp |
🟣 BENCH |
| Leak and kill switch | both readable; the leak failsafe reads present, enabled and healthy in the live board snapshot of 2026-09-22 (it was OFF on 2026-09-07), and the kill switch’s ambiguity is a known, written ask | .claude/context/upstream/pr-h-kill-is-ambiguous.md |
🟣 BENCH |
| Battery | two packs, electronics and thrusters, de-multiplexed by id | host fc/srot_fc.py (note_battery) |
🟣 BENCH |
| Per-thruster RPM | measured by the ESCs, returned on the command wire | fw Pico + thruster_link.cpp; needs Bluejay from srot-esc-flasher |
🟣 BENCH |
| Thruster presence | computed on the board and dropped before the wire — a one-line ask, open | .claude/context/upstream/pr-f-esc-presence-on-the-wire.md; 958/958 frames read zero with nothing attached |
🔴 BLOCKED |
| Trustworthy timestamps | stamped on the board’s own clock: period 100.000 ms, sd 0.000 at the source, against sd 4.741 ms by arrival | measured-bars.md §14 |
🟣 BENCH |
| Reading the board live | ros2 run mongla_manager connect prints everything the board sends — no ROS graph, no built workspace needed |
mongla_manager/srot_connect.py |
🟣 BENCH |
| A preflight that gates rather than guesses | bringup_check --srot grades each subsystem and exits non-zero on a real fault |
mongla_manager/bringup_check.py |
🟣 BENCH |
3. Vision: what the vehicle sees
| Capability | How it works | Evidence | State |
|---|---|---|---|
| Detection hardware | a Hailo-8 that does nothing else | mongla_vision/detection/hailo.py |
🟣 BENCH |
| Detection rate | 80.9 Hz standalone, 53.9 Hz through the live ROS graph | measured-bars.md §3 |
🟣 BENCH |
| Photon to detection | 18.0 ms median | measured-bars.md §3 |
🟣 BENCH |
| Frame freshness | a mailbox that keeps the newest: 16.9 ms stale against 396 ms | measured-bars.md §3; cameras/v4l2_mailbox.py |
🟣 BENCH |
| Segmentation | decoded without leaving the quantised domain: 31.5 ms → 0.93 ms, bit-exact | measured-bars.md (Hailo segmentation); detection/seg_decode.py |
🟣 BENCH |
| Holding a target between detections | a lock ladder — tracker, optical-flow follower, then a feature anchor that held 175 consecutive frames the detector had lost | measured-bars.md §2; mongla_vision/lock_node.py |
🟣 BENCH |
| Continuity budget | measured from 71 real gaps: the coast covers 91.5 %, the track buffer 100 % | measured-bars.md §2 |
🟣 BENCH |
| Lens geometry | measured per camera: 63.8° air, 46.7° water ±0.7°, 25 views, held-out validated | measured-bars.md §3; config/calibration/*.json |
🟣 BENCH |
| Refraction through a flat port | corrected radially, by the same rectifier every metric consumer uses | mongla_vision/optics.py |
🟣 BENCH |
| Image preprocessing | measured in 17 configurations and never positive; on the gate it destroyed 95 % of detections. Ships off, and a test keeps it off | measured-bars.md §1 |
🟣 BENCH |
4. Estimation: where the vehicle is
No DVL is fitted and no GPS reaches underwater, so every number in this table is built from the IMU, the depth sensor and the downward camera. Without it, a distance move is a timed guess.
| Capability | How it works | Evidence | State |
|---|---|---|---|
| Velocity over the floor | optical flow from the downward camera, with rotation removed before scaling | mongla_vision/flow/flow_node.py; measured-bars.md §10 |
🟣 BENCH |
| Distance accuracy | three 30 cm slides: worst error 1.09 cm (3.6 %); recovered height 0.72 / 0.69 / 0.70 m against a 0.72 m tape | measured-bars.md §13 |
🟣 BENCH |
| Refusing to guess | 727 static intervals produced zero velocity reports: an unmeasurable interval is never published as “0 m/s” | measured-bars.md §10 |
🟣 BENCH |
| Height above the floor | from a single pixel on a known plane | mongla_localization/floor_plane.py |
🟡 BUILT |
| Pose filter | a right-invariant EKF over IMU, depth, flow, heading and fixes | mongla_localization/inekf.py |
🟡 BUILT |
| Late measurements | replayed at the instant they describe, with the tail re-run | mongla_localization/retro.py, test_retro.py |
🟡 BUILT |
| Absolute position | a fix from a prop whose location is known, or resection from two bearings | course_map.py, resection.py, pnp_node.py |
🟡 BUILT |
| Absolute heading | from a prop of known bearing, from pool lane lines, or from the floor tile grating | heading_anchor.py, pool_lines.py, tile_grating.py |
🟡 BUILT |
| Venue priors | a course file per competition, overridable on the deck without a rebuild, plus a tool to survey the real venue | mongla_localization/courses/*.yaml, course_survey.py |
🟡 BUILT |
| Frame tree | FRD/NED inside, REP-103 FLU/ENU at the boundary through a published TF tree; the pool frame is unreachable until anchored | mongla_localization/frames.py, test_frame_tree.py; live on the board: odom → base_link yaw −58.95° ENU against a 148.6° NED heading (90 − 148.6 = −58.6) |
🟣 BENCH |
| Camera lever arm and lens height | a turn in place is not read as sideways motion (ω × r), and flow scales by the lens height, not the barometer’s | inekf.update_body_velocity_xy, test_camera_lever_arm.py (0.1256 m/s → under 1 cm/s); the three offsets are not yet taped, so the corrections are off and sensor_offsets reads DEGRADED |
🟡 BUILT |
5. Acting: missions, alignment and payload
| Capability | How it works | Evidence | State |
|---|---|---|---|
| Vision alignment | the same two verbs, now 50 Hz on the srot path (soaked 90 s at 49.86 Hz, zero late ticks) over a hull the board stabilises at 500 Hz | motion_vision.py; measured-bars.md §3 |
🟣 BENCH |
| Downward-camera work | the same, and now without a streamed depth setpoint: the axes that need one are refused explicitly instead of silently doing nothing | mongla_control/vision_verbs.py, test_srot_vision_actuation.py |
🟡 BUILT |
| Driving through a target | unchanged — the verb is backend-independent | motion_vision.move_loop |
🟡 BUILT |
| Payload | MAVLink to the board’s own outputs, with a typed outcome per shot: fired, refused, denied, no-ack, busy | fc/base.py, fc/srot_fc.py |
🟡 BUILT |
| Firing on a fresh frame | gated on a new live detection, so a frozen or predicted box can never trigger a shot | motion_vision.py |
🟡 BUILT |
| Knowing whether it fired | the board’s own answer is carried back into the mission result | mongla_control/vision_verbs.py |
🟡 BUILT |
| Rationing a run | an opt-in budget: every task is asked whether it is still worth attempting, and the verdict is recorded | mongla_planner/run_budget.py, missions/sauvc_full.py |
🟡 BUILT |
| Giving up cleanly | a task deadline that cancels the goal in flight and hands the mission its fallback | mongla_planner/client.py, mongla_dsl.task() |
🟡 BUILT |
| Evidence after a run | a scorecard per run: every verb, its outcome, the goal id, what the camera saw, and optionally the frame itself | mongla_dsl.py, vision_dsl.py |
🟡 BUILT |
| Operator radio | LoRa through Bondor, including the competition’s flare order sent to a hull already in the water | mongla_manager/flare_order_send.py; fw src/comms/flare_order.h |
🔴 BLOCKED |
6. What owning the stack actually gave us
Not the hardware — the verbs. Six things that follow from being able to read, measure or change every layer, including the one that keeps the vehicle level:
- We can measure inside the loop. The board publishes its own depth error, output and command. That is how we know the depth loop has never truly run — something a closed autopilot would never have told us.
- We can prove firmware and host agree. A test here reads the firmware’s headers directly and fails if our copy of a shared constant drifts. Two codebases, one truth.
- Sensors that were being thrown away came back. Per-ESC RPM and current were computed on the board and discarded one line before the wire. We found them by reading the source.
- Time became trustworthy. Because the board stamps its own messages, a late reading is still usable — we know when it was true, not merely when it arrived.
- The vehicle can be brought up one capability at a time. Every feature is a switch with a defensible default, so a failure is isolated instead of argued about.
- A missing feature has an address. Nine written asks, each with the evidence that
produced it, sit in
.claude/context/upstream/. The board is not final either: a capability that needs a new sensor is a conversation with the hardware team, not a wall.
7. The honest list: what is blocked, and on what
| Blocked | Why | Unblocked by |
|---|---|---|
| Depth hold in water | the board’s depth loop has never run closed, and it gates every automatic move | two armed bench checks, in water |
| Thruster health | presence is computed then dropped before the wire, so “eight healthy” and “none attached” look identical | a one-line firmware merge |
| Distance moves on srot | move_*_dist are refused; they need a board-side measured move |
firmware PR #23 |
| Velocity and position sent to the board | the board has no velocity ingest of any kind today | a firmware merge accepting the standard optical-flow message |
| Flare order over the air | a LoRa antenna inside an aluminium hull is inside a Faraday cage | an external antenna feedthrough, plus firmware PR #24 |
| Learned velocity estimation | needs thrusters fitted and spinning to gather data | thrusters on the hull |
| Flare detection range | a 16 mm pole is detectable only very close, and the real number is unmeasured | a pool session with the actual prop |
| Every mission | not one has been flown on this platform | water time |
Nothing here claims a pool result we have not taken. Where a row says BENCH, it means exactly that: a bench, a recorded bag, or the board on a desk.
The numbers are maintained in
measured-bars.md,
where each is recorded with the method that produced it, the conditions, and the bar it has to
clear. Tests read that file, so a number cannot quietly drift away from the code.