Choosing a Grid Cell Size for Multibeam Data
The cell size of a bathymetric grid is chosen in about four seconds, usually by picking a round number, and it determines more about the product than any later processing decision. Too fine and most cells contain a single sounding, so the grid is a scatter plot with interpolation between the dots and every statistic computed per cell is meaningless. Too coarse and the survey’s real resolution — paid for in vessel days — is averaged away and cannot be recovered. This page derives the number instead of picking it, within the DEM interpolation workflow.
Root cause: density is not uniform, and neither is the beam footprint
Two things vary across a multibeam survey and both bear directly on the cell size.
Sounding density varies with depth and across the swath. A multibeam sonar emits a fixed number of beams per ping regardless of depth, so the swath widens with depth and the same beams cover more ground: density falls roughly with the square of depth. Across a single swath, outer beams are more widely spaced than nadir beams for the same reason.
Beam footprint grows with depth and angle. Each beam illuminates an ellipse on the seabed whose size is set by the beam width and the slant range. A 1° × 1° system at 50 m nadir has a footprint under a metre; the same system at 500 m has one nearly nine metres across. A grid finer than the footprint is asking the data for a resolution the physics did not deliver.
import numpy as np
def beam_footprint_m(depth_m: float, beam_width_deg: float, angle_deg: float = 0.0) -> float:
"""Along-track footprint diameter of one beam, at nadir or off to the side."""
theta = np.radians(beam_width_deg)
slant = depth_m / np.cos(np.radians(angle_deg))
return 2.0 * slant * np.tan(theta / 2.0) / np.cos(np.radians(angle_deg))
for d in (20, 50, 200, 500):
print(f"{d:>4} m: nadir {beam_footprint_m(d, 1.0):5.2f} m "
f"60 deg {beam_footprint_m(d, 1.0, 60.0):6.2f} m")
# 20 m: nadir 0.35 m 60 deg 1.40 m
# 50 m: nadir 0.87 m 60 deg 3.49 m
# 200 m: nadir 3.49 m 60 deg 13.96 m
# 500 m: nadir 8.73 m 60 deg 34.91 m
A survey spanning 20 m to 500 m has a physical resolution limit varying by a factor of twenty-five across its own extent. No single cell size is correct for all of it, which is the fact that a variable-resolution grid exists to handle and a fixed grid has to compromise around.
Step-by-step derivation with production code
1. Measure the density you actually have
The nominal figure from the acquisition log is an average over the whole survey and is wrong nearly everywhere. Measure it:
import logging
from dataclasses import dataclass
import numpy as np
from scipy.spatial import cKDTree
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class DensityStats:
per_m2_p10: float # sparse tail — the depth and swath-edge cells
per_m2_median: float
per_m2_p90: float
def sounding_density(xy: np.ndarray, *, sample: int = 20_000, k: int = 9) -> DensityStats:
"""Local sounding density from k-nearest-neighbour spacing, in soundings/m²."""
if xy.ndim != 2 or xy.shape[1] != 2:
raise ValueError("xy must be an (n, 2) array of projected coordinates")
tree = cKDTree(xy)
rng = np.random.default_rng(0) # deterministic sample
idx = rng.choice(len(xy), size=min(sample, len(xy)), replace=False)
dist, _ = tree.query(xy[idx], k=k)
# k points inside a disc of radius r_k gives density k / (pi r_k^2).
r_k = dist[:, -1]
dens = k / (np.pi * np.maximum(r_k, 1e-6) ** 2)
stats = DensityStats(*(float(np.percentile(dens, p)) for p in (10, 50, 90)))
logger.info("density soundings/m²: p10 %.2f, median %.2f, p90 %.2f",
stats.per_m2_p10, stats.per_m2_median, stats.per_m2_p90)
return stats
Using the tenth percentile rather than the median for the cell-size decision is the choice that matters. Sizing on the median produces a grid that is well populated over half the survey and empty over the sparse parts, which are the outer beams and the deep water — the areas most likely to be the reason the survey was commissioned.
2. Turn a soundings-per-cell target into a cell size
MIN_SOUNDINGS_PER_CELL = 5 # below this, a per-cell median is not a statistic
def cell_size_from_density(dens_per_m2: float, target: int = MIN_SOUNDINGS_PER_CELL) -> float:
"""Smallest cell that still holds `target` soundings at this density."""
if dens_per_m2 <= 0:
raise ValueError("density must be positive")
return float(np.sqrt(target / dens_per_m2))
Five is a defensible default and not a law. It is the point below which a per-cell median stops being robust to a single outlier, which is the whole reason the gridder computes a median rather than a mean. A survey feeding a navigation product might want more; one feeding a regional geomorphology map can accept fewer.
3. Apply the physical and standards floors
def choose_cell_size(
xy: np.ndarray, depth_m: np.ndarray, *, beam_width_deg: float = 1.0,
target: int = MIN_SOUNDINGS_PER_CELL,
) -> float:
"""Cell size satisfying density, beam footprint and a rounded reporting step."""
dens = sounding_density(xy)
from_density = cell_size_from_density(dens.per_m2_p10, target)
deepest = float(np.nanpercentile(np.abs(depth_m), 95))
from_physics = beam_footprint_m(deepest, beam_width_deg)
chosen = max(from_density, from_physics)
# Round up to a tidy step so adjacent surveys share a lattice.
steps = np.array([0.25, 0.5, 1, 2, 5, 10, 25, 50])
chosen = float(steps[np.searchsorted(steps, chosen)])
logger.info("cell size: density wants >= %.2f m, physics wants >= %.2f m, using %.2f m",
from_density, from_physics, chosen)
return chosen
Taking the maximum rather than a compromise is the correct operation: both are lower bounds, and violating either produces a grid making a claim the data does not support. Rounding up to a tidy step is not cosmetic — adjacent surveys gridded at 1.0 m and 1.13 m cannot be mosaicked without resampling one of them.
4. Where depth varies widely, stop using one number
A survey from 20 m to 500 m has no single defensible cell size. The standard answer is a variable-resolution grid: nested tiles at 0.5 m inshore, 2 m mid-shelf, 8 m deep, each region gridded at a size its own data supports. BAG (Bathymetric Attributed Grid) supports this natively through refinement grids; a COG does not, and the usual COG-based approach is one file per resolution band with a coverage index.
The cost is that every consumer must handle multiple resolutions. The benefit is that no part of the product claims a resolution its data cannot support, and the shallow part is not throttled to the deep part’s limit. For a survey spanning one order of magnitude in depth, a single grid throws away roughly three quarters of the shallow resolution or fabricates most of the deep detail — there is no setting that does neither.
5. Publish the count, not just the depth
Every argument above rests on soundings per cell, and that number is already computed by the gridder on its way to producing a depth. Writing it out costs one integer band and answers, per cell, the question the cell-size analysis answered only on average.
def grid_with_counts(xy, z, transform, shape):
"""Median depth and sounding count per cell, in one pass."""
inv = ~transform
col, row = inv * (xy[:, 0], xy[:, 1])
r, c = np.floor(row).astype("int64"), np.floor(col).astype("int64")
inside = (r >= 0) & (r < shape[0]) & (c >= 0) & (c < shape[1])
flat = r[inside] * shape[1] + c[inside]
counts = np.bincount(flat, minlength=shape[0] * shape[1]).reshape(shape)
order = np.argsort(flat, kind="stable")
# Median per cell via the sorted-group midpoint; empty cells stay NaN.
depth = np.full(shape[0] * shape[1], np.nan)
starts = np.searchsorted(flat[order], np.arange(shape[0] * shape[1]))
ends = np.searchsorted(flat[order], np.arange(shape[0] * shape[1]), side="right")
zs = z[inside][order]
for cell in np.flatnonzero(counts.ravel()):
depth[cell] = np.median(zs[starts[cell]:ends[cell]])
logger.info("gridded %d soundings into %d populated cells",
int(inside.sum()), int((counts > 0).sum()))
return depth.reshape(shape), counts
A consumer who receives both bands can compute their own confidence mask, exclude thin cells from a volume calculation, and see immediately where the survey ran out of data — none of which is possible from a depth band alone. It is the same argument as the uncertainty layer in the parent section: the number that qualifies the measurement is nearly free at the point it is produced and unrecoverable afterwards.
Verification and acceptance test
import pytest
def test_grid_is_populated_where_it_claims_to_be(counts, cell_size):
"""Most cells inside the survey footprint must hold enough soundings."""
inside = counts > 0
thin = (counts[inside] < MIN_SOUNDINGS_PER_CELL).mean()
assert thin < 0.20, (
f"{thin:.0%} of populated cells hold fewer than {MIN_SOUNDINGS_PER_CELL} "
f"soundings at {cell_size} m — the grid is finer than the data")
def test_cell_size_is_not_below_the_beam_footprint(cell_size, depth_m):
deepest = float(np.nanpercentile(np.abs(depth_m), 95))
assert cell_size >= beam_footprint_m(deepest, 1.0) * 0.99, (
"cell size is below the beam footprint at the 95th-percentile depth")
Publish the per-cell sounding count as a companion band whether or not these tests are wired in. It is one integer per cell, it is the direct evidence for both assertions, and it lets a consumer decide for themselves which parts of the grid they trust.
Edge cases and gotchas
- Overlapping survey lines inflate density locally. Where two lines overlap, density doubles and the derived cell size shrinks. If the overlap is a small fraction of the survey, use the tenth percentile as above and it is handled; if the whole survey is 100% overlapped by design, measure density on one line rather than on the merged cloud.
- A gridder’s “cell size” and a COG’s are not always the same thing. Some tools take a cell size in the source CRS and others in the output CRS. On a geographic grid a “0.0001 degree” cell is 11 m east-west at the equator and 8 m at 45° — and rectangular, not square.
- Rounding up hides a marginal decision. When the derived size is 1.04 m and the tidy step is 2 m, the grid is twice as coarse as the data supports. Log both numbers so the gap is visible; where it is large, the nested-resolution route is the honest answer.
Related
- Handling Data Gaps in Sparse Bathymetry — what to do about the cells this analysis shows are empty
- Kriging vs IDW for Bathymetry Interpolation — the estimator that runs at the cell size chosen here
- DEM Interpolation Techniques for Seafloor Mapping — the parent workflow