Deterministic UTC Timestamping for Multi-Instrument Runs

Correlating a spectrometer, a source-measure unit, and a temperature controller into one causal record fails the moment you trust each device’s own clock. This guide builds a single authoritative, monotonic UTC time base on the host, stamps every sample deterministically, and bounds the skew of instruments that free-run their internal clocks — the timestamping stage of Metadata Injection Workflows inside the broader Data Capture, Validation & Metadata Sync architecture. It is the concrete answer to the multi-vendor timestamp-skew hazard that the parent guide names but leaves to the acquisition layer to solve.

Scope: One Host Time Base, Not Per-Device Wall Clocks

The problem is narrow and specific. Several instruments acquire in parallel during a single run, each reporting a timestamp from its own free-running RTC — a lock-in amplifier that was last set months ago, a DAQ card with no battery-backed clock at all, a legacy controller still emitting local time with a daylight-saving offset. Those timestamps disagree by seconds to minutes and drift against each other over a long run, so using any of them as the ordering key across devices reconstructs the wrong causal sequence. The fix is to elect the host as the sole time authority: every sample is stamped at ingestion with a host-derived UTC value and a monotonic sequence number, and the instrument’s own reported time is preserved only as an attribute for later diagnosis, never as an ordering key.

Two assumptions bound this. First, the host clock is disciplined by NTP or, for sub-millisecond labs, PTP (IEEE 1588) — we care about its offset from true UTC and, more importantly, that we never read a stepping wall clock for interval math. Second, ingestion latency between an instrument emitting a sample and the host stamping it is bounded and roughly symmetric per device, so the residual skew is estimable rather than unbounded. Fields are normalized to RFC 3339 exactly as the parent guide’s validation gates require, and the sequence counter stamped here is the same sequence_no an Async Command Queuing System relies on to reorder out-of-order broker deliveries. Instruments are reached over whatever transport applies — USB-TMC, a VISA Resource Manager, or RS-232 — because the time base consumes already-parsed samples and is transport-agnostic.

Why time.monotonic() and time.time() Are Not Interchangeable

time.time() returns wall-clock UTC but is not monotonic: NTP slews it continuously and steps it discontinuously, so subtracting two time.time() readings to measure an interval can yield a negative number or a jump the instant a correction lands. time.monotonic() never goes backward and is immune to clock adjustments, but it has no defined epoch — its zero is arbitrary and meaningless as a calendar time. Neither alone is sufficient: you need monotonic ordering and a real UTC calendar value.

The resolution is to anchor the two together exactly once, at the start of a run, and derive every subsequent UTC timestamp from the monotonic clock rather than re-reading the wall clock. Capture an anchor pair (mono_0, utc_0) as close to simultaneously as the runtime allows, then map any later monotonic reading to UTC:

Because m is monotonic, t_utc is monotonic too, so sample timestamps never invert even if NTP steps the wall clock mid-run. The anchor absorbs the host’s absolute offset once; the monotonic delta carries reproducible interval structure on top of it. The cost is a slow drift between the anchored UTC and true UTC as the two oscillators diverge (a monotonic source and the NTP-disciplined wall clock rarely tick at identical rates), which is why long runs re-anchor periodically rather than trusting one anchor for hours.

For each instrument, the offset between its clock and the host is estimated with a round-trip exchange in the style of NTP’s own algorithm. Send a query at host time t0, the instrument replies carrying its local time t1 (receive) and t2 (transmit), and the reply lands at host time t3. Assuming a symmetric path, the clock offset and round-trip delay are:

θ estimates how far the instrument clock leads or lags the host; δ is the round-trip delay, and δ/2 bounds the residual uncertainty in θ under the symmetry assumption. Sampling θ several times and keeping the estimate with the smallest δ filters transient scheduling jitter — the minimum-delay sample is the least contaminated by queuing.

Host time base reconciling three free-running instrument clocks A central horizontal axis labelled host monotonic to UTC anchor is the single authority. Three instrument lanes above and below each run their own clock, offset from the host. A round-trip exchange between host time t0 and t3 and instrument times t1 and t2 yields the offset theta. Each acquired sample is stamped with a monotonic sequence number, a host UTC value derived from the anchor, and the instrument local time carried only as an attribute. The reconciler subtracts each instrument's estimated offset to place all samples on one causal axis. HOST time base monotonic → UTC anchor authoritative UTC axis → (mono₀, utc₀) t0→t1 t2→t3 θ = ((t₁−t₀)+(t₂−t₃))/2 Instrument A RTC leads +1.4 s Instrument B DST-local, lags −0.8 s Instrument C no RTC, drifts per-sample stamp seq_no ↑ · host_utc (key) instrument_local (attribute)

The host holds the only authoritative axis: its monotonic clock is anchored to UTC once, each instrument’s offset θ is measured by round-trip, and every sample carries a monotonic seq_no, a host UTC key, and the instrument’s own time as a non-authoritative attribute.

