An autonomy stack // for autonomous underwater vehicles

named for the port of Mongla · Pasur River, Bangladesh · the gateway to the Sundarbans

Machines that have to work when nobody is watching.

Underwater there is no radio, no GPS, and no second chance to explain yourself. A vehicle either understood what it was looking at, or it did not — and it will not tell you which. This is the software that has to be right anyway.

Control loop · on the board
500Hz One tick every two milliseconds, on an ESP32 core nothing else is allowed to interrupt. The Pi can crash; this does not stop.
Latency18.0 msphoton to detection, median
Field of view 46.7°measured in water. The datasheet's 63.8° (dashed) is the same lens in air.
Detection53.9 Hzthrough the live ROS graph, not a benchmark
Velocity from the floor 1.09 cmworst error over a 30 cm slide, against a tape measure. The red sliver on the tape is that error, to scale.
Tests4 208passing, none failing
In water0 divesNothing on this platform has been in water. Every number here says where it was taken.
I

The medium

Everything down here is working against you

Everything down here fights you.

Not as a metaphor. Each line is a force acting on a decision the software has to make, and each one is why some ordinary technique does not survive contact with the medium.

the medium does thisso this breakswhat we measured
Absorbs red first, then everythingColour thresholds, contrast tricks 17 preprocessing configs, never once positive
Bends light at the port glassAny angle taken from a datasheet 63.8° in air → 46.7° in water
Blocks radio entirelyTelemetry, GPS, remote intervention no link below the surface
Carries 800× the mass of airAny controller tuned for a drone added mass 1.0× displacement broadside
Moves while you are decidingPosition held by dead reckoning 0.12 m/s current → 1.374 m of drift in 40 s
Hides the thing you were trackingDetectors that assume continuity 71 real detection gaps, catalogued
  1. Mirpur47 frames
    r82
    g188
    b176
  2. RoboSub42 frames
    r71
    g195
    b189
  3. Final run30 frames
    r174
    g174
    b174
Mean red, green and blue over 119 frames of our own 2025 footage — every 20th frame of each venue's folders, original captures with no HUD overlay — each venue scaled to its own brightest channel. At RoboSub red falls to 36% of the strongest channel. Final run comes back level — that footage is near-neutral, which says the camera or its white balance differed, not that the water there was kinder. Two venues, two answers, from the same pipeline: that is why the number is measured per venue instead of quoted once. Measured by tools/colour_loss.py.
Two cones from one lens: 63.8 degrees in air, 46.7 measured in water through a flat port
One lens, two fields of view. The datasheet measures in air; the vehicle swims in water, and a flat port refracts. The 27 % the water takes is 27 % of the course a search pattern believes it is sweeping.
The same simulated gate in murky water The same simulated gate in clear water [ clear ][ murky ]
Simulated: the same gate, one turbidity parameter apart — drag to compare. This is the single variable that decides whether a run scores or ends, which is why the simulator models it at all. The real water is further down the page, and every threshold behind these rows is in measured-bars.md with its method beside it.
The vehicle gliding through the test pool The test pool seen from above
“For a successful technology, reality must take precedence over public relations, for nature cannot be fooled.”
Richard Feynman · Rogers Commission, Appendix F · 1986

Act one // where it learned

It learned most of what it knows from losing to water.

Mongla began as the autonomy software for a university AUV programme. It flew on that programme's vehicles. The vehicles, the team and the name stayed there; the software, and everything it had been taught, came here.

Before us — the team places 2nd

The programme's vehicle takes 2nd at RoboSub. This is the team's result, a year before the author joined; it is here as the ground he started on.

The author joins — the vision years

Detection, tracking, and the slow discovery that most published underwater-vision advice does not survive being measured on your own footage.

RoboSub — 8th of 58

The author leads the vision sub-team. It worked, and it had a ceiling: our control ran at 20 Hz, steering a box we were not allowed inside.

RoboSub — 8th again, on more points

The campaign he led as engineering lead, still on the programme's vehicle. In September he left the university, on principle; Mongla continues independently, with the firmware and hardware lead as its only co-author.

8thof 58 teams · 4,083 points
  1. Semi-Final 14,083#3
  2. Semi-Final 2132#27
  3. Third Chance4,384#4

finals wild card at 4,250 · design documentation 583/860, #26

8thof 58 teams · 6,234 points
  1. Semi-Final 14,779#9
  2. Semi-Final 26,234#5
  3. Third Chance5,786#3

finals wild card at 6,500 · design documentation 628/860, #28

Read round by round out of RoboNation's published score sheets by tools/robosub_record.py, never typed in. Best round 4,083 → 6,234 points (+53%). The red tick is that year's published finals wild-card threshold; seven teams made the finals, and clearing the line on a third-chance run, as 2025 did, was not enough to be one of them. Both vehicles were the university programme's, on the old stack; nothing on the SROT board has been in water.
The team pit at RoboSub Deploying the vehicle at RoboSub Two vehicles in the test pool The team with the flag of Bangladesh
The pit, the deployment, the pool, the people. Both vehicles belonged to the university programme. What continues here is the software that ran inside them.
“A ship in port is safe, but that is not what ships are built for.”
John A. Shedd · 1928 · a motto Grace Hopper lived by

Act two // opening the box

The fast loop lives where it belongs.

A companion computer cannot hold an attitude: it sits on the far side of a cable, answering at whatever rate it can manage. So the reflexes live on a board we write the firmware for, and the Pi is left to do nothing but think.

The same disturbance drawn against a 20 Hz comb and a 500 Hz comb, with the unanswered interval shaded on each
Neither architecture is cleverer than the other. The one underneath simply gets to answer sooner, because it is not on the far side of a cable. At 20 Hz you are not steering, you are voting.

What actually crosses the cable

If the reflexes live on the board, the Pi should have almost nothing to say — and it doesn't. Here is the whole of move_forward, the most ordinary thing the vehicle is ever asked to do, exactly as it leaves the Pi. Read it left to right, the way the board does.

The first ten bytes are the envelope: where a frame starts, how long it is, who sent it, what kind of message it is. The part that means anything is three numbers — a type, a duration, a speed — and one tag that says “this is ours”. There is no thrust in it. No motor, no angle, no depth. Which thrusters spin, how hard, and how the hull holds its heading for those three seconds is the board's business; the Pi only said what it wanted.

