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.
@@ -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
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.