Building a Vessel Density Grid With GeoPandas
This is the end-to-end implementation of the product the vessel density topic specifies: a raster whose cell values are vessel-hours, in an equal-area projection, normalised for reception coverage, and carrying enough metadata to be compared with next month’s. Every step below exists because its absence produces a raster that looks right and means something else.
Root cause: the obvious implementation counts transponders
gdf.geometry from an AIS feed is a set of points, and rasterising points counts messages. As the parent topic sets out, message rate varies by a factor of ninety with vessel state, so the resulting map is a picture of reporting behaviour with traffic as a confounder. Everything here follows from replacing points with segments.
import geopandas as gpd
# The one-liner that produces the wrong product:
counts = gpd.sjoin(fixes, grid, predicate="within").groupby("index_right").size()
That is three lines shorter than the correct version and answers a different question.
Step-by-step implementation
1. Equal-area CRS, asserted rather than assumed
import logging
import geopandas as gpd
import numpy as np
import pandas as pd
from shapely.geometry import LineString
logger = logging.getLogger(__name__)
EQUAL_AREA = "EPSG:3035" # Europe; use 6933 or a local Albers elsewhere
def to_equal_area(fixes: gpd.GeoDataFrame, crs: str = EQUAL_AREA) -> gpd.GeoDataFrame:
"""Reproject and confirm the target really is equal-area."""
from pyproj import CRS
target = CRS.from_user_input(crs)
if target.is_geographic:
raise ValueError(f"{crs} is geographic; cell areas would vary across the grid")
name = (target.coordinate_operation.method_name or "").lower()
if not any(k in name for k in ("equal area", "albers", "lambert azimuthal")):
logger.warning("%s may not be equal-area (%s) — per-cell density will be biased",
crs, target.coordinate_operation.method_name)
return fixes.to_crs(target)
The warning rather than a hard failure is deliberate: some legitimate national grids are equal-area under names the check does not recognise, and a false failure would push people to remove the check entirely.
2. Segments, filtered
MAX_GAP_S = 900.0
MAX_SPEED_MS = 20.6
def build_segments(fixes: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
"""Consecutive-fix segments carrying duration and length."""
f = fixes.sort_values(["mmsi", "timestamp"]).reset_index(drop=True)
same = f["mmsi"].to_numpy()[1:] == f["mmsi"].to_numpy()[:-1]
dt = np.diff(f["timestamp"].astype("int64").to_numpy()) / 1e9
x, y = f.geometry.x.to_numpy(), f.geometry.y.to_numpy()
dist = np.hypot(np.diff(x), np.diff(y))
observed = f.get("source", pd.Series("observed", index=f.index)).to_numpy()
both_observed = (observed[1:] == "observed") & (observed[:-1] == "observed")
keep = same & both_observed & (dt > 0) & (dt <= MAX_GAP_S) & \
(dist / np.maximum(dt, 1e-9) <= MAX_SPEED_MS)
logger.info("kept %d of %d candidate segments", int(keep.sum()), int(same.sum()))
idx = np.flatnonzero(keep)
return gpd.GeoDataFrame(
{"mmsi": f["mmsi"].to_numpy()[idx], "duration_s": dt[idx], "length_m": dist[idx]},
geometry=[LineString([(x[i], y[i]), (x[i + 1], y[i + 1])]) for i in idx],
crs=fixes.crs,
)
The both_observed condition is the one most implementations omit. If the gap-filling stage inserted interpolated positions, a segment between two of them is a straight line through water nobody observed, and crediting it manufactures traffic exactly where the evidence is weakest.
3. Accumulate by densified intersection
def accumulate_vessel_seconds(segments: gpd.GeoDataFrame, transform, shape,
*, step_m: float | None = None) -> np.ndarray:
"""Vessel-seconds per cell, by densifying each segment to sub-cell spacing."""
cell = abs(transform.a)
step = step_m or cell / 3.0
grid = np.zeros(shape, dtype="float64")
inv = ~transform
for geom, dur, length in zip(segments.geometry, segments.duration_s, segments.length_m):
n = max(int(np.ceil(length / step)), 1)
share = dur / n
for k in range(n):
px, py = geom.interpolate((k + 0.5) / n, normalized=True).coords[0]
col, row = inv * (px, py)
r, c = int(row), int(col)
if 0 <= r < shape[0] and 0 <= c < shape[1]:
grid[r, c] += share
logger.info("accumulated %.1f vessel-hours over %d populated cell(s)",
grid.sum() / 3600.0, int((grid > 0).sum()))
return grid
Setting step to a third of the cell size is the balance point: finer wastes time without changing the result, coarser reintroduces the dotted-line artefact at exactly the scale the grid resolves.
4. Normalise, then write the units into the file
import rasterio
def write_density_cog(grid_s: np.ndarray, coverage: np.ndarray, transform, crs,
path: str, *, period_start: str, period_end: str,
vessel_filter: str, min_coverage: float = 0.3) -> None:
"""Coverage-normalised vessel-hours, as a COG that states what it is."""
if grid_s.shape != coverage.shape:
raise ValueError("grid and coverage layer must have the same shape")
with np.errstate(invalid="ignore", divide="ignore"):
hours = (grid_s / 3600.0) / coverage
hours[coverage < min_coverage] = np.nan
profile = {
"driver": "COG", "dtype": "float32", "nodata": np.nan, "count": 2,
"height": hours.shape[0], "width": hours.shape[1],
"transform": transform, "crs": crs, "compress": "DEFLATE", "blocksize": 512,
}
with rasterio.open(path, "w", **profile) as dst:
dst.write(hours.astype("float32"), 1)
dst.write(coverage.astype("float32"), 2)
dst.set_band_description(1, "vessel_hours_per_cell")
dst.set_band_description(2, "reception_coverage_fraction")
dst.update_tags(
quantity="vessel-hours", units="h", period_start=period_start,
period_end=period_end, vessel_filter=vessel_filter,
coverage_normalised="true", min_coverage_published=str(min_coverage),
)
logger.info("wrote %s (%s to %s, filter %s)", path, period_start, period_end, vessel_filter)
Two bands rather than one, and six tags. The coverage band lets a consumer re-derive the raw counts or apply a stricter mask; the tags are what make two months comparable. A file without them is a picture.
Verification and acceptance test
import pytest
def test_the_raster_conserves_the_observed_time(segments, grid_s):
total_h = float(segments["duration_s"].sum()) / 3600.0
grid_h = float(grid_s.sum()) / 3600.0
assert abs(grid_h - total_h) / total_h < 0.01, (
f"grid holds {grid_h:.0f} vessel-hours but segments total {total_h:.0f} — "
"segments are being dropped at the grid edge or credited twice")
def test_cells_are_equal_area(transform, crs):
from pyproj import CRS
assert not CRS.from_user_input(crs).is_geographic
assert abs(abs(transform.a) - abs(transform.e)) < 1e-6, "cells are not square"
def test_the_product_states_what_it_is(path):
import rasterio
with rasterio.open(path) as src:
for tag in ("quantity", "units", "period_start", "vessel_filter"):
assert src.tags().get(tag), f"missing required tag: {tag}"
The conservation test is the one that catches almost every implementation bug at once: a densification error, an off-by-one in the cell index, a segment credited to two cells, or a filter applied after accumulation instead of before.
Scaling the accumulation beyond one machine
The loop above is correct and single-threaded, and a month of a regional feed is tens of millions of segments. Three changes take it to production volume without changing what it computes.
Vectorise the densification. Instead of interpolating point by point, generate all sample points for all segments at once and accumulate with np.bincount on the flattened cell index. The result is identical and roughly two orders of magnitude faster:
def accumulate_vectorised(segments: gpd.GeoDataFrame, transform, shape,
*, step_m: float) -> np.ndarray:
coords = np.array([g.coords for g in segments.geometry]) # (n, 2, 2)
n_steps = np.maximum(np.ceil(segments.length_m / step_m).astype("int64"), 1)
share = (segments.duration_s / n_steps).to_numpy()
reps = np.repeat(np.arange(len(segments)), n_steps)
within = np.concatenate([(np.arange(k) + 0.5) / k for k in n_steps])
p0, p1 = coords[reps, 0, :], coords[reps, 1, :]
pts = p0 + (p1 - p0) * within[:, None]
inv = ~transform
col, row = inv * (pts[:, 0], pts[:, 1])
r, c = np.floor(row).astype("int64"), np.floor(col).astype("int64")
ok = (r >= 0) & (r < shape[0]) & (c >= 0) & (c < shape[1])
flat = np.bincount((r[ok] * shape[1] + c[ok]), weights=share[reps][ok],
minlength=shape[0] * shape[1])
return flat.reshape(shape)
Tile the domain. Accumulation is a sum, so tiles are independent and their partial grids add. Split by geographic tile, process in parallel, and sum — which also bounds memory, because no worker holds more than one tile’s segments.
Partition the input by time, not by vessel. Segments are built from consecutive fixes of one vessel, so a split by MMSI keeps every segment intact while a split by time cuts tracks at the boundary and loses one segment per vessel per boundary. For a month-long aggregation that loss is negligible; for a daily product it is a systematic under-count at midnight, which shows up as a faint daily periodicity in any time-series built from the grids.
The conservation test in the previous section is what makes all three safe to adopt: if a refactor drops or double-counts segments, the vessel-hours total stops matching and the test fails before the product ships.
Edge cases and gotchas
- A segment longer than the grid. A vessel that reports either side of a fifteen-minute gap at 20 knots produces a 9 km segment. Densification handles it correctly and slowly; where such segments are common the gap threshold is too generous for the chosen cell size.
- Densification in a loop is the slow part. For production volumes, replace the Python loop with a vectorised interpolation over all segments at once, or accept the cost and run it per tile. The version above is written for clarity; profile before scaling it.
min_coverageis a publication decision, not a technical one. Masking below 0.3 hides the noisiest cells and also hides the fact that the survey extends there. Publishing the coverage band means the consumer can choose their own threshold, which is why both bands go in the file.
Related
- Vessel Density & Traffic Raster Generation — the parent topic, including why the quantity must be named
- Filtering Invalid AIS Positions and SOG Outliers — the filter that runs before segments are built
- Setting COG Overviews and Blocksize for S3 — publishing the raster this produces