Documentation

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.py builds heading_arr and 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_AXES in simulator/scenes/schema.py is ('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.py and micronav_solver.py carry a full body-frame velocity triplet and project it to world Z as R[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 >= 0 and z = -altitude respectively.
  • 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 (per docs/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)