Detecting Port Calls From AIS Tracks
A port call sounds like a geometric test: the vessel was inside the port polygon between these two times. In practice both halves are unreliable. A vessel swinging at anchor crosses any fence edge dozens of times a day; a berth visit and a wait at anchor are the same “inside the port” to a polygon and completely different to an operations team; and the AIS gap that so often coincides with a berth — because a ship alongside is behind cranes and buildings — sits precisely where the arrival timestamp is needed. This page builds a detector that handles all three, within the behaviour segmentation workflow.
Root cause: the fence and the dwell are both fuzzy
Two independent sources of ambiguity compound. The fence is a choice: a port authority boundary includes the approach channel and the anchorage; a berth polygon includes neither; a customs boundary is different again. A vessel “arrives” at three different times depending on which one is used, sometimes hours apart.
The dwell is a threshold on a noisy signal. Position error at a berth is a few metres, which is fine; position error at anchor with a 300 m swing radius is not error at all but real movement, and any fence edge inside that radius produces alternating inside/outside states at the reporting rate.
import pandas as pd
def naive_crossings(track: pd.DataFrame, fence) -> int:
"""How many times a track crosses a fence boundary, without hysteresis."""
inside = track.geometry.within(fence)
return int((inside != inside.shift()).sum())
# A vessel at anchor for 14 hours, swinging across a fence edge:
# naive_crossings(...) -> 212
Two hundred and twelve arrival-departure pairs from one anchoring is the default behaviour, and it is why a port-call detector needs state rather than a per-fix predicate.
Step-by-step detector with production code
1. A state machine with hysteresis
import logging
from dataclasses import dataclass
from enum import Enum
logger = logging.getLogger(__name__)
class State(str, Enum):
OUTSIDE = "outside"
ARRIVING = "arriving" # inside the inner fence, dwell not yet met
INSIDE = "inside"
LEAVING = "leaving" # outside the outer fence, dwell not yet met
@dataclass
class PortCall:
mmsi: int
arrival: pd.Timestamp
departure: pd.Timestamp | None = None
arrival_confident: bool = True
departure_confident: bool = True
@property
def duration_h(self) -> float | None:
if self.departure is None:
return None
return (self.departure - self.arrival).total_seconds() / 3600.0
def detect_calls(track: pd.DataFrame, inner, outer, *, min_dwell_h: float = 1.0,
max_gap_h: float = 2.0) -> list[PortCall]:
"""Port calls from a single vessel's track, with buffered fences."""
t = track.sort_values("timestamp")
state, pending, calls = State.OUTSIDE, None, []
prev_time = None
for _, row in t.iterrows():
gap_h = ((row.timestamp - prev_time).total_seconds() / 3600.0
if prev_time is not None else 0.0)
prev_time = row.timestamp
in_inner = row.geometry.within(inner)
out_outer = not row.geometry.within(outer)
if state in (State.OUTSIDE, State.LEAVING) and in_inner:
if state is State.LEAVING:
state = State.INSIDE # never actually left
continue
pending = PortCall(int(row.mmsi), row.timestamp,
arrival_confident=gap_h <= max_gap_h)
state = State.ARRIVING
elif state is State.ARRIVING:
if out_outer:
state, pending = State.OUTSIDE, None # a transit, not a call
elif (row.timestamp - pending.arrival).total_seconds() / 3600.0 >= min_dwell_h:
state = State.INSIDE
elif state is State.INSIDE and out_outer:
pending.departure = row.timestamp
pending.departure_confident = gap_h <= max_gap_h
calls.append(pending)
state, pending = State.LEAVING, None
logger.info("mmsi %s: %d call(s)", t["mmsi"].iloc[0], len(calls))
return calls
The arrival_confident flag is the concession to the AIS gap that so often coincides with a berth. If the last fix before the arrival was two hours earlier, the arrival timestamp is somewhere in that window and the event should not be used for a turnaround statistic without saying so.
2. Separate the berth from the anchorage
A port polygon that contains both makes “time in port” the sum of waiting and working, and those are the two numbers an operations team most wants apart:
def split_by_zone(call: PortCall, track: pd.DataFrame, berths, anchorages) -> dict[str, float]:
"""Hours of a call spent at a berth versus at anchor versus manoeuvring."""
inside = track[(track.timestamp >= call.arrival) & (track.timestamp <= call.departure)]
if inside.empty:
raise ValueError("no fixes inside the call window")
dt_h = inside.timestamp.diff().dt.total_seconds().fillna(0) / 3600.0
at_berth = inside.geometry.within(berths.union_all())
at_anchor = inside.geometry.within(anchorages.union_all())
return {
"berth_h": float(dt_h[at_berth].sum()),
"anchor_h": float(dt_h[at_anchor].sum()),
"manoeuvring_h": float(dt_h[~at_berth & ~at_anchor].sum()),
}
Reporting the three separately turns a single opaque “port time” into a decomposition an operations team can act on: a rising anchor time is a berth availability problem, a rising berth time is a cargo handling problem, and they have different owners.
3. Cross-check the reported destination, and expect it to disagree
The Class A static message carries a free-text destination, and it is entered by hand. It is abbreviated inconsistently (ROTTERDAM, NLRTM, RTM), often stale from the previous voyage, and sometimes carries an ETA in the same field. It is nonetheless worth checking: agreement raises confidence in a marginal call, and systematic disagreement for one vessel usually means the transponder was never reconfigured.
def destination_agrees(reported: str, port_code: str, aliases: dict[str, set[str]]) -> bool:
"""Loose match of the free-text destination against a port's known aliases."""
if not reported:
return False
r = "".join(ch for ch in reported.upper() if ch.isalnum())
return any(a in r or r in a for a in aliases.get(port_code, set()))
Treat it as one weak signal among several, never as ground truth. A detector that requires destination agreement will miss every call by a vessel whose master did not update the field, which is a substantial fraction of the fleet.
Verification and acceptance test
import pytest
def test_a_transit_through_the_fence_is_not_a_call(transit_track, inner, outer):
calls = detect_calls(transit_track, inner, outer, min_dwell_h=1.0)
assert calls == [], "a vessel passing through was recorded as a port call"
def test_an_anchoring_produces_exactly_one_call(swinging_track, inner, outer):
calls = detect_calls(swinging_track, inner, outer, min_dwell_h=1.0)
assert len(calls) == 1, f"{len(calls)} calls from one anchoring — hysteresis is not working"
def test_a_gap_at_arrival_is_flagged(gappy_track, inner, outer):
calls = detect_calls(gappy_track, inner, outer, max_gap_h=2.0)
assert calls and not calls[0].arrival_confident, (
"an arrival preceded by a 3 h gap was reported as confident")
The middle test is the one worth running against real data rather than a fixture: real anchorages generate swing patterns no synthetic track reproduces, and a hysteresis buffer that works on a circle sometimes fails on a tidal stream that carries a vessel back and forth across a fence in a straight line.
Validating against something other than itself
A port-call detector is easy to tune until it produces plausible numbers and impossible to trust on that basis, because the thing it is measuring has no independent record in the AIS feed. Three external sources are worth the effort of obtaining, in rough order of availability.
Port authority arrival lists. Many ports publish or will supply an arrivals and departures register. It is authoritative for the events it contains and incomplete for small craft, so it validates precision well and recall poorly.
Pilot boarding records. Where pilotage is compulsory, the boarding time is a hard timestamp near the arrival and is often available. It does not cover vessels exempt from pilotage.
The vessel’s own reported destination and ETA. Weak, as discussed, but free and available for every call, which makes it the only source that covers the whole population.
def compare_against_register(detected: list[PortCall], register: pd.DataFrame,
tol_h: float = 2.0) -> dict[str, float]:
"""Precision and recall of the detector against an authoritative register."""
matched = 0
for call in detected:
near = register[(register.mmsi == call.mmsi) &
((register.arrival - call.arrival).abs() <= pd.Timedelta(hours=tol_h))]
matched += int(not near.empty)
precision = matched / max(len(detected), 1)
recall = matched / max(len(register), 1)
logger.info("vs register: precision %.2f, recall %.2f over %d detected / %d recorded",
precision, recall, len(detected), len(register))
return {"precision": precision, "recall": recall}
Run it once per port and once per year. The numbers move when the fence polygons are revised, when a terminal is rebuilt, and when the fleet mix changes, and a detector validated once at commissioning and never again is reporting a quantity nobody has checked since.
Where no external register exists at all, the honest fallback is to publish the detector’s parameters — fence version, dwell threshold, hysteresis buffer — alongside every count, so that a consumer comparing two ports at least knows whether they were measured the same way. Two ports with different dwell thresholds do not have comparable call counts, and nothing in the output says so unless it is written down.
Edge cases and gotchas
- Two berths within the swing radius. A vessel shifting berth inside a port produces a departure and an arrival that are the same event. Track the berth identity as part of the state, not just inside/outside, or shifting appears as a call ending and another beginning.
- A gap that spans the whole call. A vessel that disappears on approach and reappears outbound has a call the detector never sees. The dark-gap logic can supply a candidate, but the arrival and departure times are unknown and the call should be recorded as inferred.
- Fence polygons drift. Port boundaries and berth layouts change as terminals are rebuilt, and a call detected in 2021 against the 2026 polygon is not the same event. Version the fences and record which version produced each call, or a multi-year turnaround trend is measuring the geofence.
Related
- Clustering Vessel Tracks With DBSCAN — deriving anchorage and berth polygons from the tracks themselves
- Segmenting Vessel Routes by Behavior — the parent workflow, and why boundaries carry confidence
- Interpolating Gaps in AIS Vessel Trajectories — the gap that sits exactly where the arrival time is needed