aissegments 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aissegments/__init__.py +37 -0
- aissegments/_types.py +153 -0
- aissegments/adapters.py +354 -0
- aissegments/py.typed +0 -0
- aissegments/tdkc.py +292 -0
- aissegments-0.2.0.dist-info/METADATA +156 -0
- aissegments-0.2.0.dist-info/RECORD +9 -0
- aissegments-0.2.0.dist-info/WHEEL +4 -0
- aissegments-0.2.0.dist-info/licenses/LICENSE +21 -0
aissegments/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""AIS trajectory segmentation and feature-preserving compression.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
|
|
5
|
+
- :class:`Track` / :class:`Segment` — typed input/output containers.
|
|
6
|
+
- :func:`to_segments` — pair consecutive points of a Track into Segment records.
|
|
7
|
+
- :func:`tdkc` — Top-Down Kinematic Compression; returns a compressed Track.
|
|
8
|
+
- :func:`tdkc_segments` — convenience wrapper: TDKC plus segment construction
|
|
9
|
+
with original-point-count enrichment.
|
|
10
|
+
|
|
11
|
+
Reference
|
|
12
|
+
---------
|
|
13
|
+
Guo, S., Bolbot, V., & Valdez Banda, O. (2024). An adaptive trajectory
|
|
14
|
+
compression and feature preservation method for maritime traffic analysis.
|
|
15
|
+
*Ocean Engineering*, 312, 119189.
|
|
16
|
+
"""
|
|
17
|
+
from aissegments._types import Segment, Track, to_segments
|
|
18
|
+
from aissegments.adapters import (
|
|
19
|
+
from_aisdb_track,
|
|
20
|
+
read_csv_static_records,
|
|
21
|
+
read_csv_tracks,
|
|
22
|
+
)
|
|
23
|
+
from aissegments.tdkc import tdkc, tdkc_segments
|
|
24
|
+
|
|
25
|
+
__version__ = "0.2.0"
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"Segment",
|
|
29
|
+
"Track",
|
|
30
|
+
"__version__",
|
|
31
|
+
"from_aisdb_track",
|
|
32
|
+
"read_csv_static_records",
|
|
33
|
+
"read_csv_tracks",
|
|
34
|
+
"tdkc",
|
|
35
|
+
"tdkc_segments",
|
|
36
|
+
"to_segments",
|
|
37
|
+
]
|
aissegments/_types.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Typed containers for AIS tracks and the linestring-segment records they yield."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
_FLOAT_FIELDS = ("t", "lon", "lat", "sog", "cog")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Track:
|
|
14
|
+
"""An ordered AIS track for a single vessel.
|
|
15
|
+
|
|
16
|
+
All array fields must be the same length and ``t`` must be
|
|
17
|
+
monotonically non-decreasing. Construct via :meth:`from_arrays` to get
|
|
18
|
+
consistent ``float64`` dtypes; the constructor itself only validates.
|
|
19
|
+
|
|
20
|
+
Attributes
|
|
21
|
+
----------
|
|
22
|
+
mmsi : int
|
|
23
|
+
Maritime Mobile Service Identity for the vessel.
|
|
24
|
+
t : numpy.ndarray
|
|
25
|
+
Unix timestamps in seconds since epoch (UTC). Shape ``(N,)``.
|
|
26
|
+
lon, lat : numpy.ndarray
|
|
27
|
+
WGS84 longitude/latitude in degrees. Shape ``(N,)``.
|
|
28
|
+
sog : numpy.ndarray
|
|
29
|
+
Speed over ground in knots. Shape ``(N,)``.
|
|
30
|
+
cog : numpy.ndarray
|
|
31
|
+
Course over ground in degrees, ``[0, 360)``. Shape ``(N,)``.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
mmsi: int
|
|
35
|
+
t: np.ndarray
|
|
36
|
+
lon: np.ndarray
|
|
37
|
+
lat: np.ndarray
|
|
38
|
+
sog: np.ndarray
|
|
39
|
+
cog: np.ndarray
|
|
40
|
+
|
|
41
|
+
def __post_init__(self) -> None:
|
|
42
|
+
n = len(self.t)
|
|
43
|
+
for name in _FLOAT_FIELDS[1:]:
|
|
44
|
+
arr = getattr(self, name)
|
|
45
|
+
if len(arr) != n:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"Track field {name!r} has length {len(arr)}, expected {n}"
|
|
48
|
+
)
|
|
49
|
+
if n > 1:
|
|
50
|
+
diffs = np.diff(self.t)
|
|
51
|
+
if np.any(diffs < 0):
|
|
52
|
+
raise ValueError("Track.t must be monotonically non-decreasing")
|
|
53
|
+
|
|
54
|
+
def __len__(self) -> int:
|
|
55
|
+
return len(self.t)
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_arrays(
|
|
59
|
+
cls,
|
|
60
|
+
mmsi: int,
|
|
61
|
+
t: Iterable[float],
|
|
62
|
+
lon: Iterable[float],
|
|
63
|
+
lat: Iterable[float],
|
|
64
|
+
sog: Iterable[float],
|
|
65
|
+
cog: Iterable[float],
|
|
66
|
+
) -> Track:
|
|
67
|
+
"""Coerce Python iterables / mixed dtypes into a validated ``Track``."""
|
|
68
|
+
return cls(
|
|
69
|
+
mmsi=int(mmsi),
|
|
70
|
+
t=np.asarray(list(t), dtype=np.float64),
|
|
71
|
+
lon=np.asarray(list(lon), dtype=np.float64),
|
|
72
|
+
lat=np.asarray(list(lat), dtype=np.float64),
|
|
73
|
+
sog=np.asarray(list(sog), dtype=np.float64),
|
|
74
|
+
cog=np.asarray(list(cog), dtype=np.float64),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def take(self, indices: Iterable[int]) -> Track:
|
|
78
|
+
"""Return a new ``Track`` containing only the points at ``indices``."""
|
|
79
|
+
idx = np.asarray(list(indices), dtype=np.int64)
|
|
80
|
+
return Track(
|
|
81
|
+
mmsi=self.mmsi,
|
|
82
|
+
t=self.t[idx],
|
|
83
|
+
lon=self.lon[idx],
|
|
84
|
+
lat=self.lat[idx],
|
|
85
|
+
sog=self.sog[idx],
|
|
86
|
+
cog=self.cog[idx],
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class Segment:
|
|
92
|
+
"""A constant-COG/SOG linestring between two key AIS points.
|
|
93
|
+
|
|
94
|
+
Attributes
|
|
95
|
+
----------
|
|
96
|
+
mmsi : int
|
|
97
|
+
Vessel identifier.
|
|
98
|
+
t_start, t_end : float
|
|
99
|
+
Unix-second timestamps of the segment endpoints.
|
|
100
|
+
lon_start, lat_start, lon_end, lat_end : float
|
|
101
|
+
WGS84 coordinates (degrees) of the endpoints.
|
|
102
|
+
cog_mean, sog_mean : float
|
|
103
|
+
Mean course (degrees) and speed (knots) across the segment endpoints.
|
|
104
|
+
n_points : int
|
|
105
|
+
Number of *original* AIS points represented by the segment, including
|
|
106
|
+
both endpoints. ``2`` if the segment was built without a backing
|
|
107
|
+
original track; ``>=2`` after enrichment by :func:`tdkc_segments`.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
mmsi: int
|
|
111
|
+
t_start: float
|
|
112
|
+
t_end: float
|
|
113
|
+
lon_start: float
|
|
114
|
+
lat_start: float
|
|
115
|
+
lon_end: float
|
|
116
|
+
lat_end: float
|
|
117
|
+
cog_mean: float
|
|
118
|
+
sog_mean: float
|
|
119
|
+
n_points: int = 2
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def to_segments(track: Track) -> list[Segment]:
|
|
123
|
+
"""Pair consecutive points of ``track`` into ``Segment`` records.
|
|
124
|
+
|
|
125
|
+
Each segment carries ``n_points = 2`` since the source track is treated as
|
|
126
|
+
already-compressed (or already-key-point-only). Use
|
|
127
|
+
:func:`aissegments.tdkc_segments` if you need the original-point-count
|
|
128
|
+
enrichment.
|
|
129
|
+
"""
|
|
130
|
+
n = len(track)
|
|
131
|
+
if n < 2:
|
|
132
|
+
return []
|
|
133
|
+
segments: list[Segment] = []
|
|
134
|
+
for i in range(n - 1):
|
|
135
|
+
# Course wrap: take the shorter of the two arc directions when the
|
|
136
|
+
# endpoints straddle 0/360. Mean is then re-wrapped to [0, 360).
|
|
137
|
+
cog_diff = ((track.cog[i + 1] - track.cog[i] + 180.0) % 360.0) - 180.0
|
|
138
|
+
cog_mean = (track.cog[i] + cog_diff / 2.0) % 360.0
|
|
139
|
+
segments.append(
|
|
140
|
+
Segment(
|
|
141
|
+
mmsi=track.mmsi,
|
|
142
|
+
t_start=float(track.t[i]),
|
|
143
|
+
t_end=float(track.t[i + 1]),
|
|
144
|
+
lon_start=float(track.lon[i]),
|
|
145
|
+
lat_start=float(track.lat[i]),
|
|
146
|
+
lon_end=float(track.lon[i + 1]),
|
|
147
|
+
lat_end=float(track.lat[i + 1]),
|
|
148
|
+
cog_mean=float(cog_mean),
|
|
149
|
+
sog_mean=float((track.sog[i] + track.sog[i + 1]) / 2.0),
|
|
150
|
+
n_points=2,
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
return segments
|
aissegments/adapters.py
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""Adapters for getting AIS data into ``aissegments``.
|
|
2
|
+
|
|
3
|
+
- :func:`from_aisdb_track` — single AISdb track-dict → :class:`Track`.
|
|
4
|
+
- :func:`read_csv_tracks` — generic CSV loader; groups by MMSI, returns one
|
|
5
|
+
:class:`Track` per vessel. Recognises common column name variants
|
|
6
|
+
(Marine Cadastre's ``BaseDateTime``/``LAT``/``LON``, etc.) and parses
|
|
7
|
+
ISO 8601 timestamps as well as unix seconds. Handles ``.gz`` transparently.
|
|
8
|
+
- :func:`read_csv_static_records` — companion to ``read_csv_tracks`` that
|
|
9
|
+
surfaces per-vessel static fields (VesselType, Length, Width, Draft,
|
|
10
|
+
IMO, …) when the source CSV carries them. Output is shaped to match
|
|
11
|
+
AISdb's static-row dict so downstream consumers can treat aisdb-decoded
|
|
12
|
+
data and rich-CSV data uniformly.
|
|
13
|
+
|
|
14
|
+
None of these pull in extra runtime dependencies beyond ``numpy``.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import csv
|
|
19
|
+
import gzip
|
|
20
|
+
from collections import defaultdict
|
|
21
|
+
from collections.abc import Mapping
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
|
|
28
|
+
from aissegments._types import Track
|
|
29
|
+
|
|
30
|
+
# Column aliases (all lower-cased before matching). First element is the
|
|
31
|
+
# canonical name used in error messages.
|
|
32
|
+
_CSV_COLUMN_ALIASES: dict[str, tuple[str, ...]] = {
|
|
33
|
+
"mmsi": ("mmsi",),
|
|
34
|
+
"time": ("time", "basedatetime", "timestamp", "datetime", "date_time"),
|
|
35
|
+
"lon": ("lon", "longitude"),
|
|
36
|
+
"lat": ("lat", "latitude"),
|
|
37
|
+
"sog": ("sog", "speed"),
|
|
38
|
+
"cog": ("cog", "course"),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Optional static-info columns recognised by :func:`read_csv_static_records`.
|
|
42
|
+
# All aliases are matched lowercase. Output keys mirror AISdb's static-row
|
|
43
|
+
# dict so the same downstream code path can consume aisdb-decoded rows and
|
|
44
|
+
# rich-CSV rows uniformly.
|
|
45
|
+
_STATIC_COLUMN_ALIASES: dict[str, tuple[str, ...]] = {
|
|
46
|
+
"vessel_name": ("vesselname", "vessel_name", "name"),
|
|
47
|
+
"call_sign": ("callsign", "call_sign"),
|
|
48
|
+
"imo": ("imo", "imo_num"),
|
|
49
|
+
"ship_type": ("vesseltype", "ship_type", "shiptype"),
|
|
50
|
+
"destination": ("destination",),
|
|
51
|
+
# Source columns that get split into AISdb's per-quadrant antenna offsets.
|
|
52
|
+
"length": ("length", "loa", "ship_length"),
|
|
53
|
+
"width": ("width", "beam", "breadth"),
|
|
54
|
+
"draught": ("draft", "draught"),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _resolve_csv_columns(fieldnames: list[str]) -> dict[str, str]:
|
|
59
|
+
"""Map each canonical name to the actual header it found, or raise."""
|
|
60
|
+
lower_to_actual = {c.strip().lower(): c for c in fieldnames}
|
|
61
|
+
resolved: dict[str, str] = {}
|
|
62
|
+
missing: list[str] = []
|
|
63
|
+
for canonical, aliases in _CSV_COLUMN_ALIASES.items():
|
|
64
|
+
for alias in aliases:
|
|
65
|
+
if alias in lower_to_actual:
|
|
66
|
+
resolved[canonical] = lower_to_actual[alias]
|
|
67
|
+
break
|
|
68
|
+
else:
|
|
69
|
+
missing.append(canonical)
|
|
70
|
+
if missing:
|
|
71
|
+
raise KeyError(f"CSV missing required columns (any of these aliases): {missing}")
|
|
72
|
+
return resolved
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _to_unix_seconds(value: str) -> float:
|
|
76
|
+
"""Parse either a numeric unix-seconds timestamp or an ISO 8601 string.
|
|
77
|
+
|
|
78
|
+
Naive ISO timestamps are interpreted as UTC, the de-facto convention for
|
|
79
|
+
AIS data feeds.
|
|
80
|
+
"""
|
|
81
|
+
s = value.strip()
|
|
82
|
+
try:
|
|
83
|
+
return float(s)
|
|
84
|
+
except ValueError:
|
|
85
|
+
pass
|
|
86
|
+
if s.endswith("Z"):
|
|
87
|
+
s = s[:-1] + "+00:00"
|
|
88
|
+
dt = datetime.fromisoformat(s)
|
|
89
|
+
if dt.tzinfo is None:
|
|
90
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
91
|
+
return dt.timestamp()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _open_text_csv(path: Path):
|
|
95
|
+
"""Open a CSV file transparently, gunzipping if the suffix says so."""
|
|
96
|
+
if path.suffix.lower() == ".gz":
|
|
97
|
+
return gzip.open(path, "rt", newline="", encoding="utf-8")
|
|
98
|
+
return path.open(newline="", encoding="utf-8")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def from_aisdb_track(track_dict: Mapping[str, Any]) -> Track:
|
|
102
|
+
"""Convert an AISdb ``TrackGen`` track dictionary into an :class:`aissegments.Track`.
|
|
103
|
+
|
|
104
|
+
AISdb yields tracks as dictionaries with at least these keys:
|
|
105
|
+
|
|
106
|
+
- ``mmsi`` (scalar int)
|
|
107
|
+
- ``time`` (1-D array of unix seconds)
|
|
108
|
+
- ``lon`` (1-D array, degrees)
|
|
109
|
+
- ``lat`` (1-D array, degrees)
|
|
110
|
+
- ``sog`` (1-D array, knots)
|
|
111
|
+
- ``cog`` (1-D array, degrees)
|
|
112
|
+
|
|
113
|
+
Tracks are passed through with no resampling or smoothing — TDKC is
|
|
114
|
+
designed to operate on the raw AIS time series.
|
|
115
|
+
|
|
116
|
+
Parameters
|
|
117
|
+
----------
|
|
118
|
+
track_dict : Mapping[str, Any]
|
|
119
|
+
A track dict in AISdb's column-array layout.
|
|
120
|
+
|
|
121
|
+
Returns
|
|
122
|
+
-------
|
|
123
|
+
Track
|
|
124
|
+
A validated :class:`aissegments.Track`.
|
|
125
|
+
|
|
126
|
+
Raises
|
|
127
|
+
------
|
|
128
|
+
KeyError
|
|
129
|
+
If the dict is missing any required key.
|
|
130
|
+
ValueError
|
|
131
|
+
If the array fields disagree on length or ``time`` is not monotonic.
|
|
132
|
+
"""
|
|
133
|
+
required = ("mmsi", "time", "lon", "lat", "sog", "cog")
|
|
134
|
+
missing = [k for k in required if k not in track_dict]
|
|
135
|
+
if missing:
|
|
136
|
+
raise KeyError(f"AISdb track dict is missing keys: {missing}")
|
|
137
|
+
return Track.from_arrays(
|
|
138
|
+
mmsi=int(track_dict["mmsi"]),
|
|
139
|
+
t=np.asarray(track_dict["time"], dtype=np.float64),
|
|
140
|
+
lon=np.asarray(track_dict["lon"], dtype=np.float64),
|
|
141
|
+
lat=np.asarray(track_dict["lat"], dtype=np.float64),
|
|
142
|
+
sog=np.asarray(track_dict["sog"], dtype=np.float64),
|
|
143
|
+
cog=np.asarray(track_dict["cog"], dtype=np.float64),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def read_csv_tracks(path: Path | str) -> list[Track]:
|
|
148
|
+
"""Load AIS pings from a CSV (optionally ``.gz``) and group them by MMSI.
|
|
149
|
+
|
|
150
|
+
The header row must contain (in any order, case-insensitive) one
|
|
151
|
+
column for each of the six required fields below. Common aliases are
|
|
152
|
+
accepted so that files exported by different data providers work
|
|
153
|
+
out-of-the-box:
|
|
154
|
+
|
|
155
|
+
| Field | Recognised header names |
|
|
156
|
+
|-------|------------------------------------------------------------------|
|
|
157
|
+
| mmsi | ``mmsi`` |
|
|
158
|
+
| time | ``time``, ``BaseDateTime``, ``timestamp``, ``datetime``, ``date_time`` |
|
|
159
|
+
| lon | ``lon``, ``longitude``, ``LON`` |
|
|
160
|
+
| lat | ``lat``, ``latitude``, ``LAT`` |
|
|
161
|
+
| sog | ``sog``, ``speed`` |
|
|
162
|
+
| cog | ``cog``, ``course`` |
|
|
163
|
+
|
|
164
|
+
Time values are accepted as either:
|
|
165
|
+
|
|
166
|
+
- Unix seconds (numeric, e.g. ``1625097600``), or
|
|
167
|
+
- ISO 8601 strings (e.g. ``2019-01-01T14:15:12``, ``...Z``, or
|
|
168
|
+
``...+00:00``). Naive timestamps are interpreted as UTC.
|
|
169
|
+
|
|
170
|
+
Rows are grouped by ``mmsi`` and sorted by time within each group, so
|
|
171
|
+
the order of rows in the source file does not matter.
|
|
172
|
+
|
|
173
|
+
Parameters
|
|
174
|
+
----------
|
|
175
|
+
path : Path or str
|
|
176
|
+
Path to a ``.csv`` or ``.csv.gz`` file.
|
|
177
|
+
|
|
178
|
+
Returns
|
|
179
|
+
-------
|
|
180
|
+
list[Track]
|
|
181
|
+
One :class:`Track` per unique MMSI, sorted by descending track length
|
|
182
|
+
so callers can pick the densest tracks easily.
|
|
183
|
+
|
|
184
|
+
Raises
|
|
185
|
+
------
|
|
186
|
+
KeyError
|
|
187
|
+
If a required column is missing from the header.
|
|
188
|
+
ValueError
|
|
189
|
+
If the file is empty, or any required field is missing / non-numeric
|
|
190
|
+
/ un-parseable.
|
|
191
|
+
"""
|
|
192
|
+
path = Path(path)
|
|
193
|
+
by_mmsi: dict[int, list[tuple[float, float, float, float, float]]] = defaultdict(list)
|
|
194
|
+
|
|
195
|
+
with _open_text_csv(path) as f:
|
|
196
|
+
reader = csv.DictReader(f)
|
|
197
|
+
if reader.fieldnames is None:
|
|
198
|
+
raise ValueError(f"Empty CSV (no header row): {path}")
|
|
199
|
+
col_map = _resolve_csv_columns(list(reader.fieldnames))
|
|
200
|
+
|
|
201
|
+
for line_no, row in enumerate(reader, start=2):
|
|
202
|
+
try:
|
|
203
|
+
mmsi = int(row[col_map["mmsi"]])
|
|
204
|
+
t = _to_unix_seconds(row[col_map["time"]])
|
|
205
|
+
lon = float(row[col_map["lon"]])
|
|
206
|
+
lat = float(row[col_map["lat"]])
|
|
207
|
+
sog = float(row[col_map["sog"]])
|
|
208
|
+
cog = float(row[col_map["cog"]])
|
|
209
|
+
except (TypeError, ValueError) as exc:
|
|
210
|
+
raise ValueError(f"Invalid row {line_no} in {path}: {exc}") from exc
|
|
211
|
+
by_mmsi[mmsi].append((t, lon, lat, sog, cog))
|
|
212
|
+
|
|
213
|
+
tracks: list[Track] = []
|
|
214
|
+
for mmsi, rows in by_mmsi.items():
|
|
215
|
+
rows.sort(key=lambda r: r[0])
|
|
216
|
+
cols = np.asarray(rows, dtype=np.float64).T
|
|
217
|
+
tracks.append(
|
|
218
|
+
Track(
|
|
219
|
+
mmsi=mmsi,
|
|
220
|
+
t=cols[0],
|
|
221
|
+
lon=cols[1],
|
|
222
|
+
lat=cols[2],
|
|
223
|
+
sog=cols[3],
|
|
224
|
+
cog=cols[4],
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
tracks.sort(key=len, reverse=True)
|
|
228
|
+
return tracks
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _to_int_or_none(value: str) -> int | None:
|
|
232
|
+
"""Parse a CSV cell as int, tolerating empties, whitespace, and floats."""
|
|
233
|
+
s = (value or "").strip()
|
|
234
|
+
if not s:
|
|
235
|
+
return None
|
|
236
|
+
try:
|
|
237
|
+
return int(float(s))
|
|
238
|
+
except (TypeError, ValueError):
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _to_float_or_none(value: str) -> float | None:
|
|
243
|
+
s = (value or "").strip()
|
|
244
|
+
if not s:
|
|
245
|
+
return None
|
|
246
|
+
try:
|
|
247
|
+
return float(s)
|
|
248
|
+
except (TypeError, ValueError):
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def read_csv_static_records(path: Path | str) -> dict[int, dict[str, Any]]:
|
|
253
|
+
"""Extract per-MMSI static info from a CSV (e.g. Marine Cadastre).
|
|
254
|
+
|
|
255
|
+
The CSV must have ``mmsi`` and a recognised time column (see
|
|
256
|
+
:func:`read_csv_tracks` for aliases). Optional static columns are
|
|
257
|
+
detected case-insensitively:
|
|
258
|
+
|
|
259
|
+
| Output key | Recognised headers |
|
|
260
|
+
|-------------|---------------------------------|
|
|
261
|
+
| vessel_name | ``VesselName``, ``name`` |
|
|
262
|
+
| call_sign | ``CallSign`` |
|
|
263
|
+
| imo | ``IMO``, ``imo_num`` |
|
|
264
|
+
| ship_type | ``VesselType``, ``ShipType`` |
|
|
265
|
+
| destination | ``Destination`` |
|
|
266
|
+
| dim_bow / dim_stern | (split from ``Length`` / ``LOA``) — half each |
|
|
267
|
+
| dim_port / dim_star | (split from ``Width`` / ``Beam``) — half each |
|
|
268
|
+
| draught | ``Draft``, ``Draught`` |
|
|
269
|
+
|
|
270
|
+
Marine Cadastre publishes overall ``Length``/``Width`` rather than the
|
|
271
|
+
per-quadrant antenna offsets that AIS Type-5 carries. The split is
|
|
272
|
+
halved into ``dim_bow``/``dim_stern`` (and similarly for width) — a
|
|
273
|
+
centred-antenna approximation, which is what most AIS feeds report
|
|
274
|
+
when the antenna position is unknown.
|
|
275
|
+
|
|
276
|
+
Multiple rows per MMSI are merged with **last non-NULL value wins per
|
|
277
|
+
field**, and the latest ``time`` is recorded. Returns ``{}`` if no
|
|
278
|
+
recognised static columns are present.
|
|
279
|
+
|
|
280
|
+
Output dict is shaped to match AISdb's static-row dict so the same
|
|
281
|
+
downstream code (e.g. OMRAT's ``_ensure_static``/``_ensure_state``)
|
|
282
|
+
can consume both data sources uniformly.
|
|
283
|
+
|
|
284
|
+
Raises
|
|
285
|
+
------
|
|
286
|
+
ValueError
|
|
287
|
+
If the file is empty (no header).
|
|
288
|
+
KeyError
|
|
289
|
+
If ``mmsi`` or ``time`` columns are missing.
|
|
290
|
+
"""
|
|
291
|
+
path = Path(path)
|
|
292
|
+
out: dict[int, dict[str, Any]] = {}
|
|
293
|
+
|
|
294
|
+
with _open_text_csv(path) as f:
|
|
295
|
+
reader = csv.DictReader(f)
|
|
296
|
+
if reader.fieldnames is None:
|
|
297
|
+
raise ValueError(f"Empty CSV (no header row): {path}")
|
|
298
|
+
kinematic_map = _resolve_csv_columns(list(reader.fieldnames))
|
|
299
|
+
# Build the static column map; absent columns are simply skipped.
|
|
300
|
+
lower_to_actual = {c.strip().lower(): c for c in reader.fieldnames}
|
|
301
|
+
static_map: dict[str, str] = {}
|
|
302
|
+
for canonical, aliases in _STATIC_COLUMN_ALIASES.items():
|
|
303
|
+
for alias in aliases:
|
|
304
|
+
if alias in lower_to_actual:
|
|
305
|
+
static_map[canonical] = lower_to_actual[alias]
|
|
306
|
+
break
|
|
307
|
+
if not static_map:
|
|
308
|
+
return {}
|
|
309
|
+
|
|
310
|
+
for row in reader:
|
|
311
|
+
try:
|
|
312
|
+
mmsi = int(row[kinematic_map["mmsi"]])
|
|
313
|
+
except (TypeError, ValueError, KeyError):
|
|
314
|
+
continue
|
|
315
|
+
try:
|
|
316
|
+
t = _to_unix_seconds(row[kinematic_map["time"]])
|
|
317
|
+
except (TypeError, ValueError, KeyError):
|
|
318
|
+
continue
|
|
319
|
+
|
|
320
|
+
entry = out.setdefault(mmsi, {"mmsi": mmsi, "time": t})
|
|
321
|
+
# Track latest timestamp per MMSI.
|
|
322
|
+
if t > entry.get("time", t):
|
|
323
|
+
entry["time"] = t
|
|
324
|
+
|
|
325
|
+
for canonical, src_col in static_map.items():
|
|
326
|
+
# csv.DictReader yields "" for missing/blank cells; the
|
|
327
|
+
# parsers below all return None for empty input so no
|
|
328
|
+
# explicit None-guard is needed here.
|
|
329
|
+
raw = row.get(src_col, "")
|
|
330
|
+
if canonical in ("imo", "ship_type"):
|
|
331
|
+
val = _to_int_or_none(raw)
|
|
332
|
+
elif canonical in ("length", "width", "draught"):
|
|
333
|
+
val = _to_float_or_none(raw)
|
|
334
|
+
else:
|
|
335
|
+
s = str(raw).strip()
|
|
336
|
+
val = s or None
|
|
337
|
+
if val is not None:
|
|
338
|
+
entry[canonical] = val
|
|
339
|
+
|
|
340
|
+
# Convert overall Length / Width into AISdb's per-quadrant antenna
|
|
341
|
+
# offsets (halved — centred-antenna approximation).
|
|
342
|
+
for entry in out.values():
|
|
343
|
+
length = entry.pop("length", None)
|
|
344
|
+
width = entry.pop("width", None)
|
|
345
|
+
if length is not None:
|
|
346
|
+
half = length / 2.0
|
|
347
|
+
entry["dim_bow"] = half
|
|
348
|
+
entry["dim_stern"] = half
|
|
349
|
+
if width is not None:
|
|
350
|
+
half = width / 2.0
|
|
351
|
+
entry["dim_port"] = half
|
|
352
|
+
entry["dim_star"] = half
|
|
353
|
+
|
|
354
|
+
return out
|
aissegments/py.typed
ADDED
|
File without changes
|
aissegments/tdkc.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Top-Down Kinematic Compression (TDKC) on AIS tracks.
|
|
2
|
+
|
|
3
|
+
Implements the algorithm of:
|
|
4
|
+
|
|
5
|
+
Guo, S., Bolbot, V., & Valdez Banda, O. (2024). An adaptive trajectory
|
|
6
|
+
compression and feature preservation method for maritime traffic analysis.
|
|
7
|
+
*Ocean Engineering*, 312, 119189. https://doi.org/10.1016/j.oceaneng.2024.119189
|
|
8
|
+
|
|
9
|
+
Pipeline
|
|
10
|
+
--------
|
|
11
|
+
1. Recursively build a *Compression Binary Tree* (CBT). Each node records the
|
|
12
|
+
intermediate point with the maximum aggregated z-score of Synchronous
|
|
13
|
+
Euclidean Distance (SED) and Synchronous Velocity Difference (SVD), and
|
|
14
|
+
splits the sub-trajectory at that point. Recursion exhausts down to
|
|
15
|
+
length-2 leaves; the threshold is *not* applied during construction.
|
|
16
|
+
2. Compute adaptive thresholds ``sed_eps`` and ``svd_eps`` as the mean SED and
|
|
17
|
+
mean SVD across all CBT nodes (paper Eqs. 27-28), each clamped to a
|
|
18
|
+
user-configurable floor that defends against floating-point noise on clean
|
|
19
|
+
inputs. Real AIS data is unaffected (mean SED ~10²-10³ m, well above the
|
|
20
|
+
1 m default floor).
|
|
21
|
+
3. A node is a *key node* if either of its measurements exceeds its threshold,
|
|
22
|
+
*or* any of its children is a key node — this is the recursion-termination
|
|
23
|
+
fix from the paper. Indices of key-node split points are retained.
|
|
24
|
+
4. Output: original first/last point + every key-node split point.
|
|
25
|
+
|
|
26
|
+
Recursion depth scales with tree depth (≈ ``log2(N)`` for balanced inputs).
|
|
27
|
+
For pathological inputs that produce a degenerate tree, raise the system
|
|
28
|
+
recursion limit before calling :func:`tdkc`.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from itertools import pairwise
|
|
34
|
+
|
|
35
|
+
import numpy as np
|
|
36
|
+
|
|
37
|
+
from aissegments._types import Segment, Track, to_segments
|
|
38
|
+
|
|
39
|
+
# Earth radius in metres for haversine. WGS84 mean radius.
|
|
40
|
+
_EARTH_RADIUS_M = 6_371_008.8
|
|
41
|
+
|
|
42
|
+
# Default threshold floors. Below GPS accuracy (~10 m for Class A AIS) but
|
|
43
|
+
# well above double-precision arithmetic noise on lat/lon arithmetic.
|
|
44
|
+
_DEFAULT_MIN_SED_M = 1.0
|
|
45
|
+
_DEFAULT_MIN_SVD_KN = 0.01
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Internal numerics
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _haversine_m(
|
|
54
|
+
lon1: np.ndarray, lat1: np.ndarray, lon2: np.ndarray, lat2: np.ndarray
|
|
55
|
+
) -> np.ndarray:
|
|
56
|
+
"""Great-circle distance in metres, vectorised over numpy arrays."""
|
|
57
|
+
lon1r = np.deg2rad(lon1)
|
|
58
|
+
lat1r = np.deg2rad(lat1)
|
|
59
|
+
lon2r = np.deg2rad(lon2)
|
|
60
|
+
lat2r = np.deg2rad(lat2)
|
|
61
|
+
dlon = lon2r - lon1r
|
|
62
|
+
dlat = lat2r - lat1r
|
|
63
|
+
a = np.sin(dlat / 2.0) ** 2 + np.cos(lat1r) * np.cos(lat2r) * np.sin(dlon / 2.0) ** 2
|
|
64
|
+
return 2.0 * _EARTH_RADIUS_M * np.arcsin(np.sqrt(np.clip(a, 0.0, 1.0)))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _zscore(x: np.ndarray) -> np.ndarray:
|
|
68
|
+
"""Z-score normalise; return zeros if std is zero (constant input)."""
|
|
69
|
+
if x.size == 0:
|
|
70
|
+
return x
|
|
71
|
+
mean = x.mean()
|
|
72
|
+
std = x.std()
|
|
73
|
+
if std == 0.0:
|
|
74
|
+
return np.zeros_like(x)
|
|
75
|
+
return (x - mean) / std
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _compute_sed_svd(
|
|
79
|
+
t: np.ndarray,
|
|
80
|
+
lon: np.ndarray,
|
|
81
|
+
lat: np.ndarray,
|
|
82
|
+
sog: np.ndarray,
|
|
83
|
+
cog: np.ndarray,
|
|
84
|
+
start: int,
|
|
85
|
+
end: int,
|
|
86
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
87
|
+
"""Compute SED and SVD for every intermediate point in ``[start, end]``.
|
|
88
|
+
|
|
89
|
+
Returns two arrays of shape ``(end - start - 1,)``. Caller guarantees
|
|
90
|
+
``end - start >= 2`` so there is at least one intermediate. Zero-fills
|
|
91
|
+
if the start and end share a timestamp (synchronous interpolation
|
|
92
|
+
degenerates).
|
|
93
|
+
"""
|
|
94
|
+
inter = np.arange(start + 1, end)
|
|
95
|
+
n_inter = inter.size
|
|
96
|
+
t_s, t_e = float(t[start]), float(t[end])
|
|
97
|
+
dt_total = t_e - t_s
|
|
98
|
+
if dt_total <= 0.0:
|
|
99
|
+
return np.zeros(n_inter), np.zeros(n_inter)
|
|
100
|
+
frac = (t[inter] - t_s) / dt_total
|
|
101
|
+
|
|
102
|
+
# SED: distance between the actual point and the synchronous-interpolated
|
|
103
|
+
# point on the start-end great circle (paper Eqs. 4-7, generalised to the
|
|
104
|
+
# sphere via haversine — see paper Sec. 3.2.1 par. on Cartesian distortion).
|
|
105
|
+
lon_sync = lon[start] + frac * (lon[end] - lon[start])
|
|
106
|
+
lat_sync = lat[start] + frac * (lat[end] - lat[start])
|
|
107
|
+
sed = _haversine_m(lon_sync, lat_sync, lon[inter], lat[inter])
|
|
108
|
+
|
|
109
|
+
# SVD: magnitude of the velocity-vector difference between the actual
|
|
110
|
+
# point and the synchronous-interpolated velocity (paper Eqs. 11-19).
|
|
111
|
+
delta_s = float(sog[end]) - float(sog[start])
|
|
112
|
+
delta_c = ((float(cog[end]) - float(cog[start]) + 180.0) % 360.0) - 180.0
|
|
113
|
+
s_sync = sog[start] + frac * delta_s
|
|
114
|
+
c_sync = cog[start] + frac * delta_c
|
|
115
|
+
vx_sync = s_sync * np.sin(np.deg2rad(c_sync))
|
|
116
|
+
vy_sync = s_sync * np.cos(np.deg2rad(c_sync))
|
|
117
|
+
vx_pt = sog[inter] * np.sin(np.deg2rad(cog[inter]))
|
|
118
|
+
vy_pt = sog[inter] * np.cos(np.deg2rad(cog[inter]))
|
|
119
|
+
svd = np.hypot(vx_sync - vx_pt, vy_sync - vy_pt)
|
|
120
|
+
return sed, svd
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# Compression Binary Tree
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class _Node:
|
|
130
|
+
idx: int
|
|
131
|
+
sed: float
|
|
132
|
+
svd: float
|
|
133
|
+
left: _Node | None = None
|
|
134
|
+
right: _Node | None = None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _build_cbt(
|
|
138
|
+
t: np.ndarray,
|
|
139
|
+
lon: np.ndarray,
|
|
140
|
+
lat: np.ndarray,
|
|
141
|
+
sog: np.ndarray,
|
|
142
|
+
cog: np.ndarray,
|
|
143
|
+
start: int,
|
|
144
|
+
end: int,
|
|
145
|
+
) -> _Node | None:
|
|
146
|
+
"""Recursive CBT construction (paper Algorithm 3). No threshold applied."""
|
|
147
|
+
if end - start < 2:
|
|
148
|
+
return None
|
|
149
|
+
sed_arr, svd_arr = _compute_sed_svd(t, lon, lat, sog, cog, start, end)
|
|
150
|
+
aggregated = _zscore(sed_arr) + _zscore(svd_arr)
|
|
151
|
+
rel_max = int(np.argmax(aggregated))
|
|
152
|
+
split_idx = start + 1 + rel_max
|
|
153
|
+
return _Node(
|
|
154
|
+
idx=split_idx,
|
|
155
|
+
sed=float(sed_arr[rel_max]),
|
|
156
|
+
svd=float(svd_arr[rel_max]),
|
|
157
|
+
left=_build_cbt(t, lon, lat, sog, cog, start, split_idx),
|
|
158
|
+
right=_build_cbt(t, lon, lat, sog, cog, split_idx, end),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _collect_thresholds(
|
|
163
|
+
node: _Node | None, sed_acc: list[float], svd_acc: list[float]
|
|
164
|
+
) -> None:
|
|
165
|
+
if node is None:
|
|
166
|
+
return
|
|
167
|
+
sed_acc.append(node.sed)
|
|
168
|
+
svd_acc.append(node.svd)
|
|
169
|
+
_collect_thresholds(node.left, sed_acc, svd_acc)
|
|
170
|
+
_collect_thresholds(node.right, sed_acc, svd_acc)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _identify_keys(
|
|
174
|
+
node: _Node | None,
|
|
175
|
+
sed_eps: float,
|
|
176
|
+
svd_eps: float,
|
|
177
|
+
out: set[int],
|
|
178
|
+
) -> bool:
|
|
179
|
+
"""Return True if ``node`` (or any descendant) is a key node. Mutates ``out``."""
|
|
180
|
+
if node is None:
|
|
181
|
+
return False
|
|
182
|
+
self_key = (node.sed > sed_eps) or (node.svd > svd_eps)
|
|
183
|
+
left_key = _identify_keys(node.left, sed_eps, svd_eps, out)
|
|
184
|
+
right_key = _identify_keys(node.right, sed_eps, svd_eps, out)
|
|
185
|
+
if self_key or left_key or right_key:
|
|
186
|
+
out.add(node.idx)
|
|
187
|
+
return True
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _adaptive_thresholds(
|
|
192
|
+
cbt: _Node, min_sed_m: float, min_svd_kn: float
|
|
193
|
+
) -> tuple[float, float]:
|
|
194
|
+
"""Mean SED / SVD across the tree, clamped to the configured floors."""
|
|
195
|
+
sed_acc: list[float] = []
|
|
196
|
+
svd_acc: list[float] = []
|
|
197
|
+
_collect_thresholds(cbt, sed_acc, svd_acc)
|
|
198
|
+
sed_eps = max(float(np.mean(sed_acc)), float(min_sed_m))
|
|
199
|
+
svd_eps = max(float(np.mean(svd_acc)), float(min_svd_kn))
|
|
200
|
+
return sed_eps, svd_eps
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
# Public API
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def tdkc(
|
|
209
|
+
track: Track,
|
|
210
|
+
*,
|
|
211
|
+
min_sed_m: float = _DEFAULT_MIN_SED_M,
|
|
212
|
+
min_svd_kn: float = _DEFAULT_MIN_SVD_KN,
|
|
213
|
+
) -> Track:
|
|
214
|
+
"""Compress ``track`` using TDKC; return a new Track of only the key points.
|
|
215
|
+
|
|
216
|
+
Endpoints are always retained. Tracks of length ``<= 2`` pass through
|
|
217
|
+
unchanged.
|
|
218
|
+
|
|
219
|
+
Parameters
|
|
220
|
+
----------
|
|
221
|
+
track : Track
|
|
222
|
+
Input AIS track for a single vessel.
|
|
223
|
+
min_sed_m : float, optional
|
|
224
|
+
Lower bound for the adaptive SED threshold, in metres. Defaults to
|
|
225
|
+
``1.0`` (below typical GPS accuracy, above floating-point noise).
|
|
226
|
+
Set to ``0`` for paper-faithful behaviour at the cost of FP-driven
|
|
227
|
+
artefacts on perfectly clean inputs.
|
|
228
|
+
min_svd_kn : float, optional
|
|
229
|
+
Lower bound for the adaptive SVD threshold, in knots. Defaults to
|
|
230
|
+
``0.01``. Set to ``0`` for paper-faithful behaviour.
|
|
231
|
+
|
|
232
|
+
Returns
|
|
233
|
+
-------
|
|
234
|
+
Track
|
|
235
|
+
New track containing the original endpoints plus every key-node split
|
|
236
|
+
point identified by TDKC. Always sorted by ``t``.
|
|
237
|
+
"""
|
|
238
|
+
n = len(track)
|
|
239
|
+
if n <= 2:
|
|
240
|
+
return track
|
|
241
|
+
cbt = _build_cbt(track.t, track.lon, track.lat, track.sog, track.cog, 0, n - 1)
|
|
242
|
+
assert cbt is not None # n >= 3 guarantees a non-empty intermediate set
|
|
243
|
+
sed_eps, svd_eps = _adaptive_thresholds(cbt, min_sed_m, min_svd_kn)
|
|
244
|
+
keys: set[int] = {0, n - 1}
|
|
245
|
+
_identify_keys(cbt, sed_eps, svd_eps, keys)
|
|
246
|
+
return track.take(sorted(keys))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def tdkc_segments(
|
|
250
|
+
track: Track,
|
|
251
|
+
*,
|
|
252
|
+
min_sed_m: float = _DEFAULT_MIN_SED_M,
|
|
253
|
+
min_svd_kn: float = _DEFAULT_MIN_SVD_KN,
|
|
254
|
+
) -> list[Segment]:
|
|
255
|
+
"""Compress with TDKC and emit one ``Segment`` per consecutive key-pair.
|
|
256
|
+
|
|
257
|
+
Each segment's ``n_points`` is the count of *original* points spanned,
|
|
258
|
+
including both endpoints — useful when the segments are written into a
|
|
259
|
+
PostGIS table that wants to preserve the underlying observation density.
|
|
260
|
+
|
|
261
|
+
See :func:`tdkc` for ``min_sed_m`` / ``min_svd_kn``.
|
|
262
|
+
"""
|
|
263
|
+
n = len(track)
|
|
264
|
+
if n < 2:
|
|
265
|
+
return []
|
|
266
|
+
if n == 2:
|
|
267
|
+
return to_segments(track)
|
|
268
|
+
cbt = _build_cbt(track.t, track.lon, track.lat, track.sog, track.cog, 0, n - 1)
|
|
269
|
+
assert cbt is not None
|
|
270
|
+
sed_eps, svd_eps = _adaptive_thresholds(cbt, min_sed_m, min_svd_kn)
|
|
271
|
+
keys: set[int] = {0, n - 1}
|
|
272
|
+
_identify_keys(cbt, sed_eps, svd_eps, keys)
|
|
273
|
+
sorted_idx = sorted(keys)
|
|
274
|
+
compressed = track.take(sorted_idx)
|
|
275
|
+
base_segments = to_segments(compressed)
|
|
276
|
+
enriched: list[Segment] = []
|
|
277
|
+
for seg, (i_start, i_end) in zip(base_segments, pairwise(sorted_idx), strict=True):
|
|
278
|
+
enriched.append(
|
|
279
|
+
Segment(
|
|
280
|
+
mmsi=seg.mmsi,
|
|
281
|
+
t_start=seg.t_start,
|
|
282
|
+
t_end=seg.t_end,
|
|
283
|
+
lon_start=seg.lon_start,
|
|
284
|
+
lat_start=seg.lat_start,
|
|
285
|
+
lon_end=seg.lon_end,
|
|
286
|
+
lat_end=seg.lat_end,
|
|
287
|
+
cog_mean=seg.cog_mean,
|
|
288
|
+
sog_mean=seg.sog_mean,
|
|
289
|
+
n_points=int(i_end - i_start + 1),
|
|
290
|
+
)
|
|
291
|
+
)
|
|
292
|
+
return enriched
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aissegments
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: AIS trajectory segmentation and feature-preserving compression for maritime traffic analysis.
|
|
5
|
+
Project-URL: Homepage, https://github.com/axelHorteborn/AISsegments
|
|
6
|
+
Project-URL: Repository, https://github.com/axelHorteborn/AISsegments
|
|
7
|
+
Project-URL: Issues, https://github.com/axelHorteborn/AISsegments/issues
|
|
8
|
+
Author-email: Axel Hörteborn <axel.horteborn@ri.se>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: AIS,Douglas-Peucker,TDKC,compression,maritime,segmentation,trajectory
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: numpy>=1.24
|
|
24
|
+
Provides-Extra: aisdb
|
|
25
|
+
Requires-Dist: aisdb>=1.0; extra == 'aisdb'
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: aisdb>=1.7; extra == 'dev'
|
|
28
|
+
Requires-Dist: matplotlib>=3.7; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-cov>=4; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
32
|
+
Provides-Extra: viz
|
|
33
|
+
Requires-Dist: matplotlib>=3.7; extra == 'viz'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# AISsegments
|
|
37
|
+
|
|
38
|
+
**A Python toolkit for compressing AIS vessel trajectories into compact, query-friendly linestring segments — without losing the kinematic features that maritime safety and risk analysis depend on.**
|
|
39
|
+
|
|
40
|
+
## What is this?
|
|
41
|
+
|
|
42
|
+
`aissegments` takes raw AIS position reports (millions of pings per day in a busy sea area) and outputs a small number of **constant-COG/SOG linestring segments per vessel**, one row per (vessel, time-window, course/speed). The output is shaped to drop straight into a PostGIS `LINESTRING` table.
|
|
43
|
+
|
|
44
|
+
It is the reference Python implementation of the **Top-Down Kinematic Compression (TDKC)** algorithm of Guo, Bolbot & Valdez Banda (Ocean Engineering 312 (2024), 119189), with the recursion-termination and adaptive-threshold fixes described in the paper.
|
|
45
|
+
|
|
46
|
+
## Goals
|
|
47
|
+
|
|
48
|
+
1. **Shrink AIS data without losing the maritime-relevant features.** A continent-scale AIS feed easily produces hundreds of millions of position reports per month. Most of those points are uninformative — a vessel cruising in a straight line at a steady speed needs only its endpoints. TDKC keeps the points where vessels actually do something interesting (turn, accelerate, stop, manoeuvre) and drops the rest.
|
|
49
|
+
2. **Treat AIS as a sequence of *kinematic states*, not just positions.** The classical Douglas-Peucker simplification looks only at how far points stray from a straight line. That throws away course and speed changes that lie on a straight track, which are exactly the events maritime risk analysis cares about. TDKC uses both position (Synchronous Euclidean Distance) **and** velocity (Synchronous Velocity Difference) with adaptive per-track thresholds.
|
|
50
|
+
3. **Produce database-ready output.** The output isn't a smaller list of points — it's a list of `Segment` records with start/end coordinates, mean COG/SOG, and the count of original observations spanned. Each segment is a 2-point `LINESTRING` ready for `ST_Intersects` and other PostGIS operations.
|
|
51
|
+
4. **Stay framework-neutral.** The core depends only on NumPy. AISdb is an optional adapter (`pip install "aissegments[aisdb]"`); other input paths (CSV from Marine Cadastre / institutional exports / your own pipeline) work via [`read_csv_tracks`](src/aissegments/adapters.py) without any extra dependencies.
|
|
52
|
+
|
|
53
|
+
## Who is this for?
|
|
54
|
+
|
|
55
|
+
- **Maritime risk analysts** who need to run collision/grounding/allision queries against millions of vessel positions per area-year and want the spatial+temporal index to fit in memory.
|
|
56
|
+
- **AIS data engineers** maintaining a Postgres/PostGIS warehouse of vessel tracks and looking for a principled way to densify ingestion without overwhelming storage.
|
|
57
|
+
- **Researchers** reproducing or extending Guo et al.'s adaptive trajectory compression work.
|
|
58
|
+
|
|
59
|
+
## What it produces
|
|
60
|
+
|
|
61
|
+
- `tdkc(track)` — same `Track` interface but with only the *key points* retained (typically 1-5% of the input, depending on track shape and threshold tuning).
|
|
62
|
+
- `tdkc_segments(track)` — a list of `Segment` records, one per consecutive key-point pair, ready for direct insertion as PostGIS `LINESTRING(start_lon start_lat, end_lon end_lat)` geometries with `cog_mean`, `sog_mean`, and `n_points` (count of original AIS pings each segment represents).
|
|
63
|
+
|
|
64
|
+
## Why not just use Douglas-Peucker?
|
|
65
|
+
|
|
66
|
+
DP and its variants throw away every point that lies on a straight line, regardless of whether the vessel's *behaviour* is changing. A vessel slowing from 15 to 5 knots while continuing to head east — DP keeps two points (start, end) and you lose the entire speed change. TDKC keeps the deceleration point because its **velocity vector** has shifted. See [`docs/algorithm.md`](docs/algorithm.md) for the precise math, and [`examples/output/03_min_svd_sweep.png`](examples/output/) for a visual side-by-side.
|
|
67
|
+
|
|
68
|
+
## Companion package
|
|
69
|
+
|
|
70
|
+
[OMRAT](https://github.com/axelande/OMRAT) (Open Maritime Risk Analysis Tool) — a QGIS plugin for collision/grounding/allision risk modelling — uses AISsegments as its segment-ingestion backend. The OMRAT pipeline shows a complete end-to-end flow: NMEA / CSV → aisdb decode → TDKC compression → bulk-load into a year-partitioned PostGIS schema.
|
|
71
|
+
|
|
72
|
+
## Install
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pip install aissegments
|
|
76
|
+
|
|
77
|
+
# with the optional AISdb adapter for ingestion from raw NMEA / CSV
|
|
78
|
+
pip install "aissegments[aisdb]"
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
For development with full test + coverage tooling:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
git clone https://github.com/axelHorteborn/AISsegments
|
|
85
|
+
cd AISsegments
|
|
86
|
+
pip install -e ".[dev,aisdb]"
|
|
87
|
+
pytest --cov
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Quickstart
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
import numpy as np
|
|
94
|
+
from aissegments import Track, tdkc, tdkc_segments
|
|
95
|
+
|
|
96
|
+
# Build a Track from your own arrays (lat/lon in degrees, sog in knots, cog in degrees).
|
|
97
|
+
track = Track.from_arrays(
|
|
98
|
+
mmsi=219000123,
|
|
99
|
+
t=np.array([0, 60, 120, 180, 240], dtype=float), # unix seconds
|
|
100
|
+
lon=np.array([12.0, 12.001, 12.002, 12.003, 12.004]),
|
|
101
|
+
lat=np.array([55.0, 55.0, 55.0, 55.0, 55.0]),
|
|
102
|
+
sog=np.array([10.0, 10.0, 10.0, 10.0, 10.0]),
|
|
103
|
+
cog=np.array([90.0, 90.0, 90.0, 90.0, 90.0]),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Compress: returns a Track containing only the key points.
|
|
107
|
+
compressed = tdkc(track)
|
|
108
|
+
print(len(compressed), "key points kept out of", len(track))
|
|
109
|
+
|
|
110
|
+
# Or go straight to segment records (one per consecutive key-point pair).
|
|
111
|
+
segments = tdkc_segments(track)
|
|
112
|
+
for s in segments:
|
|
113
|
+
print(s.t_start, s.t_end, s.cog_mean, s.sog_mean, s.n_points)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Using AISdb as an input adapter
|
|
117
|
+
|
|
118
|
+
`aissegments` can consume the per-vessel track dicts produced by [AISdb](https://github.com/AISViz/AISdb)'s `TrackGen()`:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
import aisdb
|
|
122
|
+
from aissegments.adapters import from_aisdb_track
|
|
123
|
+
from aissegments import tdkc_segments
|
|
124
|
+
|
|
125
|
+
with aisdb.SQLiteDBConn(dbpath="ais.db") as conn:
|
|
126
|
+
qry = aisdb.DBQuery(start=..., end=..., callback=aisdb.sql_query_strs.in_bbox_time)
|
|
127
|
+
tracks = aisdb.TrackGen(qry.gen_qry(), decimate=False)
|
|
128
|
+
for t_dict in tracks:
|
|
129
|
+
track = from_aisdb_track(t_dict)
|
|
130
|
+
for seg in tdkc_segments(track):
|
|
131
|
+
... # write seg to your PostGIS table
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## What's in the package
|
|
135
|
+
|
|
136
|
+
| Module | Purpose |
|
|
137
|
+
| --- | --- |
|
|
138
|
+
| [`aissegments.tdkc`](src/aissegments/tdkc.py) | TDKC algorithm: SED + SVD, Compression Binary Tree, adaptive thresholds, key-node identification |
|
|
139
|
+
| [`aissegments._types`](src/aissegments/_types.py) | `Track` and `Segment` dataclasses, `to_segments` helper |
|
|
140
|
+
| [`aissegments.adapters`](src/aissegments/adapters.py) | Input adapters: `from_aisdb_track`, `read_csv_tracks` (Marine Cadastre etc.), `read_csv_static_records` for vessel-info extraction |
|
|
141
|
+
|
|
142
|
+
## Algorithm details
|
|
143
|
+
|
|
144
|
+
See [docs/algorithm.md](docs/algorithm.md) for the mathematical formulation, with equation references back to the source paper.
|
|
145
|
+
|
|
146
|
+
## Citation
|
|
147
|
+
|
|
148
|
+
If you use this package in academic work, please cite both the software and the underlying paper:
|
|
149
|
+
|
|
150
|
+
> Guo, S., Bolbot, V., & Valdez Banda, O. (2024). An adaptive trajectory compression and feature preservation method for maritime traffic analysis. *Ocean Engineering*, 312, 119189. https://doi.org/10.1016/j.oceaneng.2024.119189
|
|
151
|
+
|
|
152
|
+
A `CITATION.cff` is included so GitHub renders a "Cite this repository" widget.
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
aissegments/__init__.py,sha256=sYtJDLpXS9VAI5tuKrRlSW_XzP5ifEDlS3Gx6Y_b-xo,1071
|
|
2
|
+
aissegments/_types.py,sha256=r5ecX240XuFb2qYrNzTZYHLT8Ko2sUO_DaiFBZkoYbc,4939
|
|
3
|
+
aissegments/adapters.py,sha256=yzXrFPkoIg2vrEJJ-MAqta3dg720ZWsbBjebSm0n14A,13283
|
|
4
|
+
aissegments/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
aissegments/tdkc.py,sha256=v-CHnT6EB7rmSQsa8s4o7wl1yd1RNURhJm48f48EYiM,10403
|
|
6
|
+
aissegments-0.2.0.dist-info/METADATA,sha256=itKGv9NV0149CmWEEnwE1f3BahFum5heq_5mvRSZ5ko,8594
|
|
7
|
+
aissegments-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
8
|
+
aissegments-0.2.0.dist-info/licenses/LICENSE,sha256=IgsaQEbxB00lWlxntjhHIPweHYTzJAYd5tIsvOMrZmg,1072
|
|
9
|
+
aissegments-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Axel Hörteborn
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|