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 T(t)T(t) from the raw depth so the sounding refers to the chart datum. The reduced depth is

zreduced(t)=zraw(t)(T(t)+δzone+δlat)z_{\text{reduced}}(t) = z_{\text{raw}}(t) - \big(T(t) + \delta_{\text{zone}} + \delta_{\text{lat}}\big)

Three error sources feed the bracket. A static tide — one number applied to a whole line instead of the real curve T(t)T(t) — leaves a residual equal to the tide’s change over the line’s duration. A wrong zoning correction δzone\delta_{\text{zone}} applies a co-tidal zone’s phase and range that does not match the survey location, biasing the line. And latency δlat\delta_{\text{lat}} 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,

rij=z^i(x)z^j(x),r_{ij} = \hat z_i(\mathbf{x}) - \hat z_j(\mathbf{x}),

is zero for a perfectly reduced survey over stable seafloor. A systematic non-zero rijr_{ij} that is constant across each crossing pair and tracks acquisition time is the tide fingerprint.

Crossover Offset Between Survey Lines Left panel: a plan view of four parallel main survey lines crossed by two perpendicular tie lines, with circles marking the crossover intersection points where depths from two lines can be compared. Right panel: a vertical cross-section at one crossover showing line A and line B over the same genuinely flat seafloor, with line B sitting a constant vertical distance below line A across the whole overlap, labelled as a tide offset because the gap does not depend on position and does not vanish at nadir. Line network (plan view) circles = crossover points (compare depths) At one crossover (side view) depth across overlap line A line B constant tide offset gap is uniform across the overlap → tidal, not slope or refraction

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 rijr_{ij} constrains the difference cicjc_i - c_j. 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.

Up: Removing Bathymetric Artifacts and Noise