Correcting Heave-Induced Ripple in Bathymetry
A gridded surface shows a regular corrugation running across the survey lines, wavelength a few tens of metres, amplitude ten or twenty centimetres, remarkably even. It looks like a field of sand waves and it is very often the vessel going up and down. Distinguishing the two matters because they call for opposite responses: a bedform is the product, and a motion artefact is a defect that should be corrected at its source rather than filtered out of the surface. This page is the discrimination and the fix, within the artifact removal workflow.
Root cause: heave is compensated, and compensation is never exact
The motion sensor measures heave, roll and pitch, and the acquisition software applies them to place each beam. Three things break that chain, and all three produce a periodic residual at the wave period:
Lever-arm error. The motion sensor and the transducer are at different places on the vessel. Roll and pitch turn that offset into a vertical displacement, and an error of a few centimetres in the measured offset becomes a periodic depth error scaled by the vessel’s rotation.
Latency. Motion samples and ping times are stamped by different clocks. A constant offset of even 20 ms between them applies yesterday’s heave to today’s ping, and at a 6-second swell period with 1 m amplitude that is roughly 2 cm of error — worse in shorter, steeper seas.
Heave filter settling. A heave sensor high-pass filters acceleration to recover displacement, and that filter has a settling time and a period below which it under-reports. In a long swell the reported heave is smaller than the real one, and the residual is a scaled copy of the wave.
import numpy as np
from scipy import signal
def along_track_spectrum(residual: np.ndarray, spacing_m: float, speed_kn: float):
"""Peak period, in seconds, of a residual sampled along the track."""
if spacing_m <= 0 or speed_kn <= 0:
raise ValueError("spacing and speed must be positive")
f, pxx = signal.welch(residual - residual.mean(), fs=1.0 / spacing_m,
nperseg=min(1024, len(residual)))
peak_cycles_per_m = f[np.argmax(pxx)]
speed_ms = speed_kn * 0.514444
return float(1.0 / (peak_cycles_per_m * speed_ms)) if peak_cycles_per_m else float("inf")
Convert the spatial wavelength to a temporal period using the vessel speed and compare it against the swell period logged during acquisition. A match within a second or so is the single strongest piece of evidence available, and it takes one function call.
Step-by-step fix with production code
1. Confirm the phase relationship across the swath
Heave moves the whole vessel, so it displaces every beam of a ping by the same amount. A bedform does not care where the beams are. Comparing the residual at nadir against the residual at the swath edge separates them cleanly:
import logging
import numpy as np
import pandas as pd
logger = logging.getLogger(__name__)
def swath_phase_coherence(df: pd.DataFrame, *, nadir_deg: float = 10.0) -> float:
"""Correlation between the nadir residual and the outer-beam residual, per ping.
Near 1.0 means the whole swath moved together — a vessel-motion signature.
Near 0 means the residual is a property of the seabed under each beam.
"""
inner = (df["angle_deg"].abs() <= nadir_deg)
outer = (df["angle_deg"].abs() >= 45.0)
if not inner.any() or not outer.any():
raise ValueError("need both nadir and outer-beam soundings to compare")
a = df[inner].groupby("ping_id")["residual_m"].mean()
b = df[outer].groupby("ping_id")["residual_m"].mean()
joined = pd.concat([a, b], axis=1, join="inner").dropna()
if len(joined) < 50:
raise ValueError(f"only {len(joined)} pings have both — too few to correlate")
r = float(joined.corr().iloc[0, 1])
logger.info("nadir/outer residual correlation over %d pings: %.3f", len(joined), r)
return r
A correlation above about 0.7 is a motion signature. Below about 0.3 the residual is seabed, and the next place to look is the sound-velocity profile rather than the motion sensor.
2. Fix it at the source: lever arm and latency
Once identified, the correction belongs in the reprocess, not in the grid. Both parameters can be solved for from the data itself, because both produce residuals with a distinctive dependence on vessel attitude:
def solve_latency_s(df: pd.DataFrame, candidates=np.arange(-0.100, 0.101, 0.005)) -> float:
"""Time offset between the motion series and the ping series, by minimising
the variance of the corrected residual."""
best, best_var = 0.0, float("inf")
for lag in candidates:
heave = np.interp(df["ping_time_s"].to_numpy() + lag,
df["motion_time_s"].to_numpy(), df["heave_m"].to_numpy())
var = float(np.var(df["z_uncorrected"].to_numpy() + heave))
if var < best_var:
best, best_var = float(lag), var
if abs(best) >= candidates.max() - 1e-9:
raise ValueError(
f"best latency {best:+.3f} s is at the edge of the search range — "
"widen it, or the residual is not a timing problem")
logger.info("solved latency %+.3f s (residual variance %.5f m²)", best, best_var)
return best
The edge-of-range guard is the part worth keeping. An optimiser that returns its own boundary has not found a minimum; it has run out of room, and accepting the value produces a “correction” that makes the next survey worse.
3. Filtering is the last resort, and it is recorded
If the raw motion data is unavailable — a legacy dataset, a third-party deliverable — the ripple can be filtered out along-track with a notch at the known wavelength. This is a cosmetic repair and it must be labelled as one, because it also removes any real bedform at that wavelength, and bedforms at swell wavelength are exactly the ones that a swell-dominated shelf produces.
def notch_along_track(z: np.ndarray, spacing_m: float, wavelength_m: float,
q: float = 8.0) -> np.ndarray:
"""Suppress one along-track wavelength. Removes real bedforms at it too."""
from scipy.signal import iirnotch, filtfilt
w0 = (1.0 / wavelength_m) / (0.5 / spacing_m)
if not 0 < w0 < 1:
raise ValueError(f"wavelength {wavelength_m} m is outside the Nyquist range")
b, a = iirnotch(w0, q)
logger.warning("notching %.1f m along-track: this is cosmetic, not a correction — "
"record it in the product metadata", wavelength_m)
return filtfilt(b, a, z, axis=0)
The logger.warning in a function that works correctly is deliberate. The risk here is not that the code fails, it is that a cosmetic repair is mistaken for a correction three months later, and a warning in the run log is the cheapest available defence.
Verification and acceptance test
import pytest
def test_ripple_is_gone_and_the_bedform_is_not(before, after, spacing_m, speed_kn):
p_before = along_track_spectrum(before, spacing_m, speed_kn)
p_after = along_track_spectrum(after, spacing_m, speed_kn)
assert abs(p_before - 6.1) < 1.0, "the input did not contain the expected ripple"
assert abs(p_after - 6.1) > 1.5, "the ripple period still dominates after correction"
# Energy at the bedform wavelength must survive the correction.
from scipy import signal
def energy_at(z, lam):
f, pxx = signal.welch(z - z.mean(), fs=1.0 / spacing_m, nperseg=1024)
return float(np.interp(1.0 / lam, f, pxx))
assert energy_at(after, 28.0) > 0.6 * energy_at(before, 28.0), (
"the correction removed the sand-wave energy as well as the ripple")
The second assertion is what separates a correction from a smoothing. A filter that removes the ripple will fail it, which is the intended outcome.
What the ripple costs downstream
A twenty-centimetre corrugation is small against most depth budgets and large against almost every derivative computed from the surface, which is where the cost actually lands.
Slope and curvature. A derivative amplifies short-wavelength content in proportion to how short it is. A 20 cm ripple at 20 m wavelength contributes a slope of roughly 1.1°, which on a shelf whose real slope is 0.3° means the slope raster is dominated by the artefact. Every habitat model keyed on slope inherits it.
Volume differences. Two surveys with ripples at different phases differ by up to twice the amplitude wherever the phases oppose. Over a dredge area of a few hectares that is thousands of cubic metres of apparent change, and it appears as a plausible spatial pattern rather than as noise.
Feature detection. Any detector tuned for bedforms finds the ripple everywhere, at high confidence, because it is more regular than a real bedform field. A pipeline that counts sand waves will report a suspiciously uniform population.
The general shape of this is that the artefact is worst in exactly the products that motivated the survey. A raw depth grid tolerates it; a change-detection or habitat product does not, and those are usually the deliverable.
Edge cases and gotchas
- Ripple that reverses sign with heading is almost always latency rather than lever arm, because a timing offset flips its effect when the vessel turns around and a geometric offset does not. Splitting the residual by heading before solving is the fastest way to tell them apart.
- A “ripple” at exactly the ping interval is not heave at all; it is a per-ping bias, usually a bottom-detection threshold interacting with a changing seabed. It has no wave period and will not correlate with the swell log.
- Real bedforms at swell wavelength exist and are common. On a swell-dominated shelf the wave field and the bedform field share a scale, because one built the other. That is precisely why the cross-line test matters: it is the only one of the three that cannot be confounded by a coincidence of scale.
Related
- Automated Spike Removal in Sonar Datasets — the isolated-outlier case, and why a robust statistic is needed there too
- Fixing Tide-Induced Offsets Between Survey Lines — the other periodic, line-correlated artefact
- Removing Bathymetric Artifacts & Noise — the artefact taxonomy and which coordinate each signature shows up in