Compressing Fleet Trajectory Archives Without Losing Fidelity

After the encoding work in writing matched trajectories to GeoParquet, the geometry column is four fifths of what remains. The tempting next step is to simplify the lines, and that is the one thing not to do — a simplified line reports a shorter distance, and an archive whose distances are wrong is not an archive of anything.

There are three techniques that shrink the geometry column substantially while keeping every vertex and every digit anybody can measure. Together they typically remove seventy percent of an archive, and none of them changes an answer.


Compatibility and Configuration Requirements

Requirement Value Notes
Coordinate system Projected, metres Delta encoding needs a linear coordinate space
Receiver accuracy Known, per device family Sets the quantisation grid
numpy ≥ 1.26 Integer delta arrays
Parquet writer Any supporting INT32 lists Deltas fit comfortably in 32-bit integers
Reader Your own, or a documented decoder Delta encoding is not standard WKB

That last row is the real cost. A delta-encoded geometry column is no longer readable by a generic geospatial tool, so this is a technique for the deep archive rather than for the tier analysts query directly. Keeping the last two years in standard WKB and delta-encoding everything older is a sensible split.


Three Techniques

Drop what can be recomputed

length_m is ST_Length(geom). mean_speed_kmh is length over duration. Both are convenient and both are derivable, and in a columnar archive each is eight bytes per row that compresses poorly because the values are effectively random.

Removing derivable columns is the cheapest saving available and the one most often overlooked, because in a row store those columns are genuinely worth having. In an archive read by a query engine that can compute them on the fly, they are not.

Quantise to the accuracy you actually have

A double-precision coordinate carries about fifteen significant digits. A consumer-grade GNSS fix has between three and eight metres of real error. Storing the twelfth decimal place of something accurate to five metres is storing noise, and noise is the least compressible thing there is.

import numpy as np

GRID_M = 0.01          # 1 cm — two orders of magnitude finer than the real error

def quantise(xy: np.ndarray, grid_m: float = GRID_M) -> np.ndarray:
    """Round projected coordinates onto a fixed grid and return integers."""
    return np.rint(xy / grid_m).astype(np.int64)

A centimetre grid is far finer than any fleet receiver justifies and still turns a double into an integer that delta-encodes well. The judgement here is to pick a grid you can defend — ten to a hundred times finer than the measurement error — and then never revisit it, because changing the grid means rewriting the archive.

Delta-encode along the line

Consecutive vertices of a matched trajectory are metres apart. Their absolute coordinates are seven-digit numbers with no relationship a compressor can exploit; their differences are two- or three-digit numbers that zstd compresses to almost nothing.

def encode_line(xy: np.ndarray, grid_m: float = GRID_M) -> tuple[np.ndarray, np.ndarray]:
    """Absolute first vertex, integer deltas thereafter."""
    q = quantise(xy, grid_m)
    origin = q[0]
    deltas = np.diff(q, axis=0).astype(np.int32)
    return origin, deltas


def decode_line(origin: np.ndarray, deltas: np.ndarray, grid_m: float = GRID_M) -> np.ndarray:
    q = np.vstack([origin, origin + np.cumsum(deltas, axis=0)])
    return q * grid_m

decode_line(*encode_line(xy)) returns the original coordinates to within the grid, which is the whole claim. Assert it on a sample every time the archive is written — a decoder that drifts is the one failure mode this technique has, and it is silent.

Bytes per vertex, four representations A vertex stored as two double-precision values in WKB costs 16 bytes and compresses to 14. Quantised to centimetre integers it costs 16 and compresses to 9. Delta-encoded it costs 8 and compresses to 2.6. The line itself is identical in every case to within a centimetre. Bytes per vertex after zstd, same geometry throughout WKB, float64 14.0 quantised int64 9.0 delta int32 4.0 delta int32 + zstd 2.6 A five-fold reduction on the dominant column, with every vertex retained and every line unchanged. Simplification would have achieved something similar by deleting the data, and broken every distance.

What It Adds Up To

Applied together to a fleet month, the three techniques compound because each makes the next more effective — dropping columns leaves a higher proportion of compressible geometry, and quantisation is what makes the deltas small enough to encode in 32 bits.

Each technique applied in turn A 4.2 gigabyte month falls to 3.1 gigabytes with tuned encodings, 2.8 after dropping derivable columns, 2.1 after quantisation, and 1.2 after delta encoding — a seventy-one percent reduction with no vertex removed. One fleet month, cumulative reduction as written 4.2 GB + tuned encodings 3.1 GB + drop derivable columns 2.8 GB + quantise to 1 cm 2.1 GB + delta encode 1.2 GB — 71 % smaller Every vertex survives. Distances, dwell times and edge joins return exactly what they did before. Analytical scans get faster too, because the query reads a third of the bytes it used to.

