Aligning Timestamps Across GPS Feed Sources
Two GPS feeds can each be perfectly accurate about where an asset is and still disagree about when it was there. One receiver stamps positions in device-local time, another in UTC, a third in GPS time that is eighteen leap seconds ahead of UTC; a fourth samples at 1 Hz while its neighbour reports every ten seconds. Fuse them without reconciling the clocks and you get a vessel that appears in two places at once, an interpolated track that folds back on itself, and a freshness comparison that measures skew instead of staleness. This guide is the focused procedure for normalizing timestamps across heterogeneous GPS and AIS feeds — detecting clock skew, collapsing timezone and epoch differences to a single UTC baseline, and resampling to a common interval — before any freshness or coverage check runs. It sits under Temporal Baseline Alignment for Time-Series GIS and within the broader Spatial Data Freshness & Quality Metrics program, for the data engineers, GIS platform administrators, and SREs who own multi-feed ingestion integrity.
What “misaligned” actually means
Timestamp misalignment across GPS sources is not one problem but four, and each needs a different fix. Confusing them is why naïve fusion produces nonsense. The first is timezone ambiguity: a receiver emits 2026-07-14 09:30:00 with no offset, and a downstream parser assumes UTC when the device meant local time, silently shifting every position by hours. The second is epoch difference: GPS time does not observe leap seconds, so a raw GPS-week timestamp runs eighteen seconds ahead of UTC as of this writing, and an AIS receiver reporting UTC seconds-of-minute wraps every sixty seconds with no minute context. The third is clock skew and drift: a cheap oscillator on an unsynchronized device gains or loses hundreds of milliseconds over an hour, so two feeds that agreed at power-on diverge steadily. The fourth is sampling-interval mismatch: a 1 Hz feed and a 0.1 Hz feed cannot be compared point-for-point without resampling one onto the other.
Only the first two are constant offsets that a fixed correction removes. Skew is time-varying and must be measured continuously; interval mismatch is structural and must be resolved by resampling. Treat all four as “the timestamps are wrong” and you will hard-code an offset that masks a drifting clock, or resample away a skew you should have alerted on. This alignment is precisely the temporal-consistency guarantee that Tracking Spatial Data Freshness SLAs assumes is already true before it measures data age.
Normalize every source to UTC epoch first
The non-negotiable first step is collapsing every incoming timestamp to UTC epoch milliseconds at the ingestion boundary, rejecting any payload whose timezone provenance is unknown rather than assuming a default. Assuming UTC for a naive local timestamp is the single most common cause of a track that looks fresh but is hours misfiled. Parse explicitly, convert the epoch difference for GPS-time sources, and stamp the normalized value alongside the original for audit:
from datetime import datetime, timezone, timedelta
# GPS time is ahead of UTC by the accumulated leap-second count (18 s as of 2026).
GPS_UTC_LEAP_OFFSET = timedelta(seconds=18)
def to_utc_epoch_ms(raw_ts: str, source_clock: str, tz_offset_min: int | None) -> int:
if source_clock == "gps": # GPS week/second time, no leap seconds
dt = datetime.fromisoformat(raw_ts).replace(tzinfo=timezone.utc)
dt = dt - GPS_UTC_LEAP_OFFSET # convert GPS -> UTC
elif source_clock == "utc":
dt = datetime.fromisoformat(raw_ts).replace(tzinfo=timezone.utc)
elif source_clock == "local":
if tz_offset_min is None: # refuse to guess a timezone
raise ValueError("local timestamp without a declared offset — rejected")
tz = timezone(timedelta(minutes=tz_offset_min))
dt = datetime.fromisoformat(raw_ts).replace(tzinfo=tz)
else:
raise ValueError(f"unknown source_clock: {source_clock}")
return int(dt.astimezone(timezone.utc).timestamp() * 1000)
The guard on the local branch is the load-bearing line. A feed that cannot state its offset does not get a default; it gets rejected into a dead-letter queue for investigation, because a wrong assumption here is undetectable downstream — the position is valid, the timestamp parses, and the error only surfaces as a spatial contradiction after fusion. Settling the CRS ahead of this, as Coordinate Reference System Validation requires, keeps a projection fault from being misread as a timing fault when the tracks later fail to line up.
Detect skew between feeds
Once every feed is on UTC epoch, skew becomes measurable as the residual offset between two feeds observing the same asset. The cleanest estimator pairs positions that are spatially near-coincident — the same vessel at the same place — and takes the difference of their normalized timestamps. Export that as gis.spatial.clock_skew_seconds, a gauge dimensioned by feed pair, so a drifting oscillator trends visibly rather than hiding in fused output:
-- Estimate per-feed-pair clock skew from spatially co-located fixes.
-- Two feeds reporting the same asset within 25 m should share a timestamp;
-- the residual is clock skew.
WITH paired AS (
SELECT
a.feed_id AS feed_a,
b.feed_id AS feed_b,
a.asset_id,
EXTRACT(EPOCH FROM (a.ts_utc - b.ts_utc)) AS skew_seconds
FROM gps_fixes a
JOIN gps_fixes b
ON a.asset_id = b.asset_id
AND a.feed_id < b.feed_id -- each pair once
AND ST_SRID(a.geom) = ST_SRID(b.geom) -- never compare across datums
AND ST_DWithin(a.geom, b.geom, 25) -- ~co-located (projected metres)
AND abs(EXTRACT(EPOCH FROM (a.ts_utc - b.ts_utc))) < 120 -- within a sane window
)
SELECT feed_a, feed_b,
percentile_cont(0.5) WITHIN GROUP (ORDER BY skew_seconds) AS median_skew_s,
stddev(skew_seconds) AS skew_jitter_s,
COUNT(*) AS sample_pairs
FROM paired
GROUP BY feed_a, feed_b;
A stable non-zero median is a constant offset a correction can absorb; a median that walks over time is genuine drift that needs an alert and, often, an NTP or GPS-disciplined-clock fix at the source. The skew_jitter_s column separates the two — low jitter with a fixed median is correctable, high jitter is an unstable clock. This skew signal feeds the same alignment score the parent guide builds, and it must be reconciled before the Spatial Coverage & Extent Monitoring sweep runs, or a co-located pair split across two interval buckets will read as a coverage gap.
Alert on skew, then resample to a common interval
Skew that exceeds a feed pair’s normal band should page before it corrupts a materialized slice, and the rule belongs on the median, not on raw samples, so one noisy pair does not drown a real drift:
groups:
- name: gps_clock_alignment
rules:
- alert: FeedClockSkewHigh
expr: |
abs(gis_spatial_clock_skew_seconds) > 2
for: 10m
labels: { severity: warning, team: data-engineering }
annotations:
summary: "Clock skew {{ $value }}s between {{ $labels.feed_a }}/{{ $labels.feed_b }}"
- alert: FeedClockSkewDrifting
# skew moving beyond its own trailing baseline — an unsynchronized oscillator
expr: |
abs(gis_spatial_clock_skew_seconds
- avg_over_time(gis_spatial_clock_skew_seconds[6h]))
> 3 * stddev_over_time(gis_spatial_clock_skew_seconds[6h])
for: 15m
labels: { severity: critical, team: sre-gis }
Only after skew is within band should feeds be resampled onto a shared interval for comparison. Snap each normalized timestamp to a common grid — a ten-second bucket for mixed 1 Hz and 0.1 Hz feeds — and reduce to one representative fix per asset per bucket, so downstream joins align point-for-point instead of straddling buckets:
-- Snap normalized fixes onto a shared 10-second grid, one row per asset/bucket.
SELECT DISTINCT ON (asset_id, bucket)
asset_id,
to_timestamp(floor(extract(epoch FROM ts_utc) / 10) * 10) AS bucket,
geom, feed_id
FROM gps_fixes
ORDER BY asset_id, bucket, abs(extract(epoch FROM ts_utc)
- floor(extract(epoch FROM ts_utc) / 10) * 10 - 5);
FAQ
Can I just assume UTC when a feed omits its timezone?
No — that assumption is undetectable when wrong. A naive local timestamp read as UTC produces a valid position at a plausible time that is silently hours off, and nothing downstream can recover the intent. Reject the payload into a dead-letter queue and fix the source contract. The only safe default is refusal, because a wrong offset corrupts every freshness and fusion result that consumes the feed.
Why do GPS and UTC timestamps differ by eighteen seconds?
GPS time does not observe leap seconds, while UTC does, so the two have diverged by the accumulated leap-second count — eighteen seconds as of 2026. A raw GPS-week timestamp is therefore ahead of UTC, and treating it as UTC misplaces a fast-moving asset by the distance it travels in those seconds. Subtract the current leap offset during normalization, and keep the offset configurable because it changes when a new leap second is announced.
How is skew different from freshness lag?
Freshness lag measures how old the newest data is against an SLA window; skew measures whether two feeds agree about when a shared observation happened. A pair can be perfectly fresh and badly skewed, or perfectly aligned and stale. Measure them separately: gis.spatial.clock_skew_seconds for agreement between sources, spatial_freshness_lag_seconds for age. Alerting on one as if it were the other sends the wrong team to the wrong fix.
What interval should I resample to?
Snap to the slowest feed’s native interval, or a small multiple of it, so you never fabricate positions the slow feed never reported. Resampling a 0.1 Hz feed onto a 1 Hz grid invents nine interpolated points per real fix and inflates apparent coverage. A ten-second bucket is a common compromise for mixed vessel and vehicle feeds; pick per fusion job based on the coarsest input, not the finest.
Related
- Temporal Baseline Alignment for Time-Series GIS — the parent guide on event-time vs processing-time coherence this alignment feeds.
- Tracking Spatial Data Freshness SLAs — measures data age, which assumes the clocks are already reconciled.
- Coordinate Reference System Validation — the datum gate that keeps a projection fault from being misread as a timing fault.
- Spatial Coverage & Extent Monitoring — where a mis-bucketed co-located pair would otherwise read as a coverage gap.
- Spatial Data Freshness & Quality Metrics — the program these temporal gates belong to.