Skip to main content

Cross-device sync

Align LABO's per-frame / per-event data with data from an external device — EEG, biopac, a standalone eye tracker, external motion capture, physiological sensors. The answer is always: use MonotonicExecutionTime as the reference clock, and align via a known synchronisation pulse or a merge_asof nearest-neighbour join.

What this recipe does

  1. Assume the external device logged its own events with timestamps in seconds from some (possibly different) zero point.
  2. Establish a time offset between LABO and the external device — usually via one or more shared sync pulses (a trigger pulse LABO sends, recorded by the external device with its own timestamp).
  3. Translate external timestamps into LABO's MonotonicExecutionTime frame.
  4. Join aligned data using merge_asof.

Files you need

  • ExperienceState.csv — LABO's reference timeline.
  • EventPoints.csv — to identify sync-pulse events LABO fired.
  • An external CSV / data file with its own timestamp column.

Establishing the offset

The cleanest method is a sync pulse:

  1. During the experience, LABO fires an event (logged in EventPoints.csv) at the same time that a trigger line goes high (recorded by the external device).
  2. In the external device's recording, find the sample where the trigger fired — that sample's external-device timestamp corresponds to the event's MonotonicExecutionTime.
  3. Offset = MonotonicExecutionTime_of_pulse_in_LABO − external_timestamp_of_pulse.
  4. External timestamp in LABO-time = external_timestamp + offset.

Multiple pulses let you verify the two clocks don't drift — fit a linear regression if drift is expected.

Code

import pandas as pd

# LABO data.
events = pd.read_csv("EventPoints.csv")
exp = pd.read_csv("ExperienceState.csv")

# External device data. Assume it has columns: 'ext_time_s', 'signal_value'.
# Replace this with your actual loader (e.g. MNE for EEG, pyedflib, etc.).
ext = pd.read_csv("external_device.csv")

# 1. Find the sync pulse(s) in LABO's event log.
labo_pulses = events[events["Event_Description"] == "SyncPulse"]["MonotonicExecutionTime"].values

# 2. Find the matching pulse(s) in the external data.
# This depends on how the external device logs the pulse — e.g. a boolean column,
# or a trigger-code column. Pseudocode:
ext_pulses = ext[ext["trigger"] == 1]["ext_time_s"].values

# 3. Compute offset (assume a single pulse for simplicity).
assert len(labo_pulses) == len(ext_pulses) == 1, \
"Expected exactly one sync pulse in both recordings"
offset = labo_pulses[0] - ext_pulses[0]

# 4. Translate external timestamps into LABO time.
ext["monotonic_time"] = ext["ext_time_s"] + offset

# 5. Align external samples to LABO's per-frame timeline (nearest match within tolerance).
exp_sorted = exp.sort_values("MonotonicExecutionTime")
ext_sorted = ext.sort_values("monotonic_time")

aligned = pd.merge_asof(
exp_sorted,
ext_sorted,
left_on="MonotonicExecutionTime",
right_on="monotonic_time",
direction="nearest",
tolerance=0.005, # 5 ms — tighten or loosen based on your devices' sample rates
)

# aligned now has one row per LABO frame, with the nearest external-device sample attached.

Gotchas

  • Clock drift. Over long sessions, the two devices' clocks can drift by milliseconds. A single sync pulse + offset assumes no drift. For sessions > a few minutes, fire pulses at the start and end, compute a linear fit of labo_time = a * ext_time + b, and use that to translate instead of a static offset.
  • Pulse latency. The LABO event and the physical trigger line don't fire at literally the same microsecond — there's software latency on both sides. Expect a constant offset of a few ms; calibrate if precision matters.
  • tolerance on merge_asof is critical. Too wide and you'll pair LABO frames with external samples that are actually from a different frame. Too narrow and you'll get NaN pairings. Default rule: tolerance = half the faster device's sample interval. At 90 Hz LABO + 1000 Hz EEG, that's ~0.5 ms; at 90 Hz LABO + 90 Hz external, ~5 ms is about right.
  • Direction matters. direction="nearest" is forgiving (±tolerance). For asymmetric cases — "find the EEG sample before each LABO frame" — use "backward".
  • Reference event selection. "SyncPulse" is an example. Use whatever description your experience logs; check events["Event_Description"].unique() if unsure.
  • External-device file format. CSV is what's shown here; for EEG prefer a format library (MNE-Python, pyedflib) that gives you proper channel metadata. Extract the timestamp + trigger channel into a pandas DataFrame before running the merge.

Further reading