the operator typesmongla move_forward 3 --gain 40the cable carries · 44 bytes
0FDSTX0xFD
120LEN32
200INCOMPAT0
300COMPAT0
400SEQ0
5FFSYSID255
6BFCOMPID191
74C0000MSGID76
1000000000p1 · type0
1400004040p2 · seconds3
18CDCCCC3Ep3 · speed0.4
2200000000p40
2600000000p5 · timeout0
3000000000p60
3400000000p70
381879COMMAND31000
4001TGT SYS1
4101TGT COMP1
42FD1ECRC0x1EFD
STX0xFDa MAVLink 2 frame starts here
LEN32payload length — MAVLink 2 cut 1 trailing zero byte
INCOMPAT0no flags set: the frame is unsigned
SEQ0counts frames, so the board can see a loss
SYSID255who is speaking: the Pi
COMPID191ONBOARD_COMPUTER — the id the board's link failsafe listens for
MSGID76COMMAND_LONG, little-endian
p1 · type0MOVE_FORWARD
COMMAND31000CMD_SROT_MOVE — the one custom command
TGT SYS1the board
TGT COMP1its autopilot
CRC0x1EFDchecksum, seeded by the message definition itself
Every byte above is generated by tools/wire_frame.py from the project's own verb table, packed by pymavlink exactly as the manager sends it, then decoded back — the tool fails if the frame does not round-trip.
layerwhat it ownsratestate
SROT board — firmware HenglaIMU, depth, leak, kill, thrust mixing 500 Hzbench
Raspberry Pi 5 + Hailo-8Detection, tracking, optical-flow velocity 53.9 Hzbench
Host controlVerbs, vision loops, mission logic 20–50 Hzbench
EstimationRight-invariant EKF, late measurements replayed in order —software
Anything at all, in water—— blocked
“A complex system that works is invariably found to have evolved from a simple system that worked.”
John Gall · Systemantics · 1975

Play with it // the loop, in your hands

What a control loop is, if you have never written one.

A controller is a machine that answers one question, over and over: I am here, I want to be there — how hard do I push? Below is a toy hull with one axis of heading. Turn the three knobs and watch it fail in the three classic ways. Nothing here is our vehicle: it is a simulation with made-up numbers, so that the shapes are honest even though the values are not.

overshoot—
settled—
left over—
unanswered for—

Four things worth meeting by hand. P alone overshoots and rings: nothing is braking it, so at the moment it is finally pointing the right way it is still turning. Add D and the ringing dies, because it now pushes back against its own approach speed. At three seconds a current starts pushing, and the PD controller parks a degree or two short of the target for ever — it is pushing exactly hard enough to hold station and not hard enough to close the last degree. Only I fixes that, by remembering what is still missing. Then move the loop rate: the gains do not change, but the window in which the vehicle is on its own does. That is the number in the fourth readout.

On this vehicle that last knob is not ours to turn from the Pi. It is the board's, and it is pinned at 500 Hz on a core nothing else is allowed on — which is the whole argument of the section above.

A controller is not clever. It is patient, five hundred times a second.
the loop, in one line
II

The machine

Seven hundred and two millimetres of argument

The body // 702 mm, five thrusters, one uncontrolled axis

A red machine, lit by the water it has to survive.

Drag it to turn it; take it apart with the fader, or jump to a stop. Every number below was read off the CAD geometry itself — 84,248 triangles at 0.9 mm tessellation, measured rather than quoted.

Mongla hull // drag to orbit // + − to zoom drag orbit · wheel zoom · , . close & open · 0 reset
L 702.0 mmbeam 176.1height 172.1 fineness 3.99tris —
take it apart 0 % apart
The Mongla hull in its own livery: red fairings over a grey pressure can, the axial thruster's spider in the nose, the carry handle beneath
Not a photograph and not a marketing render — the CAD itself, drawn by the same code that runs the model above, in the livery the vehicle is actually built in. The pale rectangle is the viewport; the gold hub in the nose is the axial thruster.
Bow — the axial duct
The hull head-on: the nose thruster's four-spoke stator inside its duct
Quarter, aft
The hull from behind and to one side, both tunnel openings visible
From below
The hull from below, the angle the pool floor sees
Plan
The hull from above, showing both vertical tunnel openings and the hatch

Every one of these is rendered at 2800 px from the same 84,541-triangle mesh the interactive model uses, with each part carrying its own material colour straight out of the CAD. Nothing here is a photograph, and nothing here is an artist's impression.

Orthographic starboard, plan and bow elevations of the hull, generated from the CAD
Starboard, plan and bow — traced from the mesh itself, not drawn by hand. The four circles are the tunnel bores; the spoked disc in the bow view is the axial thruster's stator.

What the geometry says

measurementvaluewhy it matters
Length overall702.0 mmOne person can carry and launch it
Beam × height176.1 × 172.1 mmNear-square section — form alone gives no preferred roll orientation
Fineness ratio3.99Just under the 4.5–6 band of minimum-drag bodies of revolution: slightly stubby, deliberately compact
Frontal area160.9 cm²The water that has to be pushed aside
Wetted area1.107 m²Where skin friction is paid
Lateral tunnels2 × ⌀84 mm at ±175 mmSway, and yaw on a 175 mm arm
Vertical tunnels2 × ⌀84 mm at ±259.5 mmHeave, and pitch on a 259.5 mm arm
Axial thruster1, in the noseSurge
Bilateral symmetry±88.05 mm, exactNo built-in turn bias to trim out
PLAN · 702 mm · bow right · every thruster on the centreplane VERT ⌀84 — heave + pitch pitch couple 519 mm LAT ⌀84 — sway + yaw yaw couple 350 mm 33 % shorter than the pitch couple SURGE ROLL — no actuator, in any of the five · passive stability only
Five thrusters, every one of them on the centreplane. Red acts in the plane of this drawing; blue acts through it. Nothing acts about the long axis — roll has no actuator.

Where the design is strong

Four tunnels and one axial unit buy five of six degrees of freedom — surge, sway, heave, pitch, yaw — with no vectoring linkage, no moving nozzles and nothing protruding into the flow. Tunnels also deliver full authority at zero forward speed, which is precisely the regime a task is won in: holding still in front of a hole. Bilateral symmetry is exact to ±88.05 mm, so there is no built-in turn to trim away. And the pitch couple is 48 % wider than the yaw couple (519 mm against 350 mm), which puts the stronger moment on the axis that has to resist a nose-down trim.

Where it costs

The four bores total 221.7 cm² of open aperture on a hull whose frontal area is 160.9 cm² — the holes come to 1.38× the frontal area. Open tunnels are cavities in cruise: they are the price of station-keeping authority, and it is paid continuously while transiting. At a fineness of 3.99 the body is already blunter than the drag optimum, so the two choices compound. The couples are also arguably the wrong way round — yaw is the axis an AUV actually uses, to hold heading against current and to line up on a target, and yaw got the narrower 350 mm couple. Swapping the tunnel pairs would favour the axis doing the work. And with every thruster on the centreplane, roll has no actuator at all: it has to be passively stable, which promotes the vertical placement of ballast from a detail to a load-bearing decision.

What this drawing cannot tell you

All of the above is geometry, and geometry is half a hull. These are unmeasured, and will not be guessed at here:

  • Net buoyancy — whether it floats at allneeds material densities
  • Centre of buoyancy above centre of mass — the whole roll argumentneeds a mass model
  • Drag coefficient, and so the power to cruiseneeds a tow test or CFD
  • Thrust per tunnel, and so whether five are enoughneeds a bollard test
  • Whether the modelled motors are the ones fitted3 of 5 populated in CAD

