Source code: GPS data analysis in Python with MovingPandas: trajectories, speed, stops, generalisation and flow maps

Runnable Python scripts, requirements and console output for the tutorial “GPS data analysis in Python with MovingPandas: trajectories, speed, stops…

Last updated

These are the exact scripts used to produce every figure and number in GPS data analysis in Python with MovingPandas: trajectories, speed, stops, generalisation and flow maps. Download them, install the requirements into a fresh virtual environment and run them from this folder.

Run it
python -m venv .venv
.venv\Scripts\activate # macOS/Linux: source .venv/bin/activate
pip install -r requirements.txt
python gps_trajectories.py

gps_trajectories.py

gps_trajectories.py
"""GPS trajectory analysis in Python with MovingPandas, on one day of real AIS vessel tracks in Puget Sound.
Steps: read the GPS fixes, build trajectories, add speed and direction, detect stops, split into trips,
generalise (Douglas-Peucker against time-based), aggregate into a flow map, and round-trip a track through GPX.
Data: NOAA / BOEM MarineCadastre.gov AIS, 1 June 2023 (US public domain), and US Census Bureau cartographic
boundary county subdivisions for Washington (public domain) as the land layer. No account or key is needed.
Run: python gps_trajectories.py (downloads about 345 MB once into data/)
"""
import time
import warnings
import zipfile
from datetime import timedelta
from pathlib import Path
warnings.filterwarnings("ignore")
import geopandas as gpd
import gpxpy
import gpxpy.gpx
import matplotlib.pyplot as plt
import movingpandas as mpd
import numpy as np
import pandas as pd
import requests
import shapely
from matplotlib.collections import LineCollection
HERE = Path(__file__).parent
DATA, OUT = HERE / "data", HERE / "output"
DATA.mkdir(exist_ok=True)
OUT.mkdir(exist_ok=True)
plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9})
AIS_URL = "https://coast.noaa.gov/htdata/CMSP/AISDataHandler/2023/AIS_2023_06_01.zip"
LAND_URL = "https://www2.census.gov/geo/tiger/GENZ2023/shp/cb_2023_53_cousub_500k.zip"
BBOX = (-122.65, 47.47, -122.25, 47.75) # central Puget Sound: Seattle, Bainbridge, Bremerton, Vashon
UTM = 32610 # WGS 84 / UTM zone 10N, metres
FOCUS = "WSF WALLA WALLA" # the ferry used for the single-trajectory steps
LAND, SEA = "#e9e5dc", "#f4f8fb"
def download(url, path):
if not path.exists():
print(f"downloading {url}")
with requests.get(url, stream=True, timeout=600) as r:
r.raise_for_status()
with open(path, "wb") as f:
for chunk in r.iter_content(1 << 20):
f.write(chunk)
return path
def basemap(ax, land, extent=None):
ax.set_facecolor(SEA)
land.plot(ax=ax, color=LAND, edgecolor="#b8b2a6", linewidth=0.4, zorder=0)
x0, y0, x1, y1 = extent or AREA
ax.set_xlim(x0, x1)
ax.set_ylim(y0, y1)
ax.set_xticks([])
ax.set_yticks([])
ax.set_xlabel("")
ax.set_ylabel("")
ax.set_aspect("equal")
def save(fig, name):
for ax in fig.axes:
if "[metre]" in ax.get_xlabel():
ax.set_xlabel("")
ax.set_ylabel("")
fig.savefig(OUT / name)
plt.close(fig)
def km_bar(ax, length=5000):
x0, x1 = ax.get_xlim()
y0, y1 = ax.get_ylim()
x, y = x0 + 0.04 * (x1 - x0), y0 + 0.05 * (y1 - y0)
ax.plot([x, x + length], [y, y], color="#222", lw=2.5, solid_capstyle="butt")
ax.text(x + length / 2, y + 0.015 * (y1 - y0), f"{length / 1000:g} km", ha="center", va="bottom", fontsize=8)
# ---------------------------------------------------------------- 1. read the GPS fixes
t0 = time.perf_counter()
zip_path = download(AIS_URL, DATA / "AIS_2023_06_01.zip")
cache = DATA / "puget_sound_ais_2023-06-01.csv"
if not cache.exists():
with zipfile.ZipFile(zip_path) as z:
parts = []
for chunk in pd.read_csv(z.open(z.namelist()[0]), chunksize=1_000_000):
keep = chunk.LON.between(BBOX[0], BBOX[2]) & chunk.LAT.between(BBOX[1], BBOX[3])
parts.append(chunk[keep])
pd.concat(parts).to_csv(cache, index=False)
ais = pd.read_csv(cache, parse_dates=["BaseDateTime"])
print(f"AIS fixes in the Puget Sound box on 1 June 2023: {len(ais):,} from {ais.MMSI.nunique()} vessels "
f"(read in {time.perf_counter() - t0:.1f} s)")
print(ais[["MMSI", "BaseDateTime", "LAT", "LON", "SOG", "COG", "VesselName", "VesselType"]].head(3).to_string(index=False))
n0 = len(ais)
ais = ais.drop_duplicates(["MMSI", "BaseDateTime"]).sort_values(["MMSI", "BaseDateTime"])
print(f"duplicate timestamps dropped: {n0 - len(ais):,}")
gaps = ais.groupby("MMSI").BaseDateTime.diff().dt.total_seconds().dropna()
print(f"time between fixes: median {gaps.median():.0f} s, 90th pct {gaps.quantile(0.9):.0f} s, max {gaps.max() / 3600:.1f} h")
land_zip = download(LAND_URL, DATA / "cb_2023_53_cousub_500k.zip")
land = gpd.read_file(land_zip).to_crs(UTM)
pts = gpd.GeoDataFrame(ais, geometry=gpd.points_from_xy(ais.LON, ais.LAT), crs=4326).to_crs(UTM)
AREA = tuple(gpd.GeoSeries([shapely.box(*BBOX)], crs=4326).to_crs(UTM).total_bounds)
land = land.clip(shapely.box(*AREA).buffer(2000))
# ---------------------------------------------------------------- 2. build trajectories
pts = pts.rename(columns={"BaseDateTime": "t"})
t0 = time.perf_counter()
tc = mpd.TrajectoryCollection(pts, "MMSI", t="t", min_length=1000)
print(f"\nTrajectoryCollection: {len(tc)} trajectories longer than 1 km, built in {time.perf_counter() - t0:.1f} s "
f"({ais.MMSI.nunique() - len(tc)} vessels moved less than 1 km and were left out)")
# a vessel that leaves the box and comes back later would be joined by a straight line across land
tc = mpd.ObservationGapSplitter(tc).split(gap=timedelta(minutes=15), min_length=1000)
print(f"ObservationGapSplitter(gap=15 min): {len(tc)} trajectories after splitting at gaps in reception")
focus = next(tr for tr in tc if tr.df.VesselName.iloc[0] == FOCUS)
print(f"{FOCUS}: {sum(tr.df.VesselName.iloc[0] == FOCUS for tr in tc)} trajectory, {len(focus.df):,} fixes, "
f"{focus.get_length() / 1000:.1f} km, {focus.get_start_time():%H:%M} to {focus.get_end_time():%H:%M} UTC")
fig, axs = plt.subplots(1, 2, figsize=(11, 5.2))
basemap(axs[0], land)
pts.plot(ax=axs[0], markersize=0.2, color="#3288bd", alpha=0.35)
axs[0].set_title(f"(a) {len(pts):,} AIS fixes: unordered points")
basemap(axs[1], land)
lines = tc.to_traj_gdf()
lines.plot(ax=axs[1], linewidth=0.5, color="#5e4fa2", alpha=0.6)
focus.to_line_gdf().plot(ax=axs[1], linewidth=1.6, color="#d53e4f")
axs[1].set_title(f"(b) {len(tc)} trajectories (red: {FOCUS.title()})")
km_bar(axs[0])
km_bar(axs[1])
save(fig, "01-points-to-trajectories.png")
# ---------------------------------------------------------------- 3. speed and direction
focus.add_speed(overwrite=True, units=("km", "h"))
focus.add_direction(overwrite=True)
f = focus.df
sog_kmh = f.SOG * 1.852
moving = sog_kmh > 5
diff = (f.speed - sog_kmh)[moving]
ang = ((f.direction - f.COG + 180) % 360 - 180)[moving]
print(f"\n{FOCUS} speed from positions vs reported SOG (moving fixes, n={moving.sum()}): "
f"median |difference| {diff.abs().median():.2f} km/h, r = {np.corrcoef(f.speed[moving], sog_kmh[moving])[0, 1]:.3f}")
print(f"direction from positions vs reported COG: median |difference| {ang.abs().median():.1f} degrees")
print(f"max computed speed {f.speed.max():.1f} km/h ({f.speed.max() / 1.852:.1f} kn); "
f"max reported SOG {f.SOG.max():.1f} kn")
tc.add_speed(overwrite=True, units=("km", "h"))
allp = tc.to_point_gdf()
fast = allp[allp.speed > 100]
print(f"fixes implying more than 100 km/h across all vessels: {len(fast)} in {fast.MMSI.nunique()} trajectories")
cleaned = mpd.OutlierCleaner(tc).clean(v_max=100, units=("km", "h"))
print(f"OutlierCleaner(v_max=100 km/h) removed {len(allp) - len(cleaned.to_point_gdf()):,} fixes")
tc = cleaned
focus_clean = mpd.OutlierCleaner(focus).clean(v_max=45, units=("km", "h"))
focus_clean.add_speed(overwrite=True, units=("km", "h"))
print(f"OutlierCleaner(v_max=45 km/h) on {FOCUS}: removed {len(focus.df) - len(focus_clean.df)} fixes; "
f"max speed now {focus_clean.df.speed.max():.1f} km/h")
fig = plt.figure(figsize=(11, 7))
gs = fig.add_gridspec(2, 2, height_ratios=[0.9, 1], hspace=0.28)
ax = fig.add_subplot(gs[0, :])
basemap(ax, land)
ln = focus.to_line_gdf()
ln["speed"] = focus.df.speed.iloc[1:].values
seg = LineCollection([np.asarray(g.coords) for g in ln.geometry], cmap="Spectral_r", linewidths=2)
seg.set_array(ln.speed.values)
seg.set_clim(0, 40)
ax.add_collection(seg)
x0, y0, x1, y1 = focus.to_traj_gdf().total_bounds
ax.set_xlim(x0 - 1500, x1 + 1500)
ax.set_ylim(y0 - 1500, y1 + 1500)
fig.colorbar(seg, ax=ax, shrink=0.8, pad=0.01, label="speed from positions (km/h)")
km_bar(ax, 2000)
ax.set_title(f"(a) {FOCUS.title()}, Bainbridge Island (left) to Seattle (right): speed from add_speed()")
ax = fig.add_subplot(gs[1, 0])
ax.plot(f.index, sog_kmh, color="#3288bd", lw=1.6, label="reported SOG")
ax.plot(f.index, f.speed, color="#d53e4f", lw=0.8, label="add_speed()")
ax.set_ylabel("km/h")
ax.legend(loc="upper center", fontsize=7, ncol=2)
ax.set_ylim(0, 70)
ax.set_title("(b) speed: from positions vs reported")
ax.set_xlabel("1 June 2023 (UTC)")
ax.xaxis.set_major_formatter(plt.matplotlib.dates.DateFormatter("%H:%M"))
ax = fig.add_subplot(gs[1, 1], sharex=ax)
ax.scatter(f.index[moving], f.COG[moving], s=4, color="#3288bd", label="reported COG")
ax.scatter(f.index[moving], f.direction[moving], s=1.5, color="#d53e4f", label="add_direction()")
ax.set_ylabel("degrees from north")
ax.set_yticks([0, 90, 180, 270, 360])
ax.set_ylim(0, 400)
ax.legend(loc="upper center", fontsize=7, ncol=2, markerscale=3)
ax.set_title("(c) direction while moving (> 5 km/h)")
ax.set_xlabel("1 June 2023 (UTC)")
ax.xaxis.set_major_formatter(plt.matplotlib.dates.DateFormatter("%H:%M"))
save(fig, "02-speed-direction.png")
focus = focus_clean
# ---------------------------------------------------------------- 4. stop detection
ferries = mpd.TrajectoryCollection([tr for tr in tc if str(tr.df.VesselName.iloc[0]).startswith("WSF ")])
print(f"\nWashington State Ferries in the box: {len(ferries)} trajectories from "
+ ", ".join(sorted({tr.df.VesselName.iloc[0].replace('WSF ', '').title() for tr in ferries})))
t0 = time.perf_counter()
detector = mpd.TrajectoryStopDetector(ferries)
stops = detector.get_stop_points(max_diameter=150, min_duration=timedelta(minutes=5))
print(f"TrajectoryStopDetector(max_diameter=150 m, min_duration=5 min): {len(stops)} stops in "
f"{time.perf_counter() - t0:.1f} s")
# group the stop points into places: stops within 500 m of each other belong to the same terminal
blobs = gpd.GeoDataFrame(geometry=list(stops.buffer(250).union_all().geoms), crs=UTM)
blobs["place"] = range(len(blobs))
stops = gpd.sjoin(stops, blobs, predicate="within").drop(columns="index_right")
TERMINALS = {"Seattle (Colman Dock)": (-122.3385, 47.6025), "Bainbridge Island": (-122.5097, 47.6229),
"Bremerton": (-122.6237, 47.5623), "Fauntleroy": (-122.3963, 47.5232),
"Vashon": (-122.4637, 47.5108), "Southworth": (-122.4957, 47.5130)}
term = gpd.GeoDataFrame({"name": list(TERMINALS)}, geometry=gpd.points_from_xy(*zip(*TERMINALS.values())),
crs=4326).to_crs(UTM)
places = stops.dissolve("place", aggfunc={"duration_s": ["count", "median", "sum"]})
places.columns = ["geometry", "n_stops", "median_dwell_s", "total_s"]
places["geometry"] = places.centroid
places = places.set_crs(UTM, allow_override=True)
places = gpd.sjoin_nearest(places, term, max_distance=600, how="left").drop(columns="index_right")
places["name"] = places.name.fillna("unnamed")
places = places.sort_values("n_stops", ascending=False)
print("stop places (stops within 500 m grouped):")
print(places.assign(median_dwell_min=(places.median_dwell_s / 60).round(1), hours_stopped=(places.total_s / 3600).round(1),
lon=places.to_crs(4326).geometry.x.round(4), lat=places.to_crs(4326).geometry.y.round(4))
[["name", "n_stops", "median_dwell_min", "hours_stopped", "lon", "lat"]].to_string(index=False))
fig, ax = plt.subplots(figsize=(8.5, 6.4))
basemap(ax, land)
ferries.to_traj_gdf().plot(ax=ax, color="#5e4fa2", lw=0.4, alpha=0.5)
sc = ax.scatter(stops.geometry.x, stops.geometry.y, s=8 + stops.duration_s.clip(upper=3600) / 60 * 3,
c=stops.duration_s / 60, cmap="Spectral_r", vmin=5, vmax=60, edgecolor="#333", linewidth=0.3,
alpha=0.85, zorder=3)
OFFSETS = {"Seattle (Colman Dock)": (8, 10), "Bainbridge Island": (-10, 14), "Bremerton": (6, 12),
"Fauntleroy": (10, 4), "Vashon": (14, -26), "Southworth": (-70, 14)}
for _, p in places.iterrows():
label = p["name"] if p["name"] != "unnamed" else "berthed all day"
dwell = (f"{p.n_stops} stops, median {p.median_dwell_s / 60:.0f} min" if p.n_stops > 1
else f"1 stop of {p.median_dwell_s / 3600:.0f} h")
ax.annotate(f"{label}\n{dwell}",
(p.geometry.x, p.geometry.y), xytext=OFFSETS.get(p["name"], (8, -22)), textcoords="offset points",
fontsize=7, bbox=dict(boxstyle="square,pad=0.2", fc="white", ec="none", alpha=0.85), zorder=4)
fig.colorbar(sc, ax=ax, shrink=0.6, label="stop duration (minutes; marker size capped at 60)", extend="max")
km_bar(ax)
ax.set_title(f"{len(stops)} ferry stops detected (max_diameter 150 m, min_duration 5 min)")
save(fig, "03-stop-detection.png")
# ---------------------------------------------------------------- 5. splitting into trips
trips = mpd.StopSplitter(ferries).split(max_diameter=150, min_duration=timedelta(minutes=5), min_length=1000)
print(f"\nStopSplitter(max_diameter=150 m, min_duration=5 min, min_length=1 km): {len(ferries)} ferry "
f"trajectories -> {len(trips)} trips")
def nearest_place(pt):
d = places.distance(pt)
return places.name.iloc[int(np.argmin(d.values))] if d.min() < 1500 else "edge of box"
rows = []
for tr in trips:
rows.append({"ferry": tr.df.VesselName.iloc[0].replace("WSF ", "").title(),
"from": nearest_place(tr.get_start_location()), "to": nearest_place(tr.get_end_location()),
"minutes": tr.get_duration().total_seconds() / 60, "km": tr.get_length() / 1000,
"traj": tr})
trip_df = pd.DataFrame(rows)
trip_df["route"] = trip_df.apply(lambda r: " - ".join(sorted([r["from"], r["to"]])), axis=1)
summary = (trip_df.groupby("route").agg(trips=("minutes", "size"), median_min=("minutes", "median"),
median_km=("km", "median")).sort_values("trips", ascending=False))
print(summary.round(1).to_string())
ft = trip_df[trip_df.ferry == FOCUS.replace("WSF ", "").title()]
print(f"{FOCUS} trips: {len(ft)}, median crossing {ft.minutes.median():.0f} min over {ft.km.median():.1f} km")
fig = plt.figure(figsize=(10, 9))
gs = fig.add_gridspec(2, 1, height_ratios=[1.35, 1], hspace=0.22)
ax0 = fig.add_subplot(gs[0])
basemap(ax0, land)
cmap = plt.get_cmap("Spectral")
for i, (_, r) in enumerate(ft.iterrows()):
r.traj.to_traj_gdf().plot(ax=ax0, color=cmap(i / max(len(ft) - 1, 1)), lw=1.3)
x0, y0, x1, y1 = focus.to_traj_gdf().total_bounds
ax0.set_xlim(x0 - 1500, x1 + 1500)
ax0.set_ylim(y0 - 1500, y1 + 1500)
km_bar(ax0, 2000)
ax0.set_title(f"(a) {FOCUS.title()} split into {len(ft)} trips, one colour each")
ax1 = fig.add_subplot(gs[1])
top = summary[summary.trips >= 4].index
ROUTE_COLOURS = ["#d53e4f", "#f46d43", "#66c2a5", "#3288bd", "#5e4fa2", "#999999"]
for i, route in enumerate(top):
v = trip_df[trip_df.route == route].minutes
ax1.hist(v, bins=np.arange(0, 80, 2.5), alpha=0.8, color=ROUTE_COLOURS[i % len(ROUTE_COLOURS)],
label=f"{route} ({len(v)} trips)")
ax1.set_xlabel("trip duration (minutes)")
ax1.set_ylabel("trips")
ax1.legend(fontsize=7.5)
ax1.set_title("(b) duration of every ferry trip by route")
save(fig, "04-split-trips.png")
# ---------------------------------------------------------------- 6. generalisation: Douglas-Peucker vs time-based
fdf = focus.df
tt = (fdf.index - fdf.index[0]).total_seconds().values
xy = np.c_[fdf.geometry.x, fdf.geometry.y]
def errors(gen):
"""Spatial error (distance to the generalised line) and synchronous error (distance to where the
generalised trajectory says the vessel was at the same moment), for every original fix."""
g = gen.df
gt = (g.index - fdf.index[0]).total_seconds().values
gx, gy = np.interp(tt, gt, g.geometry.x), np.interp(tt, gt, g.geometry.y)
sync = np.hypot(xy[:, 0] - gx, xy[:, 1] - gy)
line = gen.to_linestring()
spatial = shapely.distance(shapely.points(xy), line)
errors.last_sync_xy = np.c_[gx, gy]
return len(g), spatial, sync
sweep = []
methods = {"Douglas-Peucker": (mpd.DouglasPeuckerGeneralizer, [2, 5, 10, 20, 50, 100, 200, 400], "m"),
"Top-down time ratio": (mpd.TopDownTimeRatioGeneralizer, [2, 5, 10, 20, 50, 100, 200, 400], "m"),
"Min. time delta": (mpd.MinTimeDeltaGeneralizer,
[timedelta(minutes=m) for m in (2, 3, 5, 10, 15, 20, 30, 60)], "min")}
for name, (cls, tols, unit) in methods.items():
for tol in tols:
t0 = time.perf_counter()
gen = cls(focus).generalize(tolerance=tol)
secs = time.perf_counter() - t0
n, sp, sy = errors(gen)
sweep.append({"method": name, "tolerance": tol if unit == "m" else tol.total_seconds() / 60,
"unit": unit, "points": n, "kept_pct": 100 * n / len(fdf), "spatial_max_m": sp.max(),
"spatial_mean_m": sp.mean(), "sync_mean_m": sy.mean(), "sync_max_m": sy.max(), "secs": secs})
sweep = pd.DataFrame(sweep)
print(f"\ngeneralising {FOCUS} ({len(fdf):,} fixes):")
print(sweep[["method", "tolerance", "unit", "points", "kept_pct", "spatial_max_m", "spatial_mean_m",
"sync_mean_m", "sync_max_m", "secs"]].round(2).to_string(index=False))
picks = {"Douglas-Peucker": 50, "Top-down time ratio": 50, "Min. time delta": 10}
crossing = ft.sort_values("minutes").iloc[len(ft) // 2].traj
c0, c1 = crossing.get_start_time(), crossing.get_end_time()
win = (fdf.index >= c0) & (fdf.index <= c1)
appr = fdf[(fdf.index >= c0) & (fdf.index <= c0 + timedelta(minutes=9))]
ax_, ay_ = appr.geometry.x.mean(), appr.geometry.y.mean()
half = 0.6 * max(np.ptp(appr.geometry.x), np.ptp(appr.geometry.y)) + 300
zoom = (ax_ - half, ay_ - half, ax_ + half, ay_ + half)
print(f"figure 5 zooms on the Bainbridge approach of one crossing: {c0:%H:%M}-{c1:%H:%M} UTC, "
f"{win.sum()} original fixes")
fig = plt.figure(figsize=(11, 8.4))
gs = fig.add_gridspec(2, 6, height_ratios=[1, 0.95], hspace=0.3, wspace=0.6)
for i, (name, tol) in enumerate(picks.items()):
cls = methods[name][0]
gen = cls(focus).generalize(tolerance=tol if name != "Min. time delta" else timedelta(minutes=tol))
n, sp, sy = errors(gen)
sxy = errors.last_sync_xy
ax = fig.add_subplot(gs[0, 2 * i:2 * i + 2])
basemap(ax, land, extent=zoom)
for (ox, oy), (sx, sy_) in zip(xy[win], sxy[win]):
ax.plot([ox, sx], [oy, sy_], color="#3288bd", lw=0.9, zorder=2)
ax.plot(xy[win, 0], xy[win, 1], color="#bbb", lw=2.2, zorder=1)
ax.scatter(xy[win, 0], xy[win, 1], s=6, color="#555", zorder=2)
g = gen.df
gw = (g.index >= c0) & (g.index <= c1)
ax.plot(g.geometry.x[gw], g.geometry.y[gw], color="#d53e4f", lw=1.1, marker="o", ms=3.5, zorder=3)
km_bar(ax, 500)
unit = "min" if name == "Min. time delta" else "m"
ax.set_title(f"{name}, {tol} {unit}\n{n} of {len(fdf)} fixes kept\nmean sync. error, this crossing: "
f"{sy[win].mean():.0f} m", fontsize=8)
for j, (metric, label) in enumerate([("spatial_max_m", "max. distance to generalised line (m)"),
("sync_mean_m", "mean synchronous error (m)")]):
ax = fig.add_subplot(gs[1, 3 * j:3 * j + 3])
for k, (name, grp) in enumerate(sweep.groupby("method", sort=False)):
ax.plot(grp.kept_pct, grp[metric], marker="o", ms=4, color=["#d53e4f", "#3288bd", "#66a61e"][k], label=name)
ax.set_xscale("log")
ax.set_yscale("log")
ax.set_xlabel("fixes kept (%)")
ax.set_ylabel(label)
ax.grid(alpha=0.3, which="both")
ax.legend(fontsize=7)
fig.suptitle(f"Generalising {FOCUS.title()}: grey = original fixes, red = kept fixes, "
f"blue = offset to the synchronous position", y=0.97, fontsize=9.5)
save(fig, "05-generalisation.png")
# ---------------------------------------------------------------- 7. aggregation into a flow map
movers = mpd.TrajectoryCollection([tr for tr in tc if tr.df.SOG.max() > 3])
gen_tc = mpd.MinDistanceGeneralizer(movers).generalize(tolerance=100)
t0 = time.perf_counter()
agg = mpd.TrajectoryCollectionAggregator(gen_tc, max_distance=2000, min_distance=200,
min_stop_duration=timedelta(minutes=5))
sig = agg.get_significant_points_gdf()
clusters = agg.get_clusters_gdf()
flows = agg.get_flows_gdf()
print(f"\nflow map from {len(movers)} vessels that moved faster than 3 kn: {len(sig):,} significant points, "
f"{len(clusters)} clusters, {len(flows)} flows in {time.perf_counter() - t0:.1f} s")
flows = flows.sort_values("weight")
print(f"flow weights: median {flows.weight.median():.0f}, max {flows.weight.max():.0f}; "
f"flows with weight >= 5: {(flows.weight >= 5).sum()}")
fig, axs = plt.subplots(1, 2, figsize=(11, 5.2))
fig.subplots_adjust(right=0.9, wspace=0.04)
basemap(axs[0], land)
movers.to_traj_gdf().plot(ax=axs[0], lw=0.4, color="#5e4fa2", alpha=0.5)
sig.plot(ax=axs[0], markersize=1.5, color="#f46d43", alpha=0.6)
clusters.plot(ax=axs[0], markersize=12, color="#222", marker="x")
axs[0].set_title(f"(a) {len(sig):,} significant points, {len(clusters)} cluster centres")
basemap(axs[1], land)
w = flows.weight.clip(upper=60)
cax = fig.add_axes([0.915, 0.25, 0.012, 0.5])
flows.plot(ax=axs[1], column="weight", cmap="Spectral_r", linewidth=0.4 + w / 8, vmin=1, vmax=60, legend=True,
cax=cax, legend_kwds={"label": "trajectories per flow"})
axs[1].set_title(f"(b) {len(flows)} aggregated flows between clusters")
km_bar(axs[0])
km_bar(axs[1])
save(fig, "06-flow-map.png")
# ---------------------------------------------------------------- 8. GPX in and out
trip = ft.sort_values("minutes").iloc[len(ft) // 2].traj.to_crs(4326)
gpx = gpxpy.gpx.GPX()
track = gpxpy.gpx.GPXTrack(name=f"{FOCUS.title()} {trip.get_start_time():%Y-%m-%d %H:%M} UTC")
gpx.tracks.append(track)
seg = gpxpy.gpx.GPXTrackSegment()
track.segments.append(seg)
for t, p in zip(trip.df.index, trip.df.geometry):
seg.points.append(gpxpy.gpx.GPXTrackPoint(p.y, p.x, time=t.to_pydatetime()))
gpx_path = OUT / "ferry_trip.gpx"
gpx_path.write_text(gpx.to_xml(), encoding="utf-8")
print(f"\nwrote {gpx_path.name}: {len(seg.points)} track points, {gpx_path.stat().st_size / 1024:.1f} KB")
gpx_pts = gpd.read_file(gpx_path, layer="track_points")
print(f"read back with geopandas (layer='track_points'): {len(gpx_pts)} rows, columns include "
f"{[c for c in ('track_fid', 'track_seg_point_id', 'time', 'geometry') if c in gpx_pts.columns]}")
gpx_pts["time"] = pd.to_datetime(gpx_pts.time).dt.tz_localize(None)
gpx_traj = mpd.Trajectory(gpx_pts.to_crs(UTM), traj_id="gpx", t="time")
gpx_traj.add_speed(units=("km", "h"))
print(f"GPX trajectory: {gpx_traj.get_length() / 1000:.2f} km in {gpx_traj.get_duration()}, "
f"max speed {gpx_traj.df.speed.max():.1f} km/h (original trip {trip.to_crs(UTM).get_length() / 1000:.2f} km)")
with open(gpx_path, encoding="utf-8") as fh:
parsed = gpxpy.parse(fh)
first = parsed.tracks[0].segments[0].points[0]
print(f"gpxpy: {parsed.tracks[0].name}; first point {first.latitude:.5f}, {first.longitude:.5f} at {first.time}; "
f"gpxpy length_2d {parsed.length_2d() / 1000:.2f} km")