Lab Streaming Layer (LSL) & XDF
Everything so far (Pipeline Rules, Interval QC Plot,
EDA & SCRs) used Crane as the example: one Biopac .mat file
plus separate behaviour CSVs. FOH recordings work differently — this page
covers that other path: the same PipelineTemplate architecture from
Design Patterns, wired up to LSL/.xdf data instead
of a Biopac .mat file.
Where FOH currently stands
foh_pipeline.py's run_pipeline() already builds the same
PipelineTemplate shape as Crane's — this page documents that target
shape, not a hypothetical future one. Target behaviour data is now
wired into the import and processing steps (see below); the
participant-level output schema (build_foh_participant_output_schema())
is still the one piece left as a no-op. See item 21 in
Next Steps
for the current punch list. The @deprecated run_lsl_pipeline
function is the old, pre-refactor path — mobi_foh_batch_process has
already moved off it onto run_pipeline(); mobi_foh_process (the
single-file CLI) hasn't yet. It's kept only until both CLIs are off it,
then deleted (see that same item).
What is LSL, and what's a .xdf file?
Lab Streaming Layer (LSL) is middleware used during a live recording session to synchronize several independent data sources — VR event markers, physiology, EEG, trial/task events — onto one shared clock, even though they come from different software and hardware. Each data source is called a stream.
.xdf is the file format LSL recordings get saved to. One .xdf file
contains all the streams from a session bundled together, each tagged
with its own name, type, channel info, and timestamps already aligned to
that shared clock. This is different from Crane, where physiology and
behaviour arrive as separate files that then need to be matched up after
the fact (see Pipeline Rules) — with .xdf, that
alignment is mostly already done by the time you load the file.
Where this code lives
| File | Role |
|---|---|
read_mobi_xdf/xdf_io.py |
Low-level helpers: pull one named stream out of a loaded .xdf file and turn it into a labelled pandas DataFrame. |
cli/check_mobi_xdf.py |
Loads a .xdf file (via the third-party pyxdf library) and reports which streams it actually contains. |
processing/lsl.py |
The LSL/.xdf counterpart to biopac.py: FohLslPhysiologyDataImportStrategy (physiology import), plus gather_xdf_data_streams and friends. |
processing/foh_config.py |
Typed trial-interval definitions (LslEventSpecification, LslIntervalSpecifications) — which VR/LSL event marks the start and end of each interval FOH cares about (baseline, stress, recovery). |
processing/foh_pipeline.py |
The FOH experiment's pipeline — wires the strategy steps below into the shared PipelineTemplate, the same way crane_pipeline.py does for Crane. |
Going further
Per Code Organization,
read_mobi_xdf/ is planned to move into processing/, living next to
biopac.py — both are just physiology-file loaders for different
formats, and don't need to live in a separate top-level folder.
Pulling one stream out of a .xdf file
Real code, xdf_io.py:
def extract_single_stream(streams: list, stream_name: str) -> tuple[pd.DataFrame, dict]:
"""Extract the time series and time stamps from a specified stream in xdf data."""
for s in streams:
if s['info']['name'][0] == stream_name:
single_stream = s
single_stream_time_series = np.array(single_stream['time_series'])
single_stream_time_stamps = np.array(single_stream['time_stamps'])
single_stream_df = pd.DataFrame(single_stream_time_series)
single_stream_df["time_stamps"] = single_stream_time_stamps
return single_stream_df, single_stream
raise xdfIOException(f"Could not find XDF stream {stream_name!r}")
streams is the list pyxdf.load_xdf(...) returns — one dict per stream,
holding its raw metadata plus time_series/time_stamps arrays. This
function just finds the one stream matching stream_name (e.g.
"VR_markers") and reshapes it into a DataFrame.
gather_xdf_data_streams wraps this to grab several named streams at once,
and — importantly — skips a missing stream with a warning instead of
crashing the whole load, since not every stream is guaranteed to be
present in every recording session.
Finding a participant's files: ParticipantConfig.from_lsl_data
Crane's file-finding rules (see Pipeline Rules) are
built on ParticipantConfig.from_physiology_data, which matches a
physiology file and one or more separate behaviour files by filename.
FOH doesn't need that — everything (physiology, VR markers, trial events,
target data) lives inside the same .xdf file — so it has its own
classmethod, ParticipantConfig.from_lsl_data (input_data.py), used by
FOH's own find-files strategy:
class FindFohParticipantFilesStrategyStep:
physiology_data_type = FohLslPhysiologyDataImportStrategy.input_data_file_format
def run(
self, participant_id_in: str, data_folder_in: Path, output_folder_in: Path | None = None
) -> ParticipantConfig:
return ParticipantConfig.from_lsl_data(
id_in=participant_id_in,
physiology_data_type_in=self.physiology_data_type,
data_folder_in=data_folder_in,
output_folder_in=output_folder_in,
)
Same Strategy contract as Crane's FindCraneParticipantFilesStrategyStep
(see Design Patterns) — PipelineTemplate
calls .run() on whichever one it's given, without needing to know Crane's
file-matching and FOH's file-matching work differently under the hood.
A couple of matching rules specific to from_lsl_data, worth knowing if
you're troubleshooting a missing participant:
- it globs for
*{id}*.xdfunderdata_folder_inand only keeps files ending ineeg.xdf— anything else is logged and skipped as "an old run"; - if more than one match remains, it picks the last one (sorted by
rglob's own order) rather than Crane's "first match wins" rule — worth double-checking against your actual filenames if a participant seems to pick up the wrong session.
Importing physiology data: FohLslPhysiologyDataImportStrategy
The LSL counterpart to BiopacDataImportStartegy (biopac.py). Same
ImportBioDataStrategyStep contract, same RawBioData output type — just
reading streams out of an .xdf file instead of channels out of a .mat
file:
class FohLslPhysiologyDataImportStrategy:
input_data_file_format = PhysiologyFileFormat.LSL
output_data_type = RawBioData
def run(self, config_in: ParticipantConfig) -> RawBioData:
streams_to_get = ["OpenSignals", "VR_markers"]
streams, _ = pyxdf.load_xdf(config_in.physiology_fn)
selected_lsl_physiology_streams_dfs = gather_xdf_data_streams(streams, streams_to_get)
if has_missing_requirements(missing_streams, ["OpenSignals"]):
logger.warning(f"Missing physiology data - {missing_streams}")
return RawBioData()
df_dict_out = {
"EDA": selected_lsl_physiology_streams_dfs["OpenSignals"].copy(),
"ECG": selected_lsl_physiology_streams_dfs["OpenSignals"].copy(),
}
if not has_missing_requirements(missing_streams, ["VR_markers"]):
df_dict_out["VR_markers"] = selected_lsl_physiology_streams_dfs["VR_markers"].copy()
...
return RawBioData(raw_data=df_dict_out)
Only OpenSignals is actually required — a missing VR_markers stream no
longer fails the whole import, it's just left out of raw_data. The single
OpenSignals stream is split into separate "EDA"/"ECG" entries (each
with the other's columns dropped), rather than handed back as one combined
"OpenSignals" entry — that split is what lets ecg.py's processing read a
plain EDA/ECG-keyed RawBioData, the same shape Crane's Biopac import
produces.
Because RawBioData is the same contract Crane's Biopac import returns
(see Design Patterns),
everything downstream of physiology import — EDA processing, QC plotting —
doesn't need to know or care whether the data came from a .mat file or an
.xdf stream.
Building trial intervals from LSL events: foh_config.py
Crane finds trial intervals from trigger-voltage crossings in the physiology
signal itself (trial_intervals.py). FOH instead has explicit VR/LSL
events marking when each interval starts and ends, declared as typed
constants in foh_config.py:
BASELINE_START_SPEC = LslEventSpecification(stream="VR_markers", column="Markers", event=10)
BASELINE_START_FALLBACK_SPEC = LslEventSpecification(
stream="VR_trial_events", column="VR_trial", event="RaiseSafetyPlatform", offset_seconds=-300
)
BASELINE_END_SPEC = LslEventSpecification(
stream="VR_trial_events", column="VR_trial", event="RaiseSafetyPlatform"
)
BASELINE_INTERVAL = LslIntervalSpecifications(
start=BASELINE_START_SPEC, end=BASELINE_END_SPEC, start_fallback=BASELINE_START_FALLBACK_SPEC
)
FOH_TRIAL_INTERVALS: dict[str, LslIntervalSpecifications] = {
"baseline": BASELINE_INTERVAL,
"stress": STRESS_INTERVAL,
"recovery": RECOVERY_INTERVAL,
}
Reading this: baseline normally starts at VR marker 10 on the
VR_markers stream; if that marker is missing, fall back to 300 seconds
before RaiseSafetyPlatform fires on VR_trial_events instead
(offset_seconds=-300). stream is typed as
Literal["VR_markers", "VR_trial_events"], so a typo'd stream name is a
type-checker error rather than a runtime KeyError.
FohGetTrialIntervalStrategyStep.run() (foh_trial_intervals.py) walks
FOH_TRIAL_INTERVALS, resolving each start/end spec (falling back where
one is configured) into an actual timestamp via
get_lsl_event_time_with_fallback (trial_intervals.py), and returns a
TrialIntervals — the same contract Crane's interval strategy returns.
Physiology processing is shared with Crane, unchanged
Once RawBioData and TrialIntervals exist, FOH doesn't need its own EDA
processing step — foh_pipeline.py wires in the exact same
ProcessEdaPhysiologyDataStrategyStep class crane_pipeline.py uses:
process_physiology_steps = pipeline.SequentialPhysiologyProcessingSteps(
steps=[ProcessEdaPhysiologyDataStrategyStep()]
)
This is the Strategy pattern paying off directly (see
Design Patterns): the same processing code
runs against physiology data from two differently-shaped source files,
because both import steps agree on handing back a RawBioData.
Try it yourself: chain FOH strategies through the contracts
Same idea as Design Patterns' Crane example,
run for FOH instead — each strategy step's output feeds the next one's
input, no PipelineTemplate involved:
from pathlib import Path
from vrlab_toolbox.processing.foh_behaviour import ImportFohBehaviourDataStrategyStep
from vrlab_toolbox.processing.foh_pipeline import FindFohParticipantFilesStrategyStep
from vrlab_toolbox.processing.foh_trial_intervals import FohGetTrialIntervalStrategyStep
from vrlab_toolbox.processing.lsl import FohLslPhysiologyDataImportStrategy
data_folder = Path("foh_data")
participant_config = FindFohParticipantFilesStrategyStep().run("P001", data_folder)
raw_bio_data = FohLslPhysiologyDataImportStrategy().run(participant_config)
raw_behav_data = ImportFohBehaviourDataStrategyStep().run(config_in=participant_config)
trial_intervals, interval_figure, interval_status = FohGetTrialIntervalStrategyStep().run(
raw_bio_data, raw_behav_data
)
raw_bio_data is a RawBioData — same type Crane's chain produces, just
built from .xdf streams. trial_intervals is the same TrialIntervals
type Crane's CraneGetTrialIntervalStrategyStep returns, just built from
LslEventSpecifications instead of Crane's trigger-voltage logic.
Inspecting a .xdf file yourself
Before writing any processing code against a new recording, check what streams it actually contains:
mobi_check_xdf path/to/file.xdf --verbose
This calls check_mobi_xdf() (cli/check_mobi_xdf.py), which loads the
file with pyxdf.load_xdf and prints each stream's name, type, channel
count, sampling rate, and sample count.
Known rough edges
cli/check_mobi_xdf.pyhas an open# TODO: Show missing streams— it loads and reports what's present, but doesn't yet compare that against an expected stream list.xdf_io.pystill uses bareprint()in a couple of places instead oflogging(unlike the rest of this codebase — see Golden Rules).xdf_io.py'sget_start_timeisn't called anywhere else in the codebase, and looks like it would raiseAttributeErrorif it were: it callsdatetime.fromisoformat(...), but the file only doesimport datetime(the module) rather thanfrom datetime import datetime(the class) —fromisoformatexists on the class, not the module. Left here as an example of exactly the kind of small, easy-to-miss bug that hides in unused code.processing/trial_intervals.py:87is hardset to one platform rather than detecting it — see the deferred items in Next Steps.ImportFohTargetBehaviourDataStrategyStepis now wired intofoh_pipeline.py's import steps, andProcessFohTargetDataWithIntervalsStrategyStepreturns real target output (FohTargetBehaviourOutputData) instead of discarding it. What's still the target shape rather than finished:build_foh_participant_output_schema()is still a no-op, andmobi_foh_process(unlikemobi_foh_batch_process) hasn't moved off the deprecatedrun_lsl_pipelinepath yet. Full punch list: item 21 in Next Steps.
Next: Pipeline Concepts — back to the architecture that both the Crane and FOH paths are built on.