A shell mesh cannot answer the buoyancy question, and it would be easy to pretend otherwise: the enclosed volume of this model is 1.77 L, which is the surface and not the displacement. Using it would repeat a mistake this project has already made once, and written down. The geometry here is read straight out of the CAD by pack_hull.py, which is also what builds the model above.

Every number on this hull was read off the geometry. The ones that could not be read — mass, buoyancy, drag — are left blank, not guessed.
the body, and what it refuses to say

Play with it // when the vehicle cannot do what it was asked

Ask for more thrust than exists, and the vehicle does something you never asked for.

"Forward, a little left, and turn" is three numbers. The vehicle has four horizontal thrusters, each of which can only give so much. When the sum of those demands exceeds what a motor can deliver, something has to give — and the choice of what gives is a design decision most people never make on purpose. Make the wrong one and a vehicle asked to go straight quietly turns instead.

asked for—
motors saturated—
you actually get—
heading error—

The arithmetic, in full

Four horizontal units at 45°, so each one contributes to all three axes at once: m₁ = s + l + y, m₂ = s − l − y, m₃ = s − l + y, m₄ = s + l − y. Ask for 0.8 forward and 0.4 of turn and one motor is already being asked for 1.55 of a thing that stops at 1.0.

Clip it there and the four motors no longer stand in the ratio the demand described. Run the arithmetic backwards — s′ = (m₁+m₂+m₃+m₄)/4 and so on — and the motion that comes out has a yaw term nobody asked for. The vehicle is not disobeying. It is doing exactly what four clipped numbers mean.

Why the shape is the thing to protect

Scaling the whole demand by one factor costs speed and keeps direction: you arrive later, along the line you meant. Clipping costs direction and keeps speed: you arrive somewhere else, on time. For a vehicle following a visual target, the second failure is far worse, because the controller sees the drift and asks for more correction — which saturates harder.

The board scales, and the host fits the demand yaw-first before sending it (allocation.py), on the view that a heading you did not ask for is the most expensive thing to be given.

⚠ One honest caveat: the mixer above is the firmware's eight-thruster geometry, of which four are horizontal. The hull currently in CAD has five thrusters in a different arrangement. Both are true of something; they are not true of the same vehicle, and this figure says which one it is drawing.

Saturation is where a vehicle stops doing what it was told and starts doing arithmetic.
the mixer

The suite // three repositories, one wire

Mongla is the soul. It does not own the body.

Four repositories, two authors, and a rule that is easy to state and hard to keep: we never commit to theirs, they never commit to ours. Anything missing at the low level is a pull request, not a workaround on our side.

repositorywhat it iswho owns ithow we reach it
MonglaPerception, estimation, mission language, simulator — this repository Muhammad Fahim Faisal—
Hengla — srot-control-boardThe firmware. ESP32 dual-core plus an RP2350 thruster co-processor Rakibul IslamMAVLink 2 over one USB-C cable
Bondor — srot-ground-stationThe operator's desktop GCS, plus a LoRa bridge Rakibul Islamserial · UDP 14550 · LoRa 433 MHz
srot-esc-flasherBench tool — puts Bluejay on the ESCs Rakibul Islam4-way interface

Hengla — what runs on the board

Core 1 is the flight loop and nothing else is permitted to run there: read the IMU, decide what each axis should do, split that across eight thrusters, listen to what came back, repeat two milliseconds later. Core 0 carries everything that can afford to wait — MAVLink at 100 Hz, LoRa and the SD card at 20, the display at 30. The RP2350 speaks bidirectional DShot, so an ESC reports the RPM it actually reached rather than the one it was asked for.

That last detail is the one worth pausing on. Without it the motors never answer: you ask for thrust and the honest answer to "is thruster three actually spinning?" was nobody knows. Telemetry back from the ESC turns an open question into a measurement — which is exactly how we learned that 958 of 958 recorded ESC frames read zero with nothing plugged in, and that counting messages is not a health check.

sensorscontrolDShot
MAVLink
display
LoRa · SD
ESP32
core 1500 Hzsensors · control · DShot
core 0100 HzMAVLink to the Pi
core 030 Hzdisplay
core 020 HzLoRa and SD card
Every task laps the board once per period, slowed 1000× so the eye can follow it: the inner ring's three jobs go round every 2 s because they run every 2 ms. Nothing on the outer rings can reach the inner one.

Bondor — the operator's side

An Electron and React desktop application built specifically for this board: teleop, a SROT_MOVE console, parameters, calibration, motor test, PID tuning and autotune, black-box analysis and a configurable joystick tab. It connects over USB serial at 115200 — opened with DTR and RTS de-asserted, deliberately, so that attaching a ground station does not reset the flight controller — or over UDP, or receive-only over LoRa at 433 MHz, 250 kHz bandwidth, SF7, CR 4:5.

Three copies of the wire contract exist on purpose, because there are no submodules across the suite: the LoRa struct, the vendored MAVLink dialect, and a hand-mirrored TypeScript copy of the message ids. The firmware is the source of truth for all three, and a mismatch does not raise an error — it fails CRC silently and looks exactly like being out of radio range. On our side the same hazard is answered by a test that reads the firmware's own headers and fails if our constants have drifted from them.

links · three ways in
USB serial
115200 baud · DTR and RTS held low, so attaching does not reset the board
UDP
port 14550
LoRa
433 MHz · 250 kHz · SF7 · CR 4:5 · receive-only
the wire contract · three copies, one truth
  1. LoRa struct
  2. vendored MAVLink dialect
  3. TypeScript message ids

All three follow the firmware. A mismatch fails CRC silently — and looks exactly like being out of radio range.

Bondor // against the live board, 2026-09-22 It needed the board attached to show anything true, so these were taken with the board on the bench and the cable in — the same session the reading further down came from. Everything in them is the board talking: rev 14, link OK, disarmed, leak dry, and eight thruster RPMs reading zero because there are no thrusters on it.
Bondor's Dive tab: attitude ball, compass at 17 degrees, depth 0.00 m, battery 14.2 V, leak dry, firmware rev 14, and eight thruster RPM readings at zero
diveArm, the modes, the RPM row, and the messages the board sends on connect — including its own configuration line.
Bondor's Inspector tab: fifteen message types with their rates, and SYS_STATUS expanded field by field
inspectorEvery message with its rate. This is the same stream the panel below measures — ESC_STATUS at 118.9 Hz, NAMED_VALUE_FLOAT at 347.2.
Bondor's Payload tab: sixteen PCA9685 channels, each with a role of servo or switch and a test control
payloadSixteen channels, each with a role that decides what the board will drive — and only a switch channel can be fired by the companion.
“One way is to make it so simple that there are obviously no deficiencies, and the other way is to make it so complicated that there are no obvious deficiencies.”
C.A.R. Hoare · ACM Turing Award Lecture · 1980

Play with it // four switches, one observable

Only the product is visible. Two wrongs look exactly like a right.

Between "turn left" and the water there are four independent places a sign can be flipped: the axis convention the host writes, a firmware parameter that negates every axis before the mixer, a firmware revision that changed what positive yaw means, and the direction each motor was told it spins. You can only ever observe their product — which way the hull actually went. Every one of these has been wrong on this vehicle at least once, and the register of that is in BUGS.md.

