What We Built
A towed sonar array sits hundreds of metres behind the vessel that tows it. Until now, bearing lines from those sensors originated at the host vessel’s position – close enough for a glance, but wrong by whatever the offset happens to be. When a vessel turns, the error grows: the array trails around the corner and the bearing fan pivots with it, not with the ship.
We shipped computeArrayCentre – a single calculation that places each bearing line at the array’s actual geographic position. It supports three modes, selected per sensor:
- PLAIN backtracks along the vessel’s course at the contact time by the offset distance.
- WORM walks backwards along the vessel’s actual track geometry, segment by segment, so the origin follows the path the array physically took.
- MEASURED interpolates from instrumented position data when the sensor reports its own location, falling back to PLAIN if no measurement covers the contact’s timestamp.
Zero-offset sensors return the vessel position unchanged. Unknown or missing modes fail safe the same way.
How It Works
The algorithm lives in two places and behaves identically:
- TypeScript:
shared/components/src/MapView/array-offset.ts - Python:
services/calc/debrief_calc/tools/sensor/array_offset.py
Both use a single Earth radius (6 371 000 m), the same spherical haversine formula, and linear interpolation between bracketing timestamps. A shared JSON fixture (shared/schemas/src/fixtures/valid/track-feature-array-offset-01.json) drives the test suites on both sides – same inputs, same expected outputs, full IEEE-754 precision.
The TypeScript integration point is prepareSensorContacts in sensor-utils.ts. <SensorBearingLayer> now gets corrected origins automatically whenever a sensor defines both offset and array_centre_mode; no changes were needed to the rendering code itself.
The Three Modes Side by Side
The screenshot below renders the same vessel track and the same four sensor contacts three times – once per mode – in the Debrief UI:

Same inputs, three different answers. In PLAIN the bearing fan pivots with the vessel’s current heading. In WORM it stays wrapped around the path the array physically took through the turn. In MEASURED it anchors to the sensor’s instrumented location, with only one contact shown here because the measured-position window doesn’t cover the others.
The WORM Close-Up
Zooming into WORM makes the key behaviour easier to see:

Contacts C1 and C2 were collected while the vessel was still on the southbound leg. Their bearing cuts originate there, on the pre-turn part of the track – which is where the array actually was at that moment. C3 and C4 come later, once the vessel has turned east, so their origins have walked around the corner too. PLAIN can’t do this: its origins always backtrack from the vessel’s current heading, which is only correct between manoeuvres.
By the Numbers
| New tests | 87 |
| TypeScript unit (vitest) | 39 |
| Python unit (pytest) | 32 |
| Cross-language parity | 8 |
| Integration scenarios | 5 |
| Tests failing | 0 |
| Golden fixture delta (TS vs Python) | 0.000000 m |
| 1000-contact WORM recompute (TypeScript) | 83 ms |
| 1000-contact WORM recompute (Python) | 208 ms |
Both languages sit comfortably inside the 1-second budget for 1000 contacts (SC-004), leaving headroom for larger datasets. A performance test in the TypeScript suite fails CI if that budget is ever breached.
No schema changes. No new runtime dependencies. No modifications to the rendering pipeline – the origin correction slots in upstream of it.
What’s Next
With array centres now placed correctly, the next item (#120) uses these origins as the foundation for time-based bearing-line animations: as the playback cursor moves, the array walks along the track and the fan follows it.