Exporting smoothed trajectories to GeoParquet
Exporting a smoothed trajectory correctly means four things must survive the write: the coordinates, the coordinate reference system, the UTC time axis, and the provenance of how the data was cleaned. The reliable pattern is to wrap the smoothed points in a GeoDataFrame with an explicit metric CRS, write partitioned GeoParquet with GeoDataFrame.to_parquet so the CRS lands in file metadata, and immediately round-trip read the dataset to prove the CRS and row count came back intact. Skipping that verification is how teams discover, weeks later, that every stored geometry silently defaulted to EPSG:4326.
Why this happens
A smoothed trajectory is the output of a cleaning stage — typically the drift correction described in handling GPS drift in raw trajectory logs — and by that point the coordinates already sit in a metric projected CRS. The failure mode is that Parquet has no native concept of a coordinate system: the CRS is carried only in an optional file-level metadata key. Any writer that treats geometry as an ordinary binary column drops that key, and the file opens later as if it were plain WGS84.
The full treatment of encodings, partition schemes, and row-group sizing lives in the parent spatial storage formats guide, and the broader modelling context in spatiotemporal data foundations and structures. This page narrows to one job: getting a single cleaned track onto disk as GeoParquet with its CRS and lineage guaranteed, ready to be consumed as a trajectory object.
Core export pipeline
The four steps below run in order — you cannot embed a CRS you have not declared, and you cannot verify a round trip you have not written.
- Build a validated GeoDataFrame. Turn the smoothed
x/y(or corrected lon/lat) columns into point geometry, declare the CRS explicitly, and confirm timestamps are UTC-aware. - Attach lineage metadata. Add
cleaning_versionandsourcecolumns so provenance is queryable per row, and derive adatepartition column from the UTC timestamp. - Write partitioned GeoParquet. Write with
GeoDataFrame.to_parquetso the CRS is embedded, partitioning bydateso time-range scans prune directories. - Round-trip read to verify. Read the dataset back and assert row count, CRS equality, and geometry validity before the data is published.
Production-ready Python implementation
The function below takes a smoothed frame — the corrected output of the drift-correction stage — and exports it. It stores geometry in a metric projected CRS (a UTM zone derived from the data, never raw EPSG:4326) so that any later distance or area predicate is correct, and it handles the edge cases that silently corrupt stored datasets: a missing CRS, an empty frame, and mixed geometry types.
from __future__ import annotations
from pathlib import Path
import geopandas as gpd
import pandas as pd
from shapely.geometry import Point
def export_smoothed_trajectory(
df: pd.DataFrame,
dataset_root: str | Path,
*,
cleaning_version: str,
source: str,
x_col: str = "x_m",
y_col: str = "y_m",
ts_col: str = "ts",
metric_epsg: int = 32633, # UTM zone 33N — set to the zone the smoother used
) -> Path:
"""
Export a smoothed/drift-corrected trajectory to partitioned GeoParquet.
Parameters
----------
df : Smoothed trajectory with projected coordinate columns (``x_col``,
``y_col``, in metres), a UTC-aware ``ts_col``, and a ``traj_id`` column.
dataset_root : Directory root of the partitioned dataset.
cleaning_version : Tag of the cleaning/smoothing pipeline that produced ``df``.
source : Provenance string embedded as a column and in file metadata.
x_col, y_col : Projected coordinate columns in the metric CRS (metres).
ts_col : UTC-aware timestamp column.
metric_epsg : EPSG of the metric projected CRS the coordinates are in.
NEVER 4326 — x/y here are metres, not degrees.
Returns
-------
Path to the dataset root that was written.
Raises
------
ValueError
On missing columns, an empty frame, timezone-naive timestamps, or a
non-metric target CRS.
"""
required = {x_col, y_col, ts_col, "traj_id"}
missing = required - set(df.columns)
if missing:
raise ValueError(f"Missing required columns: {missing}")
# Edge case: empty frame. A zero-row write yields no partition dirs and an
# incomplete schema, so fail loudly instead of writing a silent empty set.
if df.empty:
raise ValueError("Refusing to export an empty trajectory.")
if metric_epsg == 4326:
raise ValueError("metric_epsg must be a projected CRS, not 4326.")
work = df.copy()
# Edge case: timezone-naive timestamps break date partitioning.
work[ts_col] = pd.to_datetime(work[ts_col], utc=True)
# ── Step 1: build point geometry in the metric CRS ────────────────────────
# Coordinates are already metres in the projected CRS; build Points directly
# and declare the CRS so the write can embed it. NEVER label metre values as
# EPSG:4326 — that mislabels a projected frame as geographic.
geometry = [Point(xy) for xy in zip(work[x_col], work[y_col])]
gdf = gpd.GeoDataFrame(work, geometry=geometry, crs=f"EPSG:{metric_epsg}")
# Edge case: guard against mixed geometry types sneaking in via ``work``.
geom_types = set(gdf.geometry.geom_type.unique())
if geom_types != {"Point"}:
raise ValueError(f"Expected only Point geometry, found: {geom_types}")
if gdf.crs is None:
raise ValueError("GeoDataFrame CRS is undefined after construction.")
# ── Step 2: attach lineage + partition columns ────────────────────────────
gdf["cleaning_version"] = cleaning_version
gdf["source"] = source
gdf["date"] = gdf[ts_col].dt.strftime("%Y-%m-%d")
# ── Step 3: write partitioned GeoParquet with embedded CRS ────────────────
# GeoDataFrame.to_parquet serialises the CRS into the file-level ``geo``
# metadata; partition_cols=["date"] lets time-range scans prune directories.
root = Path(dataset_root)
root.mkdir(parents=True, exist_ok=True)
gdf.to_parquet(
root,
partition_cols=["date"],
write_covering_bbox=True,
index=False,
)
return root
Validation block
Run these checks immediately after the export. Each targets a distinct corruption mode — a dropped CRS, a truncated write, or invalid geometry — so a clean pass gives real confidence before the dataset is handed downstream.
def verify_geoparquet_export(
dataset_root: str,
original: gpd.GeoDataFrame,
expected_epsg: int = 32633,
) -> gpd.GeoDataFrame:
"""
Round-trip read a GeoParquet dataset and assert it matches expectations.
Raises AssertionError on any mismatch; returns the reloaded frame on success.
"""
reloaded = gpd.read_parquet(dataset_root)
# 1. CRS survived the write and equals the metric CRS we intended.
assert reloaded.crs is not None, "CRS was lost on write — not read back."
assert reloaded.crs.to_epsg() == expected_epsg, (
f"CRS mismatch: wrote EPSG:{expected_epsg}, read {reloaded.crs.to_epsg()}"
)
# 2. No rows were dropped by partitioning or the write.
assert len(reloaded) == len(original), (
f"Row count changed: {len(original)} → {len(reloaded)}"
)
# 3. Geometry is present, valid, and homogeneous points.
assert reloaded.geometry.notna().all(), "Null geometry after round trip."
assert reloaded.geometry.is_valid.all(), "Invalid geometry after round trip."
assert set(reloaded.geometry.geom_type.unique()) == {"Point"}, (
"Geometry type changed on round trip."
)
# 4. Lineage metadata survived as queryable columns.
for col in ("cleaning_version", "source", "date"):
assert col in reloaded.columns, f"Lineage column missing: {col}"
print(
f"Verified {len(reloaded)} rows, CRS EPSG:{reloaded.crs.to_epsg()}, "
f"{reloaded['date'].nunique()} date partition(s)."
)
return reloaded
The checklist the code enforces:
- CRS equality: the recovered EPSG must equal the one written — the single most common silent failure, since a dropped CRS reads back as
Noneor defaults elsewhere in the stack. - Row-count contract: a partitioned write that drops rows usually means a null in a partition column; the count check catches it before analytics do.
- Geometry validity and type: invalid or unexpectedly mixed geometry indicates an axis swap or a stray non-point row leaking in from upstream.
Common mistakes and gotchas
- Writing with
pyarrow.parquet.write_table. It has no geospatial awareness and never emits thegeometadata key, so the CRS is dropped and geometry becomes an opaque binary column. Always write geometry throughGeoDataFrame.to_parquet. - Mislabelling projected metres as
EPSG:4326. After smoothing,x_m/y_mare metres in a UTM zone. Constructing the GeoDataFrame withcrs="EPSG:4326"tags a projected frame as geographic, and every later distance query is wrong by orders of magnitude. Declare the actual metric EPSG. - Leaving timestamps timezone-naive. A naive
tsproducesdatepartitions shifted by the local UTC offset, so a query for one calendar day silently reads the wrong files. Convert to UTC before deriving the partition column. - Over-partitioning a single track. Partitioning one short trajectory by
dateandhourcan create dozens of directories each holding a handful of rows — the small-files problem. For a single track, adate-only partition (or none) keeps files large enough to be worth opening. The parent spatial storage formats guide covers when finer partitioning actually pays off. - Skipping the round-trip read. A write that returns without error is not proof the CRS or all rows survived. The verification step is cheap relative to re-running an analysis on a silently corrupted dataset — never skip it in a production export.
Related
- Spatial Storage Formats — parent guide covering GeoParquet and GeoArrow encoding, partition schemes, row-group sizing, and DuckDB querying.
- Handling GPS Drift in Raw Trajectory Logs — produces the smoothed, drift-corrected track this export consumes.
- Trajectory Object Design Patterns — the object contract the reloaded GeoParquet rehydrates into.
- Spatiotemporal Data Foundations & Structures — the broader modelling and cleaning context for stored trajectories.
- Discrete Global Grid Systems — the H3 grid used when adding a spatial partition key alongside the date partition.