the product—
the hull does—
verdict—
wrong switches—

Why a passing test is not a correct configuration

Pick "two wrongs" above. The hull turns the way it was asked, every bench check passes, and the vehicle is misconfigured in two places at once. Nothing in the observable disagrees, because the observable is a product and the product is right. The configuration only reveals itself when someone corrects one of the two — at which point a fix makes the vehicle worse, which is the single most confusing thing that can happen to a team on a pool deck.

This is why the rule here is to read the parameters before arming rather than to test the turn. A turn tells you about the product. Only the parameters tell you about the factors.

What the vehicle actually does about it

The firmware revision is a hull-safety interlock: the board reports SROT_FW_BEHAVIOUR_REV, and the host refuses at connect and again inside arm() if it is below the floor in srot_protocol.py. The floor is deliberately not a number written in prose anywhere — a test fails any document that quotes a stale one, because a remembered constant is how a check gets silently disabled.

The other three switches are configuration, and configuration is checked by reading it. FRAME_REVERSE = 1 with MOT_1..8_DIRECTION all −1 is the documented double-inversion hazard: if all eight read −1, the axis fix is cancelled and six of the motors are individually inverted — a hull that turns correctly and mixes incorrectly.

A sign error is the only bug that can be fixed twice and thereby unfixed.

Perception to propulsion // one frame, all the way down

From a photon to a thrust, in ten stops.

Follow one frame from the moment light reaches the lens to the moment a propeller should turn. Every stop carries the number measured there and the file that does the work — and the last two carry the honest word for what has not been measured yet.

The 18.0 ms path, to scale // hatched stages come after it
exposure 18.0 ms · a detection exists · 10.20 of it measured on the chip downstream
01 · light 30.18Hz true capture, asked for 60

Light arrives, and is counted

The physics under it

