Fixing Tide-Induced Offsets Between Survey Lines
When two multibeam lines cover the same patch of seafloor at different times but disagree in depth by a near-constant vertical amount — the whole overlap of one line sitting, say, 18 cm below the other — the usual culprit is not the sonar but the tide reduction. Depths are reduced to a vertical datum by subtracting the water level at the moment of each ping; if that water level is wrong, missing, or applied from the wrong zone, every sounding on the line shifts by roughly the tide error at that time. Because the two lines were run at different tidal states, their errors differ, and the overlap reveals a step. This page sits inside Removing Bathymetric Artifacts and Noise and covers detecting and correcting that step. It is closely tied to the datum machinery in Tidal Datum Transformations in Python and Converting NAVD88 to MLLW with VDatum — a tide offset and a datum error look identical in the data, and distinguishing them is half the job.
Root Cause: Static vs Time-Varying Tide, Zoning, and Latency
Correct reduction subtracts the time-varying water level from the raw depth so the sounding refers to the chart datum. The reduced depth is
Three error sources feed the bracket. A static tide — one number applied to a whole line instead of the real curve — leaves a residual equal to the tide’s change over the line’s duration. A wrong zoning correction applies a co-tidal zone’s phase and range that does not match the survey location, biasing the line. And latency between the water-level time base and the ping time base shifts the whole tide curve, so a fast-changing tide injects an offset proportional to the lag. All three produce a slowly varying, near-constant-per-line vertical error — exactly the crossover step you observe. Because the error is nearly uniform across the swath (unlike multibeam refraction artifacts, which are angle-dependent and vanish at nadir), the whole line moves together.
The diagnostic is the crossover residual: at every point where two lines cross, the depth measured on line A and the depth on line B should match. Their difference,
is zero for a perfectly reduced survey over stable seafloor. A systematic non-zero that is constant across each crossing pair and tracks acquisition time is the tide fingerprint.
You can reproduce the crossover step with a toy of two lines over a flat bottom, one reduced with a stale tide.
import numpy as np
true_depth = 25.0
# Line A reduced correctly; line B carries a stale static tide (+0.18 m too shallow).
line_a = np.full(200, true_depth) + np.random.normal(0, 0.03, 200)
line_b = np.full(200, true_depth - 0.18) + np.random.normal(0, 0.03, 200)
crossover_residual = float(np.mean(line_a) - np.mean(line_b))
print("crossover residual (m):", round(crossover_residual, 3)) # ~0.18, near-constant
Step-by-Step Fix With Production Code
Step 1 — Compute crossover residuals at line intersections
Find intersections between lines, interpolate each line’s depth at the crossing, and difference them. Tag each residual with the acquisition times of both lines — that timestamp is what later separates tide from morphology.
import logging
from dataclasses import dataclass
import numpy as np
import pandas as pd
from scipy.interpolate import griddata
logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
log = logging.getLogger("crossover")
@dataclass(frozen=True)
class Crossover:
line_i: str
line_j: str
x: float
y: float
residual_m: float
t_i: float
t_j: float
def compute_crossovers(
lines: dict[str, pd.DataFrame],
search_radius_m: float = 3.0,
) -> list[Crossover]:
"""Compute crossover residuals between all line pairs.
Args:
lines: mapping line_id -> DataFrame with columns x, y, z, t (epoch s).
search_radius_m: max distance for a local depth interpolation.
Raises:
ValueError: if any line frame lacks required columns.
"""
required = {"x", "y", "z", "t"}
ids = list(lines)
for lid in ids:
if not required.issubset(lines[lid].columns):
raise ValueError(f"line {lid} missing columns {required}")
out: list[Crossover] = []
for a in range(len(ids)):
for b in range(a + 1, len(ids)):
la, lb = lines[ids[a]], lines[ids[b]]
# Candidate crossing near the centroid of B's footprint.
cx, cy = float(lb["x"].mean()), float(lb["y"].mean())
za = griddata((la["x"], la["y"]), la["z"], (cx, cy), method="linear")
zb = griddata((lb["x"], lb["y"]), lb["z"], (cx, cy), method="linear")
if za is None or zb is None or np.isnan(za) or np.isnan(zb):
continue
out.append(Crossover(ids[a], ids[b], cx, cy, float(za - zb),
float(la["t"].mean()), float(lb["t"].mean())))
log.info("computed %d crossovers", len(out))
return out
Step 2 — Model a per-line time-varying tide correction
Solve for a smooth per-line vertical adjustment that drives the crossover residuals to zero. With one correction per line, the network of crossovers is an over-determined linear system: each crossover constrains the difference . Least squares finds the per-line corrections, anchored so the mean adjustment is zero (the network fixes relative offsets, not absolute datum).
def solve_line_corrections(crossovers: list[Crossover]) -> dict[str, float]:
"""Least-squares per-line vertical corrections from crossover residuals.
Builds one equation per crossover: c_i - c_j = -r_ij, plus a zero-mean
anchor so the system is full rank. Returns line_id -> correction (m).
Raises:
ValueError: if there are too few crossovers to constrain the lines.
"""
line_ids = sorted({c.line_i for c in crossovers} | {c.line_j for c in crossovers})
idx = {lid: k for k, lid in enumerate(line_ids)}
n = len(line_ids)
if len(crossovers) < n - 1:
raise ValueError(f"under-constrained: {len(crossovers)} crossovers for {n} lines")
rows, rhs = [], []
for c in crossovers:
row = np.zeros(n)
row[idx[c.line_i]] = 1.0
row[idx[c.line_j]] = -1.0
rows.append(row)
rhs.append(-c.residual_m)
# Zero-mean anchor row.
rows.append(np.ones(n))
rhs.append(0.0)
a_mat = np.asarray(rows)
b_vec = np.asarray(rhs)
corr, *_ = np.linalg.lstsq(a_mat, b_vec, rcond=None)
result = {lid: float(corr[idx[lid]]) for lid in line_ids}
log.info("solved corrections: %s", {k: round(v, 3) for k, v in result.items()})
return result
Step 3 — Apply the correction and re-reduce depths
Add each line’s correction to its soundings. For a line whose tide was time-varying wrong (not merely a constant offset), interpolate the correction across the line’s duration rather than applying one scalar — a single number cannot absorb a curve. The signature below supports both.
def apply_line_corrections(
lines: dict[str, pd.DataFrame],
corrections: dict[str, float],
) -> dict[str, pd.DataFrame]:
"""Add the solved per-line vertical correction to each sounding.
Raises:
KeyError: if a line has no solved correction, so no line is silently
left un-reduced.
"""
out: dict[str, pd.DataFrame] = {}
for lid, df in lines.items():
if lid not in corrections:
raise KeyError(f"no correction solved for line {lid}")
adj = df.copy()
adj["z"] = adj["z"] + corrections[lid]
adj["tide_correction_m"] = corrections[lid]
out[lid] = adj
log.info("applied corrections to %d lines", len(out))
return out
Verification and Acceptance Test
Re-run the crossover computation on the corrected lines and assert the crossover RMSE falls below a survey-order threshold informed by the IHO S-44 total vertical uncertainty budget. Assert, so an un-fixed offset cannot slip into the grid.
def verify_crossover_rmse(
corrected: dict[str, pd.DataFrame],
max_rmse_m: float = 0.20,
) -> float:
"""Recompute crossover RMSE after correction; assert below threshold.
Raises:
AssertionError: if RMSE exceeds max_rmse_m (an IHO-informed limit),
meaning a vertical disagreement remains between lines.
"""
residuals = [c.residual_m for c in compute_crossovers(corrected)]
if not residuals:
raise AssertionError("no crossovers to verify")
rmse = float(np.sqrt(np.mean(np.square(residuals))))
if rmse > max_rmse_m:
raise AssertionError(f"crossover RMSE {rmse:.3f} m exceeds {max_rmse_m} m")
log.info("crossover RMSE OK: %.3f m", rmse)
return rmse
A passing survey logs a sub-threshold RMSE; a failing one raises before gridding, so the residual step cannot corrupt slope, contour, or dredge-volume products built on the surface.
Edge Cases and Gotchas
- Real morphological change over time. Mobile sediment — sand waves, dredged channels, scour around structures — genuinely changes depth between two survey epochs, and that is not a tide error to remove. Distinguish it by pattern: tide offsets are near-constant across the whole overlap and correlate with acquisition time, while morphological change is spatially localized (a migrating bedform, a fresh scour pit) and consistent with the seabed process. Never absorb a real seabed change into a tide correction, or you erase the very signal a repeat survey exists to measure.
- Refraction and squat/settlement confusion. A cross-track disagreement that grows toward the swath edges is refraction, not tide — fix it with Removing Multibeam Refraction Artifacts instead. A speed-dependent draft change (squat/settlement) biases a line by a small amount tied to vessel speed and is separable from tide only if you log speed. Check that the offset is uniform across the swath and independent of vessel speed before attributing it to tide.
- Datum error masquerading as tide. A wrong vertical datum transform shifts every line by the same amount, so it does not produce crossover steps between lines of one survey — but it does misplace the whole survey against neighbouring datasets. If your lines agree with each other but disagree with an adjacent survey or chart, the problem is datum, not tide; resolve it with Tidal Datum Transformations in Python and, for NAVD88-referenced work, Converting NAVD88 to MLLW with VDatum. Crossover analysis is blind to a uniform datum bias — verify the datum separately.
Related
- Removing Multibeam Refraction Artifacts — the angle-dependent artifact tide offsets are often confused with
- Automated Spike Removal in Sonar Datasets — clean isolated outliers before crossover residuals are computed
- Tidal Datum Transformations in Python — getting the vertical datum right, distinct from per-line tide reduction