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.

Reconciling three skewed GPS feeds onto one UTC baseline Three horizontal feed timelines are shown with their sample marks offset from a shared reference grid: feed A is in UTC and on grid, feed B is shifted right by a fixed timezone offset, and feed C is shifted left by a leap-second and drift skew. Each feed passes through a normalize stage that converts to UTC epoch and measures its skew, then a resample stage snaps all three onto a common ten-second interval, producing one aligned baseline row at the bottom. Feed A UTC Feed B +tz Feed C −skew Normalize UTC · skew Aligned Resample 10s grid one row per interval, all feeds on the same UTC grid

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.