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
- Assume the external device logged its own events with timestamps in seconds from some (possibly different) zero point.
- 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).
- Translate external timestamps into LABO's
MonotonicExecutionTimeframe. - 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:
- 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). - 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. - Offset =
MonotonicExecutionTime_of_pulse_in_LABO − external_timestamp_of_pulse. - 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
- Python (pandas)
- R
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.
library(data.table)
events <- fread("EventPoints.csv")
exp <- fread("ExperienceState.csv")
ext <- fread("external_device.csv")
labo_pulses <- events[Event_Description == "SyncPulse", MonotonicExecutionTime]
ext_pulses <- ext[trigger == 1, ext_time_s]
stopifnot(length(labo_pulses) == 1, length(ext_pulses) == 1)
offset <- labo_pulses[1] - ext_pulses[1]
ext[, monotonic_time := ext_time_s + offset]
# Rolling nearest join, tolerance = 5 ms.
setkey(exp, MonotonicExecutionTime)
setkey(ext, monotonic_time)
aligned <- ext[exp, roll = "nearest", rollends = c(TRUE, TRUE)]
# Optionally filter by abs(monotonic_time - MonotonicExecutionTime) <= 0.005
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.
toleranceonmerge_asofis critical. Too wide and you'll pair LABO frames with external samples that are actually from a different frame. Too narrow and you'll getNaNpairings. 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
- Timing → MonotonicExecutionTime — why this is the right clock.
- Join keys —
merge_asofpattern for aligning two continuous streams. - Events — where to fire sync pulses from.