Verifying That Nothing Was Lost

A compression scheme that is claimed to be faithful has to be able to prove it, and the proof belongs in the pipeline rather than in a document.

def assert_roundtrip(sample: list[np.ndarray], grid_m: float = GRID_M) -> None:
    """Every vertex must survive encode/decode to within half a grid cell."""
    for xy in sample:
        back = decode_line(*encode_line(xy, grid_m), grid_m=grid_m)
        assert back.shape == xy.shape, "vertex count changed"
        worst = float(np.abs(back - xy).max())
        assert worst <= grid_m / 2 + 1e-9, f"coordinate moved {worst:.4f} m"


def assert_metrics_stable(before, after, tol_m: float = 0.05) -> None:
    """Derived metrics must be unchanged beyond the quantisation floor."""
    d = abs(before.length - after.length)
    assert d < tol_m, f"length changed by {d:.3f} m"

Both assertions must be able to fail. Point the first at a grid of fifty metres and it fires immediately; point the second at a simplified geometry and it fires too. That is the difference between this technique and simplification, stated as a test rather than as a claim.

Run them on a sample of every archive write, not once during development. The failure mode that matters — a decoder change that drifts by one grid cell per vertex — only appears at scale, and only the cumulative-sum reconstruction can produce it.


Common Pitfalls Specific to This Technique

Quantising in degrees. A grid of 1e-7 degrees is 1.1 cm of latitude and between 1.1 and 0.4 cm of longitude depending on where you are, so the effective precision varies across the fleet. Quantise in a projected CRS, always.

Choosing the grid from the storage saving rather than from the accuracy. A metre grid saves a little more than a centimetre grid and starts being visible in stop centroids and geofence tests. Derive it from the receiver, then leave it alone.

Delta-encoding across line boundaries. Deltas must reset at the start of every linestring. Continuing across a boundary produces one enormous delta and, worse, makes every subsequent reconstruction depend on the previous row’s decode succeeding.

Applying this to the tier analysts query directly. Standard WKB is what makes a file readable by every geospatial tool. Keep the recent tier standard and compress only the deep archive, where the reader is your own pipeline.


Deciding Which Tier to Compress

These techniques trade interoperability for size, so the decision is really about which readers a tier has to serve.

The recent tier serves everybody. Analysts with a notebook, a BI tool, an occasional external consumer. It must be standard GeoParquet with WKB geometry, because the cost of a file that only your pipeline can read is paid by every person who tries to use it.

The deep archive serves one reader. By the time data is three years old, essentially the only thing that opens it is a reprocessing job you control. That tier can afford a custom encoding, because there is exactly one decoder to maintain and it lives beside the writer.

A workable boundary is the retention window of the interactive tools. Whatever period the dashboards and ad-hoc queries actually reach back into stays standard; everything older gets compressed. For most fleets that boundary sits somewhere between eighteen months and three years.

Tier Age Encoding Reader
Hot 0–90 days PostGIS Operational queries
Warm 90 days – 2 years GeoParquet, WKB Anybody
Deep 2 years+ GeoParquet, delta-encoded Your reprocessing job

Migrating between tiers

Compression should be a scheduled job over whole partitions, never a per-row operation. Read a month, encode it, write it under a new prefix, verify the round trip, then delete the original. Verifying before deleting is the entire safety property, and it is easy to lose when the job is rewritten for speed.

Keep the decoder version in the file metadata. A delta-encoded archive whose decoder has changed since it was written is a data-loss incident waiting for someone to notice, and a version string in the metadata turns it into a compatibility check the reader can make on open.

GEO_META["columns"]["geom"]["encoding"] = "delta-int32-cm"
GEO_META["columns"]["geom"]["decoder_version"] = "2"

The other thing worth recording is the grid. A file compressed at a centimetre grid and one compressed at a decimetre grid are visually identical and differ in what they can support downstream, and the only way to tell them apart later is to have written it down.

Encoding follows from who reads the tier The hot tier serves operational queries from PostGIS. The warm tier serves any reader and must stay standard GeoParquet. The deep tier serves only a reprocessing job under your control and can afford a custom delta encoding. Three tiers, three audiences, three encodings hot · 0–90 days PostGIS, indexed operational queries warm · to 2 years GeoParquet, WKB any reader, any tool deep · 2 years+ delta-encoded one reader, yours Put the boundary where the interactive tools stop reaching. Compressing inside that window costs people time. Verify the round trip before deleting the original, every time — that check is the entire safety property. Record the decoder version and the grid in the file metadata; both are unrecoverable once forgotten. A tier nobody reads is a candidate for deletion, not for a cleverer encoding.