A TimeBase That Anchors Once and a Cross-Instrument Reconciler

The TimeBase below captures the monotonic-to-UTC anchor a single time, issues strictly increasing timestamps regardless of what NTP does to the wall clock, and re-anchors only when explicitly asked. The InstrumentReconciler estimates each device’s offset from round-trip samples and rewrites instrument-local times onto the host axis. Timestamps are emitted as RFC 3339 with an explicit Z, ready for the injector’s normalize_utc gate.

from __future__ import annotations

import time
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone


@dataclass(frozen=True)
class Stamp:
    """A single sample's authoritative time coordinates."""
    seq_no: int
    host_utc: str                     # RFC 3339, the causal ordering key
    mono: float                       # raw monotonic reading (interval math)
    instrument_local: str | None = None  # reported time — attribute ONLY


class TimeBase:
    """Anchors monotonic → UTC once and issues deterministic timestamps.

    Every timestamp is derived from ``time.monotonic()`` plus a fixed
    anchor, so the sequence is strictly increasing even if NTP steps the
    wall clock mid-run. ``time.time()`` is read only at anchor time.
    """

    def __init__(self, *, reanchor_after_s: float = 900.0) -> None:
        self._reanchor_after = reanchor_after_s
        self._seq = 0
        self._anchor_mono = 0.0
        self._anchor_utc = datetime.now(timezone.utc)
        self._reanchor()

    def _reanchor(self) -> None:
        """Re-capture the (monotonic, UTC) pair, bracketing to bound skew."""
        m0 = time.monotonic()
        wall = datetime.now(timezone.utc)
        m1 = time.monotonic()
        # Assign the wall reading to the midpoint of the bracket.
        self._anchor_mono = (m0 + m1) / 2.0
        self._anchor_utc = wall
        self._anchor_wall_deadline = m1 + self._reanchor_after

    def _mono_to_utc(self, mono: float) -> datetime:
        return self._anchor_utc + timedelta(seconds=mono - self._anchor_mono)

    def stamp(self, instrument_local: str | None = None) -> Stamp:
        """Issue the next monotonic, UTC-anchored timestamp for a sample."""
        mono = time.monotonic()
        if mono > self._anchor_wall_deadline:
            self._reanchor()
        self._seq += 1
        utc = self._mono_to_utc(mono)
        return Stamp(
            seq_no=self._seq,
            host_utc=utc.isoformat(timespec="microseconds").replace("+00:00", "Z"),
            mono=mono,
            instrument_local=instrument_local,
        )


@dataclass
class InstrumentReconciler:
    """Estimates one instrument's clock offset from round-trip probes.

    offset  θ = ((t1 - t0) + (t2 - t3)) / 2
    delay   δ = (t3 - t0) - (t2 - t1)
    Keeps the θ from the probe with the smallest δ (least jitter).
    """
    instrument_id: str
    _best_delay: float = field(default=float("inf"))
    _offset: float = 0.0

    def observe(self, t0: float, t1: float, t2: float, t3: float) -> None:
        """Feed one round-trip: t0/t3 host clock, t1/t2 instrument clock."""
        delay = (t3 - t0) - (t2 - t1)
        if delay < 0:
            raise ValueError(f"negative round-trip delay for {self.instrument_id}")
        if delay < self._best_delay:
            self._best_delay = delay
            self._offset = ((t1 - t0) + (t2 - t3)) / 2.0

    @property
    def skew_bound_s(self) -> float:
        """Residual uncertainty in the offset estimate, ±δ/2."""
        return self._best_delay / 2.0

    def to_host_utc(self, instrument_epoch_s: float, base: TimeBase) -> Stamp:
        """Map an instrument-clock reading onto the host UTC axis."""
        host_epoch = instrument_epoch_s - self._offset
        local_iso = datetime.fromtimestamp(
            instrument_epoch_s, tz=timezone.utc
        ).isoformat(timespec="microseconds").replace("+00:00", "Z")
        stamp = base.stamp(instrument_local=local_iso)
        # Reconciled host time overrides the raw ingestion stamp when the
        # instrument's own event time (offset-corrected) is more precise.
        corrected = datetime.fromtimestamp(host_epoch, tz=timezone.utc)
        return Stamp(
            seq_no=stamp.seq_no,
            host_utc=corrected.isoformat(timespec="microseconds").replace("+00:00", "Z"),
            mono=stamp.mono,
            instrument_local=local_iso,
        )

Three choices carry the design. The anchor is captured inside a monotonic bracket (m0, m1) and the wall reading assigned to its midpoint, so the anchor’s own uncertainty is bounded by half the bracket width rather than left unmeasured. stamp() reads only time.monotonic() on the hot path — the wall clock is touched exactly once per anchor, never per sample, so a mid-run NTP step cannot corrupt the running sequence. And the reconciler keeps the minimum-delay θ, because under queuing jitter the fastest round trip is the one whose symmetry assumption holds best.

