Creating Experiments

This tutorial walks through creating custom respiratory tracking experiments with respyra, from running a built-in config to composing a fully custom experiment flow.

Running built-in configs

The fastest path — run a pre-defined experiment with no code changes:

# Built-in config by short name
respyra-task --config demo
respyra-task --config validation_study

# Or use a file path
respyra-task --config experiments/demo.py

The --config flag accepts three forms:

Form

Example

Resolution

Short name

demo

Imports respyra.configs.demo

Dotted path

respyra.configs.demo

Imports the module directly

File path

experiments/my_study.py

Loads the .py file

Every config module must export a module-level variable named CONFIG — an ExperimentConfig instance.

Note

Config files are standard Python modules and are executed when loaded. Only use config files from trusted sources.

Creating a custom config

The recommended workflow: start from the base defaults and override what you need using dataclasses.replace().

1. Create a config file

Create a file in experiments/ (or anywhere you like):

# experiments/my_study.py
"""My first custom experiment."""

from dataclasses import replace
from defaults import CONFIG as _BASE

from respyra.configs.experiment_config import TrialConfig
from respyra.configs.presets import slow_steady

# 5 cycles at 0.1 Hz = 50 seconds per trial
MY_CONDITION = slow_steady(n_cycles=5)

CONFIG = replace(
    _BASE,
    name="My Study",
    timing=replace(_BASE.timing, tracking_duration_sec=50.0),
    display=replace(_BASE.display, fullscr=True),
    trial=TrialConfig(
        conditions=[MY_CONDITION] * 4,
        n_reps=1,
        method="sequential",
    ),
)

The experiments/defaults.py file provides sensible base parameters — from defaults import CONFIG works because load_config temporarily adds the file’s parent directory to sys.path.

2. Run it

respyra-task --config experiments/my_study.py

What you can override

Each sub-config groups related parameters. Override any of them with nested replace() calls:

from dataclasses import replace
from defaults import CONFIG as _BASE

CONFIG = replace(
    _BASE,
    # Change the experiment name
    name="Slow Breathing Study",

    # Change timing
    timing=replace(_BASE.timing,
        baseline_duration_sec=15.0,
        tracking_duration_sec=60.0,
    ),

    # Change display
    display=replace(_BASE.display,
        fullscr=True,
        monitor_size_pix=(2560, 1440),
    ),

    # Change the belt
    belt=replace(_BASE.belt, connection="usb"),
)

Using condition presets

The respyra.configs.presets module provides factory functions for common breathing paradigms:

from respyra.configs.presets import slow_steady, perturbed_slow, mixed_rhythm

# Factory functions — customize parameters
easy = slow_steady(freq_hz=0.1, n_cycles=3)          # 30 s trial
hard = slow_steady(freq_hz=0.15, n_cycles=4)         # 26.7 s trial
perturbed = perturbed_slow(feedback_gain=2.0)         # 2x visual gain (default is 1.5x)
mixed = mixed_rhythm(freq_slow=0.1, freq_fast=0.25)   # multi-segment

# Pre-built constants — standard defaults
from respyra.configs.presets import SLOW_STEADY, PERTURBED_SLOW, MIXED_RHYTHM

Building from scratch

For full control, use ConditionDef and SegmentDef directly:

from respyra.core.target_generator import ConditionDef, SegmentDef

# Graded gain levels for dose-response designs
conditions = [
    ConditionDef("gain_1.0", [SegmentDef(0.1, 3)], feedback_gain=1.0),
    ConditionDef("gain_1.5", [SegmentDef(0.1, 3)], feedback_gain=1.5),
    ConditionDef("gain_2.0", [SegmentDef(0.1, 3)], feedback_gain=2.0),
    ConditionDef("gain_2.5", [SegmentDef(0.1, 3)], feedback_gain=2.5),
]

# Fast breathing with multiple segments
fast_protocol = ConditionDef("fast_ramp", [
    SegmentDef(0.2, 2),   # 10 s at 0.2 Hz
    SegmentDef(0.3, 3),   # 10 s at 0.3 Hz
    SegmentDef(0.4, 4),   # 10 s at 0.4 Hz
])

Use integer cycle counts per segment to ensure phase continuity at boundaries — the waveform loops seamlessly.

Counterbalanced designs

For studies requiring counterbalancing across sessions, define a build_conditions() function and pass it to TrialConfig:

# experiments/my_counterbalanced_study.py
from dataclasses import replace
from defaults import CONFIG as _BASE

from respyra.configs.experiment_config import TrialConfig
from respyra.configs.presets import perturbed_slow, slow_steady

SLOW_4 = slow_steady(n_cycles=4)
PERTURBED_4 = perturbed_slow(n_cycles=4, feedback_gain=2.0)
BLOCK_SIZE = 6


def build_conditions(session_num):
    """Counterbalance starting condition across sessions.

    Odd sessions (1, 3): slow_steady block first.
    Even sessions (2, 4): perturbed block first.
    """
    if int(session_num) % 2 == 1:
        return [SLOW_4] * BLOCK_SIZE + [PERTURBED_4] * BLOCK_SIZE
    else:
        return [PERTURBED_4] * BLOCK_SIZE + [SLOW_4] * BLOCK_SIZE


