Join keys
Every analysis that combines two LABO streams needs a join key — a column that means the same thing in both files so you can line up rows. This page is the short answer to "which column do I join on?" plus worked examples in pandas and R.
The short answer
| Joining two... | Use this key |
|---|---|
| Regular streams from the same session | FrameNumber (exact match) |
| Irregular stream to a regular stream | FrameNumber (exact match) |
| Two irregular streams | FrameNumber first; fall back to MonotonicExecutionTime within a tolerance if they fire in different frames but refer to the same moment |
| LABO data to an external device (EEG, biopac, standalone tracker) | MonotonicExecutionTime (drift-free, seconds from session start) |
FrameNumber is the preferred key within a session. MonotonicExecutionTime is the preferred key across sessions or across devices. See Timing for why.
What to avoid
| ❌ Don't join on | Why |
|---|---|
ObservationTime | Unity-side clock; may drift between writers in the same frame. |
FrameTime | Same value for every row in a frame — lossy within a frame. |
FixedIntervalNumber | Only meaningful for streams writing in FixedUpdate. Mismatched across streams on different sampling loops. |
| Wall-clock timestamps | Subject to NTP drift and system clock adjustments. |
Pandas — join ExperienceState to EyeTracking
import pandas as pd
exp = pd.read_csv("ExperienceState.csv")
eye = pd.read_csv("EyeTracking.csv")
# Exact inner join on FrameNumber — both streams write on LateUpdate,
# so every eye-tracking row has a matching ExperienceState row.
joined = exp.merge(eye, on="FrameNumber", how="inner", suffixes=("_exp", "_eye"))
# Now any ExperienceState column (EpochName, EventNumber) is attached
# to every eye-tracking sample. Group gaze by epoch:
joined.groupby("EpochName")["Eye_Center_DirZ"].mean()
Pandas — join irregular Events to ExperienceState context
import pandas as pd
exp = pd.read_csv("ExperienceState.csv")
events = pd.read_csv("EventPoints.csv")
# Each event row already carries FrameNumber from Stamp() at trigger time.
# Attach the full per-frame state (fps, deltaTime, etc.) via left join.
enriched = events.merge(exp, on="FrameNumber", how="left", suffixes=("_event", "_frame"))
# Now every event has the frame's fps and deltaTime next to it —
# useful for flagging events that fired on slow frames.
slow_events = enriched[enriched["deltaTime"] > 0.025]
Pandas — align to an external device via MonotonicExecutionTime
import pandas as pd
labo = pd.read_csv("ExperienceState.csv")
eeg = pd.read_csv("external_eeg.csv") # has its own 'timestamp_s' column, seconds from session start
# Sort both by the join column; merge_asof does a nearest-match join
# within a tolerance (5 ms here).
labo = labo.sort_values("MonotonicExecutionTime")
eeg = eeg.sort_values("timestamp_s")
aligned = pd.merge_asof(
labo,
eeg,
left_on="MonotonicExecutionTime",
right_on="timestamp_s",
direction="nearest",
tolerance=0.005, # 5 ms
)
merge_asof is the right tool whenever the two streams are sampled at different rates or slightly offset — it does a windowed nearest-match rather than requiring exact equality.
R — same joins
library(dplyr)
exp <- read.csv("ExperienceState.csv")
eye <- read.csv("EyeTracking.csv")
joined <- exp %>%
inner_join(eye, by = "FrameNumber", suffix = c("_exp", "_eye"))
joined %>%
group_by(EpochName) %>%
summarise(mean_gaze_z = mean(Eye_Center_DirZ, na.rm = TRUE))
For merge_asof-style nearest-match, see data.table::nafill / roll parameter in data.table::merge or the fuzzyjoin package.
Gotchas
- Regular streams in "Both" sampling mode write two files (e.g.
ExperienceState_Update.csvandExperienceState_FixedUpdate.csv). Pick the right one: join LateUpdate-sampled streams to_Update, FixedUpdate-sampled streams to_FixedUpdate. - Missing frames are normal. A stream that's gated (e.g. body tracking without a skeleton) won't have rows for every frame. Use left joins when
FrameNumbercoverage is not guaranteed. - Hand tracking is two files, not one.
LeftHandTracking.csvandRightHandTracking.csveach have their ownFrameNumbercolumn. To get both hands in one table, join them to ExperienceState independently, then join the two results. - Irregular rows can fire between regular samples. An event-point at
FrameNumber = 745aligns to the ExperienceState row withFrameNumber = 745, but the times within that frame differ by a millisecond or two. This is real, not noise; it reflects the gap between the trigger firing and the regular sample being written at LateUpdate. See Timing for the full story.
Further reading
- Timing — why
FrameNumberis exact andMonotonicExecutionTimeis drift-free. - Column conventions — every shared column and what it means.
- Cross-device sync recipe — practical example aligning LABO to EEG.