bench

    One camera frame from exposure to detection, drawn to scale across 40 milliseconds, with the board's 500 Hz tick comb underneath
    The same 18.0 ms again, drawn against the board's 500 Hz heartbeat: by the time a detection exists, the board has already run its loop nine times. That ratio, not the latency on its own, is the reason the reflexes live down there.
    Ten stops, and the only one we could not measure is the last: the water pushing back.
    the journey, photon to thrust

    Play with it // one cable, and what fits down it

    Telemetry is free until it isn't, and then it takes the heartbeat with it.

    Everything the board says crosses one USB-C cable at 115 200 baud — about 11 520 bytes a second once the start and stop bits are paid for. Today the link runs at a measured 18.8 % of that. The temptation, always, is to add "just one more" stream; the cost is not that the new stream is slow, it is that everything already on the wire starts queueing behind it — including the heartbeat the board uses to decide whether anyone is still there.

    on the wire—
    queueing delay—
    heartbeat margin—
    verdict—

    What the model is, and what it is not

    Measured: the link's capacity, the 18.8 % the vehicle uses today, and the heartbeat at 2 Hz against the board's 5 s silence failsafe — a 10× margin — and the bar this project ships against, which is ≤ 60 % of the wire. Those are in the ledger with their method.

    Modelled: the queueing delay above. It is the standard result for a link at utilisation ρ — the wait grows like ρ/(1−ρ), which is flat and boring for most of its range and then vertical. The curve is drawn to make that shape felt, not to predict a millisecond. A figure that blurs which half is measured is worse than no figure.

    Why the heartbeat is the thing that breaks

    The board surfaces the vehicle after 5 s without hearing from the Pi. That is a deliberate, good failsafe — and it means the heartbeat is the one message whose lateness is a vehicle-level event rather than a logging nuisance. A saturated wire does not announce itself as "telemetry is slow". It announces itself as a vehicle that surfaced in the middle of a run for no reason anyone can see on deck.

    This is also why the interesting number is not bandwidth but headroom. At 18.8 % the link is not "mostly empty" — it is a system with a known distance to a cliff, and the only honest way to add a stream is to measure the distance again afterwards.

    A link is not full or empty. It is near or far from the point where waiting begins.

    The safety chain // what stops the vehicle, and what does not

    Eight links between a mistake and a hull on the bottom of the pool. One of them is missing.

    A vehicle is not made safe by one clever guard. It is made safe by a chain of dull ones, each owned by a different part of the system, each catching a failure the others cannot see. Pick a failure below and watch which link takes it. The chain is drawn as it is configured today — including the link that is switched off and the one that cannot tell two different states apart.

    caught by—
    when—
    what the vehicle doespick a failure
    Every link is a line in the code or a parameter read off the board, not an intention. The heartbeat is measured at 2 Hz against a 5 s failsafe — a 10× margin. LEAK_EN is armed again — the reading below says so — and the kill-switch ambiguity (measured-bars.md §26) is still an open ask to the firmware team, not something this repository can close from the host.
    Live off the board // 2026-09-22T03:20:06Z/dev/ttyUSB0 · 115200 baud · 40 s window
    the cable, right now 21%

    2417 of 11520 bytes a second · 78 messages · 160 frames the parser rejected. The tick is the 60 % bar this project ships against.

    1. NAMED_VALUE_FLOAT29Hzthe health names, multiplexed
    2. ATTITUDE9.8Hzroll, pitch, yaw
    3. SCALED_IMU29.8Hzthe raw inertial data
    4. UNKNOWN_2919.8HzESC_STATUS — undecodable here, by design
    5. VFR_HUD4.9Hzdepth, reported as altitude
    6. SYS_STATUS2Hzthe health words
    7. BATTERY_STATUS0.9Hzone pack per instance
    1. leakpresent · enabled · healthyreads 0 — dry
    2. barometerbit says unhealthyBARO_HEALT reads 3 · p2p 0 — the two disagree
    3. kill switchKILL = 0still means live OR not talking
    4. armednocustom mode 19

    and the board states its own configurationCFG r14 FR=1 DIR=-1,1,1,1,1,1,1,-1revision 14 · FRAME_REVERSE = 1 · eight motor directions, and not all −1 — so the double inversion two sections above is not present on this hull, and the board is the one saying so.

    Read by tools/board_snapshot.py over the USB-C cable with DTR and RTS deasserted, so attaching did not reset the board; rendered by tools/board_panel.py from docs/data/board.json. Nothing was transmitted but the parameter reads. The electronics pack reads 19.03 V, the vehicle is disarmed, and there are no thrusters attached to it.

    Why a chain, and not a better guard

    Each link is simple enough to be obviously right, and each one lives where its failure can actually be seen. The board cannot know a mission is stuck; the host can. The host cannot act if the Pi has frozen; the board can. A human cannot react in two milliseconds; the loop can. No single layer could hold all eight, which is exactly why none of them tries.

    It is also why the order of operations in arm() matters more than any one check in it: the firmware revision is checked again at arm, not only at connect, because a board can be re-flashed between the two and a revision below the floor turns every command backwards.

    The one link we cannot close from here

    The leak link was off, and is on. On 2026-09-07 this board read LEAK_EN = 0 — the firmware gates both the pre-arm refusal and the surface failsafe on that one parameter, so a leak would have done nothing. It was switched on before the bench session on 2026-09-15, and the reading above says so: LEAK comes back present, enabled and healthy. The link is only as good as its last reading, which is why this figure carries the timestamp of one.

    The kill link is ambiguous. KILL = 0 means "live" or "the power board is not talking to us", and the host cannot tell which. So arm() refuses only on a known engaged switch, and the display says UNKNOWN rather than clear when the second board is silent. That is the honest reading, not a fix — the fix is one line of firmware, and it has been asked for.

    Safety is not the guard that catches everything. It is knowing exactly which failure each guard will not see.
    the safety chain

    One task // thought through, start to finish

    What actually happens when it is told to pass a gate.

    Not pseudocode and not a flowchart of boxes. This is the real sequence, with the real rates, and with the places it is allowed to give up marked as clearly as the places it succeeds.

    ArmoncerefusesLook53.9 Hz[ ] foreverDecide1.0 sa flickerCentre20–50 HzLOSTHold175 frstated lossDrive500 Hzdepth loopKnow—no DVL
    Seven steps to a gate, left to right, and under each one the way it is allowed to fail. The hull only moves on when the step before it has said yes; a red line is a place the run is designed to stop, loudly, rather than carry on believing something false.
    stepwhat the machine is doingratehow it can fail, honestly
    ArmCheck the firmware revision, refuse below the floor, verify the barometer is healthy, confirm the kill switch is live onceRefuses. An unhealthy barometer denies every automatic move — it arms and simply will not go
    LookHailo-8 runs the detector on the forward camera; the newest frame wins and surplus frames are dropped 53.9 HzReturns nothing. A missing class sidecar yields an empty allowlist and a silent [] forever
    Decide it is theredetected('gate') asks whether the class was seen inside a recency window, not whether it is in this exact frame 1.0 s windowA flicker does not count as absence. An if here runs once — a moving search needs a loop
    Centre itvision.align drives lateral and yaw on the signed pixel offset from frame centre 20–50 Hz hostNever raises. Returns ALIGNED, LOST, TIMEOUT, NO_CAMERA or ABORTED — and where it ended
    Hold the lockLive box, then tracker coast, then a geometric anchor matching image structure 175 framesRefuses rather than inventing. The ladder ends in a stated loss
    Drive throughOne SROT_MOVE; the board runs and brakes the primitive itself 500 Hz boardThe automatic mode closes the depth loop under every primitive, and that loop has never run closed
    Know where it wentA right-invariant filter on board IMU, depth, and velocity from the downward camera; late measurements replayed at the instant they describe —No GPS, no DVL. The downward camera is the only velocity there is

    Read that column on the right again. Every row has one, and that is the design: a verb that reports success while the vehicle does nothing is the failure mode that ends competition runs, so nothing in this stack is allowed to be quietly optimistic. The ladder ends in a refusal, not in a confident invention.

    “Computing science has very convincingly shown that simplicity is a necessary precondition for reliability.”
    Edsger W. Dijkstra · EWD1175

    A run, from the inside // what it sees, believes and does

    Ride along inside Mongla's head for one run.

    Three panes, one clock. On the left is the forward camera as the detector sees it. In the middle is what the vehicle believes — its own position, the course it was told about, and how sure it is. On the right is what it decides to do about that. Nothing here is a cartoon of the pipeline: the rates, the detection gaps, the lock ladder and the refusals are the measured ones, and the narration is a sentence the code could actually have produced.

    …
      The detector blinks on the real distribution — 71 recorded gaps, median 0.155 s, worst 4.00 s — and when it blinks the lock climbs the measured ladder rather than inventing a target. Dead reckoning drifts while the vehicle is blind, which is why the belief pane draws how sure it is and not just where it thinks it is. A verb that cannot finish says so: the run below ends one task early, on purpose, because that is what the budget did.

      Why three panes and not one

      Every failure this stack has had lives in the gap between two of these panes. A detector that sees nothing while the belief pane says the gate is dead ahead is a lock problem. A belief that drifts while the camera holds a target steady is an estimator problem. A demand that keeps pushing while both agree the target is centred is a control problem. One pane cannot show you which of the three you are looking at, and a single "it works" video hides all of them.

      This is also the argument for the scorecard: every vision verb records what it saw, where it ended and why it stopped, so a run can be read back in these three columns afterwards instead of being remembered.

      What it is allowed to say

      The narration is deliberately restricted to things the code has a field for: the class it is looking for, whether it saw it inside the recency window, the signed pixel offset, which rung of the lock ladder it is on, the verb in flight, and the outcome — ALIGNED, LOST, TIMEOUT, NO_CAMERA, ABORTED. There is no line in here that the vehicle could not have written itself, because a narration that knows more than the vehicle is a story, not a readout.

      The vehicle is never confused. It is specific about what it does not know — and that is the whole difference.
      inside its head

      Play with it // the lens the water grinds for you

      Water takes a third of the picture, before the first pixel is read.

      A camera's field of view is a property of the lens, and every datasheet states it for air. Put the same camera behind a flat window in water and the number is simply no longer true: rays bend as they cross the glass, the cone narrows, and the edges of the world quietly leave the frame. Nothing warns you. The image is still sharp, still centred, still the same 640 pixels wide — it just contains less world.

      cone in air—
      cone in water—
      world in frame—
      the prop—

      Why the cone narrows

      A flat port is a pane of glass with water on one side and air on the other. A ray arriving from the water at angle θw leaves into the air at θa, and Snell's law fixes the relationship: nw sin θw = sin θa. The sensor still accepts rays out to its own half-angle, so the half-angle it accepts in the water is asin(sin θa / nw) — always smaller. The cone does not move or blur; it shrinks.

      This is also why the picture is not simply cropped. The bending is angle-dependent, so a straight line near the edge comes back curved, and a pixel's bearing is not the bearing the pinhole model would give it. That correction lives in optics.py, and it is applied before any pixel becomes a direction.

      What we measured, and what it cost

      The datasheet says 63.8°. In water we measured 46.7° — and Snell, from the index of water alone, predicts 46.71°. Two independent routes to the same number is the strongest form of "this is right" available on a bench.

      The cost of having believed the datasheet is not a blurry picture. It is every bearing being wrong by a growing amount toward the edges, every range estimate built on that bearing being wrong with it, and a standoff distance chosen so that a prop fits — computed against a cone 37 % wider than the one the vehicle actually has.

      A number measured in air is not a number about the vehicle. It is a number about the lab.

      Play with it // the sensor we could not buy

      A camera pointed at the floor is a speedometer, if you know one number.

      A Doppler velocity log is the instrument that tells an underwater vehicle how fast it is moving over the ground. We do not have one. What we have is a camera pointed down, and the fact that the floor slides past it — but the same pixel motion means a different speed depending on how far away the floor is.

      pixels move—
      floor in view—
      speed we report—
      error we make—

      What it scored against a tape measure

      Three real slides of 30 cm on the bench rig, downward camera at 0.72 m lens-to-floor, f = 513.94 px. The honest part is the third column: the height the measurement implies, recovered from the slide it got wrong.

      Worst error 1.09 cm on 30 cm — 3.6 %. Nortek quote 0.5–1 % for a real DVL's bottom track, so this is several times worse than the instrument we cannot afford, and the same order as published monocular visual odometry in turbid water. It was measured in air, on a hand slide whose own precision is about ±1 cm: the operator's tape is inside our error bar, so it is an upper bound on the error, not a measurement of it.

      slidemeasurederrorimplied height
      lateral 30 cm30.13 cm+0.130.72 m
      forward 30 cm31.09 cm+1.090.69 m
      back 30 cm31.04 cm+1.040.70 m
      tape says——0.72 m

      That last row is the result worth keeping. Height is recovered independently, from h · truth / measured, and the three runs land on 0.72, 0.69 and 0.70 m against a tape that says 0.72. The scale chain closes on itself — which is the only reason to believe the speed at all.

      The floor was always there. It only became a sensor when we knew how far away it was.
      the bottom camera

      Play with it // the correction that arrives from the past

      Every measurement is a statement about a moment that has already gone.

      A camera exposes a frame, the Hailo-8 finds a prop in it, the result crosses two processes and lands in the filter — and by then the vehicle has moved. The fix is perfectly correct about where the vehicle was. Apply it to where the vehicle is and you have just pulled the estimate backwards in time, on purpose, at speed.

      fix age—
      moved since—
      applied on arrival—
      replayed—

      What the filter actually does

      It keeps a short buffer of its own recent states. When a measurement arrives carrying the instant it describes, the filter winds itself back to that instant, applies the correction there, and replays every motion input since — so the correction lands on the state it was actually about, and the vehicle's present position is a consequence rather than an override.

      This is the only reason a timestamp is worth anything. A message that says "the prop is here" is a rumour; a message that says "the prop was here, at this instant on the board's clock" is a measurement. The board stamps its own messages, which is what makes the rewind possible at all.

      Why it looks fine until it doesn't

      Hold the vehicle still and both tracks agree exactly — latency multiplied by zero speed is zero error. Every bench test, every dry run, every "we checked it in the lab" is that case. The bug ships because the condition that reveals it is the condition you cannot create indoors: moving, turning, and being corrected at the same time.

      Drag the yaw rate to zero and the two tracks separate along one line — bad, but at least honest. Put the turn back and the naive track leans into the corner, because a correction that is 180 ms stale during a 18 °/s turn is a correction about a heading 3.2° ago.

      A late measurement is not noise. It is a true statement, applied to the wrong moment.

      The simulator // 20,649 lines, thirteen courses, one purpose

      A place to be wrong that costs nothing.

      A second colcon workspace inside this repository: Gazebo, both competition pools, thirteen courses, both cameras, ground truth, and an operator lab. It is not a demo. It is where every recovery path in the stack got run for the first time, because a pool day is four hours long and a bug is cheaper here.

      A BlueROV2 Heavy hovering beside the gate in the simulated pool, the slalom poles and task boards behind it
      Gazebo // the stand-in hull A BlueROV2 Heavy at the gate, flown by ArduSub SITL. It is not our vehicle, on purpose: the simulator is a physics environment, and every verb it runs goes through the same interface the board uses.
      The gate and the slalom poles from the start of the course
      the gate and the slalom
      The bins and the torpedo board in the simulated pool
      bins and the torpedo board
      The slalom poles, red and white, from low in the water
      slalom, from low
      The whole simulated pool, the task tables and the octagon target
      the whole course
      what it modelswhy it had to be modelledthe number
      Real T200 thrust curveA straight line was wrong by a quarter of full thrust, so fine alignment behaved nothing like the pool12.24 N error
      Water currentThe lateral integral term existed to fight a current the simulator did not have0.12 m/s → 1.374 m in 40 s
      Camera field of view in waterThe in-air figure showed more of the course than the vehicle can ever see, so every search pattern was tuned against a view that does not exist80° → 57.7°
      Bar30 noiseOurs was ten times too noisy on the one sensor every mission depends on0.02 m → 0.002 m
      Fault injectionFive recovery paths existed in the code and had never once executedDVL · camera · link · battery · thruster
      Ground-truth labelsHand labelling does not scale; the bounding-box sensor is occlusion-correct and free12.83 Hz vs 12.75 baseline

      Each of those rows started as a thing that looked fine. The pattern repeats so exactly that it became a rule: the simulator's job is not to look right, it is to be wrong in the same places the water is. A static prop generates contact and cannot be pushed. A cylinder and a box return no collision at all in one physics engine, so the vehicle drove through every gate leg and slalom pipe for months. Fog does not reach camera sensors, so thirteen courses shared one clarity while claiming six.

      The archive higher up this page came out of here — 4,925 labelled frames, 27 classes, both competitions — and so did the honest finding that two of those sessions detect nothing under the current weights.

      fault injection · five recovery paths that had never once executed
      1. DVL dropoutthe sensor we do not have, failing the way it would
      2. camera lossvision verbs must return NO_CAMERA, not hang
      3. link silencethe board surfaces after 5 s — does the host agree?
      4. battery sagheld 3 s while armed, and the board acts alone
      5. thruster failurethe allocation has to fit the demand with what is left
      Every one of these paths existed in the code and had never run. A pool day is four hours long; a fault you can inject on a laptop costs nothing. This is the simulator's real job — not looking like water, but being wrong in the same places water is.
      A simulator is where you are allowed to be wrong cheaply — which is only worth anything if you remember it is not the water.
      the simulator
      III

      The evidence

      What it sees — and how we know that it saw it

      The rehearsal // 4,925 synthetic frames, and the stack run over them

      First, we rehearse in water we built.

      Everything in this section is synthetic — frames rendered by our own simulator, scored by a detector trained on simulator imagery. It is a rehearsal, and calling it anything else would be the exact mistake this project exists to refuse. What it does prove is that the pipeline runs end to end: the true detections are handed to the same render_ui_strip() the vehicle draws with, so the operator display below is real even though the water is not.

      The operator display on a clear-water frame: seven detections including the gate at 0.93 confidence, with the aim line from frame centre to the primary target
      Simulated clear water. Seven detections — the gate at 0.93, a flare at 0.92, three drums, the pinger drum. The red box is the primary; the line runs from frame centre to it, and its length is the error the controller is closing.
      The operator display on a murky-water frame: four detections, the gate still held at 0.98
      Simulated murky water, same detector, same weights. Four detections instead of seven — but the gate survives at 0.98. The flares are what the water takes first — which is the argument for training on the water you will actually swim in, and the reason the next section exists.

      And the honest half: across the thirteen sessions, two returned no detections at all under these weights — they are a different course with a different class set. That is a fact about the model, not about the water, and it is here because a showcase that only shows the frames where the detector performed is a showcase of nothing.

      sessionframesrichest framebest confidence
      sim_transit_clear1,1207 detections0.93
      sim_transit_murky1,198——
      rs_bbox894——
      sim_clear3095 detections0.60
      sim_murky3124 detections0.98
      gate_approach ×34210 detectionswrong class set
      drum_orbit1272 detections0.69

      Every session carries its own meta.json, its class list, ground-truth labels from the simulator's bounding-box sensor, and the video it came from — 27 classes across both competitions.

      Clean water teaches you the shape of the task. It teaches you nothing about the task.
      the rehearsal

      The water // three venues, real weights, real footage

      Then we find out what the water was actually like.

      These are photographs. The weights are the ones trained during the 2025 season; the frames come from the 20 GB archive recorded at a pool in Mirpur and at RoboSub in Arizona. Six scenes, 3,345 real frames scanned, every frame chosen by searching its folder for the richest one — never picked by hand.

      Mirpur · gate
      A gate structure in a turquoise indoor pool at Mirpur, the real detector boxing shark_gate at 0.85
      RoboSub · bins
      The bins prop on an outdoor pool floor under hard sun caustics, detected at 0.94
      Final run · bins
      The competition bin tray with its sawfish and shark placards, both read by the real detector
      RoboSub · torpedo
      The torpedo board underwater, the real detector finding the board and its openings
      Mirpur · torpedo
      The torpedo board in the hazier Mirpur water, the detector still separating board from opening
      RoboSub · octagon
      Looking up toward the surface at the octagon, detected at 0.91

      Look at the third one. The machine is not finding "a bin" — it is reading the sawfish and the shark printed on the two openings, bin_shaw_fish and bin_shark, both at 0.84. Which opening you drop into is the whole task, and that distinction is what a season was spent teaching it.

      venuewhat the water is liketaskframesbest
      MirpurIndoor, turquoise, hazy — contrast falls off within a few metresgate550shark_gate 0.85
      MirpurSame pool, closer intorpedo378shaw_fish_hole 0.94
      RoboSub, ArizonaOutdoor, bright, hard moving sun caustics across the floorbins639bin 0.94
      RoboSub, ArizonaSame pool, mid-watertorpedo775shark_torpedo 0.95
      RoboSub, ArizonaLooking up toward the surfaceoctagon410octagon 0.91
      Final runClose over the prop, the placards legiblebins593bin_shark 0.90

      Mirpur and Arizona are not the same problem. One is hazy and even; the other is bright, with caustics that sweep the floor and look exactly like motion. A threshold tuned in one is wrong in the other — which is why every number in this stack carries the conditions it was taken under, and why the simulator above bothers to model caustics at all.

      The archive is 20 GB and stays out of this repository. What ships is six stills and a manifest.json naming the exact source frame behind each one, so any figure here can be re-derived with one command.

      The real footage did not make the detector look worse. It made our assumptions look worse.
      the archive
      IV

      The record

      Including every number we had to take back

      Retractions // kept on purpose

      The results we had to take back.

      The measurement ledger holds 107 entries and 821 rows of numbers. 18 of those entries say, in their own title, that they took an earlier result back; 7 more measured an idea and the answer was to leave it off. They are kept where anyone can read them, because a project that quietly deletes its wrong answers is a project you cannot check. The full ledger is here.

      first entrynewest
      taken back · 18measured, answer was no · 7stands · 82
      One column per entry, in the order the file was written; height is how many numbers it holds. The colour is what each entry says about itself in its title, counted by tools/ledger_strip.py — so it can miss a retraction that is not in a title, but it cannot invent one.
      Image enhancement made detection better

      gate detected in 30.4 % of frames → 1.2 % with it on

      Seventeen configurations across four props and three venues. Never once positive. The implementation was correct; the original A/B was confounded. Preprocessing is off, and a test keeps it off.

      The confidence-adaptive filter tracked good boxes more tightly

      selectivity 0.78× — it was doing the exact opposite

      The shipped filter followed doubtful detections more closely than confident ones. It had been live, and every test passed, because no test compared the two directions.

      Range-dependent gain cut jitter 16×

      a pooling artefact, not an effect

      One clip contributed 1,299 far-field samples at the lowest jitter and zero near-field ones. The trend was an artefact of how samples were pooled. The feature stays off.

      Thruster health was verified

      958 of 958 ESC frames read exactly 0 — with nothing plugged in

      The check counted messages arriving, and messages arrive whether or not a motor exists. A gate that cannot fail is not a gate.

      The depth loop works

      it has never once run closed

      The sign was inverted until it was found, and the sensor was not fitted when it was written. Two armed bench checks now gate every automatic move — including a plain forward command, because on this board every automatic primitive closes the depth loop.

      “It doesn’t make any difference how beautiful your guess is… If it disagrees with experiment, it’s wrong.”
      Richard Feynman

      Instruments // every number with where it was taken

      If it is not measured, it does not ship.

      Frame staleness · the camera mailbox
      396 msa queue, before
      16.9 msa mailbox, shipped
      23× fresher, by keeping only the newest frame instead of every frame in order.
      Detection · what ROS costs
      80.9 HzHailo-8, standalone
      53.9 Hzthrough the live graph
      A third of the chip's rate is spent getting frames through the graph.
      Field of view · what water does
      63.8°the datasheet, in air
      46.7°measured, in water
      A flat port bends light. Use the air number and every range estimate is wrong.
      Loop500 Hzboard, dedicated core
      Latency18.0 msphoton to detection
      Blind175frames held with no detector
      Velocity1.09 cmworst error, 30 cm slide
      Mesh0.9 mmhull tessellation in the viewer
      Suites4 208passing across six suites; 0 failed

      Each of these lives in a ledger with its method, its conditions and the bar it had to clear — and the tests read that file, so a number cannot drift away from the measurement that produced it without something going red.

      A number without its method is a rumour.
      the ledger

      The language // how a machine is asked to do something

      A mission should read like an instruction, not a state machine.

      One action, thirty verbs, two vision primitives. The vision verbs never raise — they return where and how they finished, so a mission branches on reality instead of on an exception.

      # hold station on the target, then fire mid-hold — still correcting
      res = mongla.vision.align(
          'hole', lat=0, yaw=0,     # centre it: signed px from frame centre
          fwd=62, fwd_mode='area',  # and hold this standoff
          hold=4.0, fire=[1, 2],    # two shots, spaced, each gated on a NEW frame
          lock_on=True, brake=False,
      )
      
      if not res.saw_target:        # never read x_px before this — NaN < n is silently False
          return search_pattern(mongla)
      if res.x_px > 0:              # it ended right of centre, so recover right
          mongla.move_right(duration=0.6)

      On the board this runs as streamed MANUAL_CONTROL in STABILIZE; an axis that would move the depth setpoint is refused, and comes back as FAILED rather than an exception.

      what comes back · VisionResult
      x_px = +62

      Signed pixels from frame centre at the last frame the target was seen: +x right, +y below. NaN if it was never seen — which is why saw_target is read first.

      1. ALIGNEDgoal reached
      2. LOSTseen, then gone
      3. TIMEOUTout of time
      4. NO_CAMERAnothing to look with
      5. ABORTEDstopped on purpose
      6. FAILEDrefused or errored — never raised

      The comment on the eighth line is the whole philosophy compressed: a result that is absent must never be mistaken for a result that is zero. The full verb table is in command-reference.md; what a vision verb hands back is in vision-results.md.

      A mission that cannot say what went wrong is a mission that cannot recover from it.
      the language

      The last idea // and the one nobody writes down

      The vehicle has no present tense. It has a stack of pasts, and it steers on all of them.

      Every number the controller uses is a statement about a moment that has already gone, and each one is a different moment. The attitude is two milliseconds old. The depth is ten. The detection is eighteen at best, and during a gap it can be four seconds. Nothing in the code declares this — there is no variable called now to be wrong. The vehicle simply acts, at 500 Hz, on a picture assembled from clocks that never agreed.

      freshest thing it knows—
      stalest thing it is using—
      the width of “now”—
      control ticks inside it—
      Live, and to scale: each lane is one signal, the marker is how old that signal is at this instant, and it snaps back to zero when a new one arrives. Every rate is measured — 500 Hz control, 100 Hz telemetry, a 30.18 Hz camera, detection at 53.9 Hz through the ROS graph with 18.0 ms of photon-to-detection ahead of it, and gaps drawn from the 71 real dropouts whose distribution is p50 0.155 s, p90 0.651 s, max 4.00 s. The white band is the spread between the freshest and stalest number in use: that band is the vehicle’s present, and it is never a point.

      Why this is not a latency chart

      Latency is a number you quote once. This is the thing latency does when you leave it running: the ages never line up, so every control tick blends a two-millisecond attitude with a detection from a fifth of a second ago and acts as though both describe the same world. Most of the time that is fine, because the world did not change much in that fifth of a second. The interesting question is the one the picture asks by itself — how wide is it allowed to get before "fine" stops being true?

      That question has a shipped answer, and it was measured rather than chosen: VISION_FRESH_ZERO_S is 0.20 s, which covers 56.3 % of the real gaps, and the lock ladder behind it was sized against the same 71 dropouts. The rungs exist because the band in this figure gets wide.

      What surprised us about it

      Drag the figure into a p90 gap and count the ticks: the board runs several hundred control cycles on a detection that has not been refreshed once. The reflexes are fine — they never needed the camera — but every decision layered on top is being made about a world that has moved on, and the estimate of how far it moved is itself an estimate.

      This is the honest reason a vehicle can be excellent at every individual number on this site and still lose a run. Nothing failed. The parts simply did not agree about when it was.

      There is no instant in this system where everything is true at once. Now is a band, and the engineering is in how wide you let it get.

      Shoulders // ten voices, and the instrument each one moved

      Every idea borrowed here shows up as a reading on this vehicle.

      A list of influences is a bookshelf. This is a dive: each voice sits at a depth, and choosing one lights the instrument in this vehicle that it changed — with the number that instrument shows today. If an idea did not move a reading, it has no business on the page.

      “I just wondered how things were put together.”
      Claude Shannon — who wrote the theory of the channel, and also built a juggling machine, a rocket-powered frisbee and a mouse that solved a maze. Interview, 2 November 1989; quoted in Soni & Goodman, A Mind at Play.

      Why he is at the top of this list: he is the reason a measurement is the unit of work here. Information is what survives a noisy channel — so a value a sensor cannot stand behind is not a small error, it is not information, and printing it as 0.0 manufactures certainty out of noise. That is why absence renders -- on the board and NaN here, why a 44-byte frame is drawn bit by bit further up this page, and why 958 of 958 empty ESC frames is a finding rather than a footnote. He is also the register: he did this for fun. Claude E. Shannon

      the instrument --
      Shannon

      Depth here is only an ordering — the dive is the metaphor, not a measurement. The numbers on the instruments are not: each one is the reading that idea is responsible for, and each is in measured-bars.md with its method.

      Further // where to go if any of this caught you

      The reading behind it.

      This page is one project's answer. These are the places the questions came from — and the places to go if you would rather build your own answer than read mine.

      • The whole of this stack, in source

        Every number on this page traces to a file in it

        github.com/fh1m/mongla_ws

      • The architecture from first principles

        Written for someone who has never seen a flight controller

        The Shift

      • What is proven, and how

        Every row carries its evidence and a state — in water, on a bench, in software, blocked

        Capability map

      • The firmware, the board, the ground station

        Rakibul Islam's work — the other half of the machine

        srot-control-board · srot-ground-station

      • The competition this is aimed at

        The rulebooks are the specification every prop in the simulator was built from

        RoboSub · SAUVC

      • Why a neural detector is a component, not an oracle

        The essay that reframes the model as something you shape rather than write

        Software 2.0

      • How to think about latency at all

        Carmack, on treating every stage as somewhere to claw milliseconds back

        Latency Mitigation Strategies

      • Estimation on a manifold, properly

        Barrau & Bonnabel, 2014 — the filter this stack's localization is built on

        The invariant EKF as a stable observer

      • The same idea, made to walk

        Hartley, Ghaffari, Eustice & Grizzle — the clearest worked application of it

        Contact-aided invariant EKF

      • Why the robot should not be clever

        The argument for deleting before adding, from someone who does it in public

        Hotz on complexity

      And if you are building one of these yourself: the single most useful thing in this repository is not any algorithm. It is measured-bars.md — every shipped constant with the measurement that produced it, the conditions it was taken under, and the bar it had to clear. The entries that take a number back sit in the same file as the ones that stand. That file is the project.

      “The first principle is that you must not fool yourself — and you are the easiest person to fool.”
      Richard Feynman · Cargo Cult Science · 1974

      Act three // open

      Nothing here has been in water yet.

      The honest list of what stands between this and a vehicle that swims:

      1. The board's depth loop has never run closed two armed bench checks, then waterwater
      2. Thruster health cannot tell eight healthy motors from none the firmware already computes it and drops it one line earlyone-line merge
      3. Moves by measured distance are refused no such primitive on the board; refused before dispatch, never fakedpull request
      4. The board cannot yet accept a velocity measurement we have one, good to 1.09 cm over 30 cm, and nowhere to send itpull request
      5. The competition radio cannot reach through an aluminium hull an external antenna, not a software fixhardware
      6. Three of five thrusters are populated in the CAD and the firmware's mixer is written for eighthardware
      7. No mission has been flown on this platform every number on this page says bench, footage, or boardwater

      None of that is hidden, because a capability map that promotes bench results to flight results is how a team finds out at the venue. The live list is ROADMAP.md — one status file, so it cannot disagree with itself.

      I left on principle, and I am building this on hope rather than optimism. Hope is the decision to keep working on the better version of a thing while the current version is still broken. Pushing a technical limit is the most concrete form of that I know: every measurement that came back worse than expected is a small argument that the world is knowable, and that the next version can be better than this one.
      Muhammad Fahim Faisal · Dhaka · 2026

      The next act is open // the machine is still being built