CONFIG = replace(
    _BASE,
    name="Counterbalanced Study",
    timing=replace(_BASE.timing, tracking_duration_sec=40.0),
    trial=TrialConfig(
        conditions=build_conditions(1),  # default for Session 1
        n_reps=1,
        method="sequential",
        build_conditions=build_conditions,  # called at runtime with actual session
    ),
)

When build_conditions is set, the runner calls it at runtime with the session number entered in the participant dialog, overriding the static conditions list.

See experiments/validation_study.py for a complete working example.

Custom experiment flows

For experiments that deviate from the standard flow (e.g., skipping baseline, adding custom phases, or running multiple calibrations), import individual phase functions from respyra.core.runner:

from respyra.core.runner import (
    ExperimentState,
    connect_belt,
    setup_display,
    run_participant_dialog,
    run_range_calibration,
    run_baseline,
    run_countdown,
    run_tracking,
    show_trial_feedback,
    show_end_screen,
)
from respyra.configs.experiment_config import ExperimentConfig

cfg = ExperimentConfig(...)

# Compose your own flow
belt = connect_belt(cfg)

from psychopy import core
from respyra.core.data_logger import DataLogger, create_session_file
from collections import deque

win, stimuli = setup_display(cfg)
exp_info = run_participant_dialog(cfg)
filepath = create_session_file(exp_info["participant"], exp_info["session"])
logger = DataLogger(filepath, columns=cfg.data_columns)

state = ExperimentState(
    belt=belt, win=win, logger=logger,
    clock=core.Clock(), buffer=deque(maxlen=cfg.trace_buffer_size),
    stimuli=stimuli,
)

# Example: skip baseline, run calibration, then just tracking
run_range_calibration(state, cfg)
for trial_num in range(1, 6):
    # ... run_countdown, run_tracking, etc.
    pass

Each phase function takes an ExperimentState and ExperimentConfig, mutates the state, and returns status flags. See the API reference for full signatures.

Configuration reference

ExperimentConfig

Field

Type

Default

Description

name

str

"Breath Tracking Task"

Experiment name (shown in dialogs)

belt

BeltConfig

See below

Belt connection parameters

display

DisplayConfig

See below

PsychoPy window settings

trace

TraceConfig

See below

Waveform display

dot

DotConfig

See below

Target dot appearance and feedback

timing

TimingConfig

See below

Phase durations

range_cal

RangeCalConfig

See below

Range calibration

trial

TrialConfig

See below

Trial structure and conditions

output_dir

str

"data/"

Output directory for CSV files

escape_key

str

"escape"

Key to abort the experiment

data_columns

list[str]

10 standard columns

Column names for the output CSV

BeltConfig

Field

Default

Description

connection

"ble"

Connection type: "ble" or "usb"

device_to_open

"proximity_pairing"

BLE device selection strategy

period_ms

100

Sampling interval in ms (100 = 10 Hz)

channels

[1]

Sensor channels (1 = Force in Newtons)

DisplayConfig

Field

Default

Description

fullscr

False

Full-screen mode (set True for data collection)

monitor_name

"testMonitor"

PsychoPy monitor profile name

monitor_width_cm

53.0

Physical screen width in cm

monitor_distance_cm

57.0

Viewing distance in cm

monitor_size_pix

(1920, 1080)

Screen resolution

units

"height"

PsychoPy coordinate system

bg_color

(-1, -1, -1)

Background color (black)

TraceConfig

Field

Default

Description

rect

(-0.6, -0.15, 0.55, 0.35)

Trace area (left, bottom, right, top)

y_range

(0, 10)

Initial force range (overridden after calibration)

color

"lime"

Waveform color

border_color

"#333333"

Trace border color

duration_sec

5.0

Seconds of signal visible on screen

DotConfig

Field

Default

Description

radius

0.03

Dot radius (height units)

x_offset

0.05

Offset right of trace edge

color_good

"yellow"

Good tracking color

color_bad

"red"

Poor tracking color

color_mid

"orange"

Moderate tracking color (trinary mode)

feedback_mode

"graded"

"graded", "binary", or "trinary"

error_threshold_n

1.0

Good/bad cutoff in Newtons

error_threshold_mid_n

2.0

Mid/bad cutoff in Newtons

graded_max_error_n

3.0

Error at which dot is fully red

TimingConfig

Field

Default

Description

range_cal_duration_sec

15.0

Range calibration duration

baseline_duration_sec

10.0

Baseline per trial

countdown_duration_sec

3.0

Countdown per trial

tracking_duration_sec

30.0

Tracking per trial

RangeCalConfig

Field

Default

Description

scale

0.80

Fraction of range used for target amplitude

percentile_lo

5

Lower percentile for outlier rejection

percentile_hi

95

Upper percentile for outlier rejection

force_saturation_lo

0.0

Sensor floor warning threshold

force_saturation_hi

40.0

Sensor ceiling warning threshold

TrialConfig

Field

Default

Description

conditions

[]

List of ConditionDef objects

n_reps

1

Repetitions per condition

method

"sequential"

"sequential" or "random"

build_conditions

None

Optional function (session) -> [ConditionDef] for counterbalancing