Verifying Monotonicity and Bounded Skew Over a Run

Two invariants make the time base trustworthy, and both are assertable without hardware. First, host_utc and seq_no must be strictly increasing across every sample of a run even when the wall clock is stepped backward underneath the process. Second, each instrument’s residual skew must stay inside a declared bound for the whole run. The harness below fakes an NTP step by monkeypatching the wall clock and asserts the sequence never inverts.

import time
from datetime import datetime, timedelta, timezone

from timebase import InstrumentReconciler, TimeBase


def test_monotonic_under_ntp_step(monkeypatch) -> None:
    base = TimeBase(reanchor_after_s=1e9)  # never auto-reanchor mid-test
    first = [base.stamp() for _ in range(50)]

    # Simulate NTP stepping the wall clock 5 s backward.
    real_now = datetime.now
    monkeypatch.setattr(
        "timebase.datetime",
        type("D", (), {"now": staticmethod(
            lambda tz=None: real_now(tz) - timedelta(seconds=5))}),
    )
    rest = [base.stamp() for _ in range(50)]

    stamps = first + rest
    seqs = [s.seq_no for s in stamps]
    utcs = [s.host_utc for s in stamps]
    assert seqs == sorted(seqs), "sequence numbers must never invert"
    assert utcs == sorted(utcs), "UTC stamps stayed monotonic through the step"


def test_skew_stays_bounded() -> None:
    rec = InstrumentReconciler("lockin-A")
    # Instrument clock leads host by ~1.4 s; feed jittery round trips.
    for jitter in (0.030, 0.008, 0.055, 0.012):
        t0 = time.monotonic()
        t1 = t0 + 1.4 + jitter / 2      # instrument receive
        t2 = t1 + 0.001                 # instrument transmit
        t3 = t2 - 1.4 + jitter / 2      # host receive
        rec.observe(t0, t1, t2, t3)
    assert abs(rec._offset - 1.4) < 0.02
    assert rec.skew_bound_s < 0.010, "best-delay probe bounds skew under 10 ms"

On a live rig, the operational check is a log invariant: emit seq_no, host_utc, and the per-instrument skew_bound_s on every batch, and alert when any device’s bound crosses a threshold — the same discipline Threshold Tuning & Alerting applies to injection latency. A healthy run shows skew bounds flat at a few milliseconds; a bound that climbs steadily over hours is drift, and a bound that jumps is a re-anchor or an instrument clock reset.

Failure Modes Specific to Multi-Instrument Timestamping

NTP steps the host clock during acquisition. If any per-sample timestamp is read from time.time() instead of the anchored monotonic clock, a mid-run correction injects a backward jump and two samples acquire the same or inverted host_utc, silently corrupting causal order. The signature is a run of non-increasing timestamps near a syslog ntpd step. The fix is architectural, not cosmetic: never read the wall clock on the sample path, only at anchor time, exactly as TimeBase.stamp() enforces.

Monotonic-versus-epoch drift over a long run. A single anchor held for many hours accumulates divergence between the monotonic source and true UTC, because the two oscillators do not tick at identical rates — a run can end tens of milliseconds off wall time even with perfect monotonicity. The tell is a slowly growing gap between anchored UTC and a fresh datetime.now(timezone.utc) reading. Bound it by re-anchoring on an interval (reanchor_after_s); each re-anchor is a controlled, forward-only correction that resets the accumulated drift without ever moving an already-issued timestamp backward.

Trusting an instrument wall clock as the ordering key. Reordering samples on an instrument-reported timestamp reconstructs the wrong causal sequence whenever that device’s RTC is offset or drifting — and across several devices the offsets differ, so the merged stream interleaves incorrectly. The failure is silent: the data looks plausible but two events are transposed. Order strictly on the host seq_no and reconciled host_utc; keep the instrument’s own time as an attribute for forensic comparison only, precisely the attribute-not-key rule the parent injection guide states.

DST-local timestamps from a legacy controller. An older controller may emit local time with a daylight-saving offset and no timezone tag, so a sample taken at 01:30 during the autumn fall-back repeats an hour that already occurred, and naive parsing places it before samples that truly preceded it. Diagnose by watching for a one-hour backward discontinuity at a DST boundary in one device’s raw stream only. Never parse such a field as UTC; run it through the reconciler as an instrument-local attribute, derive the authoritative time from the host, and record the controller’s declared timezone in metadata so the offset is reconstructable. Leap seconds pose the same class of hazard — a repeated or skipped UTC second — which the anchored monotonic clock absorbs because interval math runs on the leap-free monotonic source, not the smeared wall clock.

← Back to Metadata Injection Workflows

References