Coordinate systems
NED, body, slant versus ground range, and the port-image layout.
All geometry in this repo. Sources of truth:
beamformer/sas/geometry.py, simulator/platform.py,
simulator/heightfield.py, simulator/scene.py,
beamformer/sas/imaging.py.
Earth frame: NED
- X = North (along-track for a due-north heading)
- Y = East (cross-track for a due-north heading)
- Z = Down (positive down)
Seafloor is the Z = 0 reference plane. The platform lives at negative
Z (above the floor). altitude = -pos_z by convention
(PlatformMotion.altitude). Initial platform position:
P_0 = [0, 0, -altitude[0]]
Body frame
- X = Forward (bow)
- Y = Starboard (right, looking forward)
- Z = Down
The TX and the RX array positions (tx_pos_body, rx_pos_body) live in
this frame; the pipeline rotates them into NED each ping via the attitude.
Rotation: Yaw-Pitch-Roll (ZYX)
geometry.compute_rotation_matrix(roll, pitch, yaw) builds the 3x3 matrix
that rotates body -> earth:
v_earth = R @ v_body
v_body = R.T @ v_earth
Per-ping matrices are stacked by compute_rotation_matrices and used by
compute_phase_centers to place each TX/RX element in NED. Phase center
is the midpoint of TX and RX in NED:
pc_ned = (tx_ned + rx_ned) / 2.
Six degrees of freedom (surge/sway/heave, roll/pitch/yaw)
Standard SNAME senses, all verified against the code rather than assumed. Translations are along the body axes, rotations are right-handed about them.
| DOF | Axis | Positive sense | Where it is fixed |
|---|---|---|---|
| Surge | body X | forward (bow) | beamformer/sas/filters.py body-velocity triplet |
| Sway | body Y | to starboard | platform.py adds along (-sin h, cos h), the starboard normal |
| Heave | body Z | down (altitude decreases) | platform.py adds to pos_z, which is down-positive |
| Roll | about X | starboard side down | R[:,1] z-component cos(pitch)*sin(roll) |
| Pitch | about Y | bow up | R[:,0] = (cy*cp, sy*cp, -sp); positive pitch gives negative z |
| Yaw | about Z | bow to starboard, clockwise from North | R[:,0] is North at yaw 0, East at yaw +90 deg |
Reading the signs off compute_rotation_matrix is the reliable check: column
i of R is body axis i expressed in NED, and NED z is positive down, so
a negative z component means the axis is pointing up.
How the codebase uses them
- Yaw is heading.
platform.pybuildsheading_arrand adds the yaw perturbation onto it, so there is no separate crab angle: yaw relative to track shows up as heading versus velocity direction. - The simulator perturbs five of the six.
_PERTURB_AXESinsimulator/scenes/schema.pyis('sway', 'heave', 'roll', 'pitch', 'yaw'), each an independent sinusoid with amplitude and period; amplitude 0 disables the axis. Positions are metres, angles are radians. There is no surge perturbation: along-track speed is constant, so along-track velocity error is not simulated by this path. - Surge exists on the estimation side.
filters.pyandmicronav_solver.pycarry a full body-frame velocity triplet and project it to world Z asR[2,0]*surge + R[2,1]*sway + R[2,2]*heave, so the micronav solver estimates surge even though nothing perturbs it. - Circle trajectories redefine sway geometrically: applied radially, outward positive, not along a fixed starboard normal.
Why each matters for the image
Heave couples straight into range and altitude, walking the seafloor return in fast time. Sway is the classic cross-track micronav term, entering as an along-aperture phase error. Yaw relative to track changes which along-track samples the DPCA overlap actually matches. Roll steers the elevation pattern, moving the ensonified grazing band and able to push the elevation null onto the seafloor. Pitch mostly tilts the along-track beam.
Any of these perturbations puts you outside FW's validity (stop-and-hop plus a straight trajectory) unless the motion resampler path is involved.
Derived attitude for spline_track
simulator/track.py derives the commanded attitude from the per-ping NED
velocity (v_n, v_e, v_d), then passes each axis through a first-order
vehicle response:
| Command | Formula | Sense |
|---|---|---|
| yaw | atan2(v_e, v_n) (unwrapped) |
clockwise from North, matches R[:,0] |
| pitch | atan2(-v_d, hypot(v_n, v_e)) |
climbing gives positive (bow up) |
| roll | bank_gain * atan(v_h * yaw_rate / g) |
a starboard (clockwise) turn banks starboard down |
simulator/tests/test_track.py checks the signs through
compute_rotation_matrix, never by assumption.
Along-track vs cross-track
For a due-north track (heading = 0), body-X aligns with earth-X:
along-track is NED x and cross-track is NED y. For any other heading the
BF kernel still uses NED x/y; the platform velocity is rotated from body
to earth (and back) to drive the trajectory integration.
The along-track image axis is always NED x in the output SLC:
along_track_grid = np.arange(x_min, x_max, pixel_spacing).
Slant vs ground range
Slant range R = sqrt(y_ground^2 + altitude^2).
Simulator run_simulation(max_range=...) is SLANT range. Humans think
in ground range. Always convert:
slant_max = np.sqrt(ground_range_max**2 + altitude**2) * 1.1
run_simulation(..., max_range=slant_max)
The * 1.1 safety factor is required - without it the pulse tail at far
range falls off the recorded buffer and the far edge of the scene is
silently clipped.
TDBP output is ground range (pixel grid is flat NED x-y). The omega-k
output is slant range natively; SlcImage.to_ground_range(altitude)
converts it via band-limited complex interp (real/imag independent, 8x FFT
upsample + linear).
Heightfield elevation sign convention (FOOTGUN)
The heightfield grid stores positive-up elevations (h[i,j] is height
above nominal seafloor; h = 0 is the flat reference). This is the
natural convention for visualising a terrain map.
Scene.heightfield.object_scatterers stores tuples
(x, y, z, reflectivity) where z is NED (negative-up): a rock
sitting at height h above the seafloor has z = -h. Every code path
that appends to object_scatterers does z_ned = -height (see
heightfield.py lines 408, 424, 503, 658 and the docstring at line 451-452).
Why the mismatch: the grid is a rendering-friendly DEM; the scatterer list feeds the GPU echo kernel whose ray math is all NED. Avoiding one more flip inside the kernel is worth the documentation cost.
How to avoid the footgun: when you read hf.array the seafloor is
positive-up; when you read hf.object_scatterers[i][2] it is negative-up.
If you plot both on the same axis, flip one. Never compare
scatterer_z > h as a "rock above seafloor" test - it is the opposite:
scatterer_z < -h_local means the point is above.
Zone-grid orientation
Scene.set_bottom_zone_map(grid, ...) takes a row-major
list[list[str]]. Rows index along-track (X); columns index
cross-track (Y). Row 0 is low-x. Under the port-image convention
(below) that puts row 0 at the bottom of the displayed scene (the start
of the track).
Internally Heightfield.bottom_type_grid[xi, yi] - axis 0 is along-track,
axis 1 is cross-track - matches.
Starboard layout (was "port-image layout" until 2026-09-05)
imaging.save_slc_port_image and save_slc_port_image_labeled both write
a canonical orientation shared by TDBP, omega-k, and any future
beamformer so images compose without per-method flips. It is the
STARBOARD picture: a north-heading vehicle with a starboard array images
east of the track, which is range to the right on a north-up page (a port
array would image west, i.e. range to the LEFT). The old name was a
misnomer borrowed from the sidescan port-channel display; the saver does
not look at sonar_side, and port scenes are rendered in this same
layout for now (decision deferred 2026-09-05). YAML beamform.saver
accepts starboard (default) and north_up; port is a silent alias.
+-------------------+
| | <- along_track[-1] (end of track)
| TRACK on LEFT |
| edge, runs |
| bottom -> top | <- range increases L -> R
| |
| | <- along_track[0] (start of track)
+-------------------+
- Image row axis = along-track; row 0 (bottom) =
along_track[0](track start). - Image column axis = range; column 0 (left) = near range.
This matches the raster used in save_log_mag_image (which also flips
the along-track axis with log_mag_norm[::-1, :] so increasing-x is
bottom-to-top).
GUI viewport (pyqtgraph) convention
The 3D viewport in gui/viewport/viewport_3d.py and the
MeshInspectorDialog use pyqtgraph.opengl.GLViewWidget, which is a
Z-up world (the camera's elevation/azimuth angles are defined about
+Z up). NED is Z-down. The mismatch is reconciled by negating Z when
building viewer geometry:
world_Z = -scene_Z (NED → viewer)
See gui/viewport/viewport_3d_builders.py:12 for the canonical comment.
Practical consequences:
- The seafloor renders below the camera and the platform renders above,
even though in NED both have
z >= 0andz = -altituderespectively. - Any axis triad you draw must point the +Z arrow in the viewer's −Z
direction (downward on screen) so that a "+Z Down (m)" label is
visually consistent with NED. The mesh inspector
(
gui/widgets/mesh_inspector_dialog.py:_add_axes_with_labels) does this: copy that pattern when adding new annotations. - OBJ mesh assets under
models/are imported Z-up (perdocs/model-import.md), i.e. in the viewer's native frame, so they do not need the NED flip. The flip is only for scene-coordinate data (platform track, scatterer Z, heightfield elevation).
Summary of the Z sign rules
| Quantity | Convention |
|---|---|
pos_z in NED |
negative-up (platform at -altitude) |
altitude |
positive (= -pos_z) |
| Heightfield elevation grid | positive-up |
object_scatterers[i][2] |
negative-up (NED z = -height) |
| Ground-range image pixels | flat y (no Z component) |
Initial P_0 |
[0, 0, -altitude[0]] |
| GUI viewport world Z | up (NED → viewer applies world_Z = -scene_Z) |
models/*.obj mesh Z |
up (already in viewer frame, no flip needed) |