TaskClient

Emit experiment structure and stimulus events from your task code so they land in the recording, time-aligned with the neural data.

Setup

pip install kernel_sdk
TaskClient(ip="127.0.0.1", port=6767, post_session_object_path=None, debug=False, experiment_name=None, experiment_version=None, enforce_registry_entry=False)

The defaults match where kortex listens for task events, so on a normal setup there is nothing to pass:

from kernel.sdk.task import TaskClient

tc = TaskClient()
ArgumentMeaning
ip, portWhere kortex is listening for task events. Defaults to 127.0.0.1:6767, matching kortex's TCP task source.
debugLog events to the console instead of sending them. Good for developing a task without hardware.
experiment_name, experiment_versionRecorded in the session metadata file.
enforce_registry_entryRaise InvalidTaskType if the experiment type is not in TaskRegistry. Default is to log a warning and continue.

Events are sent over TCP. Point ip at another machine if your task runs somewhere other than the acquisition box.

Use debug=True while writing your task. Nothing is transmitted, every event is logged, and you can read them back with tc.debug_events.

How events work

Everything the client emits is a name/value pair with a timestamp and a monotonically increasing id. The structural methods below are conveniences that emit matched start_* and end_* pairs around a block of your code.

Counters never reset. The third trial of the second block is trial 3 overall, not trial 1 of block 2 — use the counter accessors if you need per-grouping numbers.

Experiment, task, block, trial

experiment(num=1, type=None) task(num=1, type=None) block(num=1, type=None) trial(num=1, type=None)

Each behaves two ways depending on num:

Nesting is enforced: experiment() must be called first and at the top level; a task must be inside an experiment; a block inside a task or experiment; a trial inside a block, task, or experiment.

import random

with tc.experiment() as e:                       # start_experiment: 1
    with tc.block(type="practice") as b:         # start_block: 1
                                                 # block_type: "practice"
        for t in tc.trial(
            num=5,
            type=lambda n: random.choice(["left", "right"])
        ):                                       # start_trial: 1 .. 5
                                                 # trial_type: "left" | "right"
            print(e.current_count())             # 1
            print(b.current_count())             # 1
            print(t.current_count())             # 1 .. 5
            print(b.current_type())              # "practice"
            print(t.current_type())              # "left" | "right"
                                                 # end_trial: 1 .. 5
                                                 # end_block: 1

    with tc.block(type="actual") as b:           # start_block: 2
                                                 # block_type: "actual"
        with tc.trial() as t:                    # start_trial: 6
            print(t.current_count())             # 6
                                                 # end_trial: 6

        trial_list = ["type1", "type2", "type3"]
        for t in tc.trial(num=6, type=trial_list):
            print(t.current_count(), t.current_type())
            # 8 type1 / 9 type2 / 10 type3 / 11 type1 / 12 type2 / 13 type3
                                                 # end_trial: 8 .. 13
                                                 # end_block: 2
                                                 # end_experiment: 1

Without a context manager

The returned object also supports explicit start() and stop():

e = tc.experiment().start()      # start_experiment: 2
print(e.current_count())         # 2
e.stop()                         # end_experiment: 2

Counters & types

MemberDescription
current_count()The current counter for that grouping. Always increasing, never reset.
current_type()The current type, if one was supplied.
start() / stop()Manual alternative to the with block.

The type argument accepts three forms, and emits a *_type event each time it changes:

FormBehaviour
"practice"A fixed type for every iteration.
lambda n: ...Called with the current global counter (not the loop index) each iteration; return the type. Use for randomised designs.
["a", "b", "c"]Cycled in order, wrapping as needed.

Task registry

Passing a registered type to experiment() also emits an experiment_meta event carrying the canonical task URN.

from kernel.sdk.task_registry import TaskRegistry

with tc.experiment(type=TaskRegistry.NBACK02.name) as e:
    ...

An unrecognised type logs a warning and continues, unless the client was constructed with enforce_registry_entry=True, in which case it raises InvalidTaskType.

iti

iti(time_in_seconds, sleep=time.sleep) inter_trial_interval(time_in_seconds, sleep=time.sleep) # alias

Emits start_inter_trial_interval with an incrementing counter, then inter_trial_interval with the duration, then sleeps, then emits end_inter_trial_interval with the matching counter.

with tc.experiment() as e:
    for t in tc.trial(num=10):
        ...                  # trial code
        tc.iti(5.3)          # start_inter_trial_interval: 1 .. 10
                             # inter_trial_interval: 5.3
                             # sleeps 5.3 seconds
                             # end_inter_trial_interval: 1 .. 10

The sleep argument is called with time_in_seconds, so you can substitute a higher-precision sleep — for example one that spins on your display's vsync.

rest

rest(time_in_seconds, sleep=time.sleep)

Same shape as iti, emitting start_rest, rest_interval, and end_rest.

with tc.experiment() as e:
    for t in tc.trial(num=10):
        ...
        tc.rest(5.3)         # start_rest: 1 .. 10
                             # rest_interval: 5.3
                             # end_rest: 1 .. 10

cue

cue(time_in_seconds, sleep=time.sleep)

Same shape again, emitting start_cue, cue_duration, and end_cue. Use it for a fixation cross or instruction cue preceding a stimulus.

with tc.trial() as t:
    tc.cue(1.0)              # start_cue: 1 / cue_duration: 1.0 / end_cue: 1
    tc.stim("target.png")

stim

stim(name, **kwargs) -> Stimulus stimulus(name, **kwargs) -> Stimulus # alias

Emits a stimulus event. Every keyword argument becomes its own event, which is how you attach stimulus properties.

with tc.experiment() as e:
    for t in tc.trial(num=9):
        key_to_press = get_random_key()
        tc.stim(key_to_press)               # stimulus: "k"

with tc.experiment() as e:
    for t in tc.trial(num=10):
        photo_img, photo_gender, photo_age = random_photo()
        tc.stim(
            photo_img,
            photo_gender=photo_gender,
            photo_age=photo_age)            # stimulus: "img_2345.png"
                                            # photo_gender: "male"
                                            # photo_age: 42
        ...                                 # show photo_img

rt

rt() -> float reaction_time() -> float # alias

Called on the object returned by stim(). Emits a reaction_time event measured from that stimulus and returns the interval in seconds. Because each stimulus carries its own start time, you can measure against several stimuli independently.

with tc.experiment() as e:
    for t in tc.trial(num=9):
        key_to_press = get_random_key()
        s1 = tc.stim(key_to_press)          # stimulus: "k"
        ...                                 # show key to participant
        s2 = tc.stim("photo323.png")        # stimulus: "photo323.png"
        ...                                 # show distractor, wait for keypress
        rt1 = s1.rt()                       # reaction_time: 0.5
        print(rt1)                          # 0.5 — measured from s1, not s2
        tc.iti(5.0)

sync

sync() flash() # alias

A context manager emitting start_sync and end_sync with an incrementing counter. Wrap whatever produces your photodiode pulse so the optical sync can be aligned with the data stream.

with tc.experiment() as e:
    for t in tc.trial(num=10):
        with tc.sync():      # start_sync: 1 .. 10
            ...              # show white square, sleep, hide it
                             # end_sync: 1 .. 10

event

event(name) epoch(name) # alias

A context manager emitting start_{name} and end_{name} with an incrementing counter. Use it for any interval the built-in groupings do not cover.

with tc.event("instructions"):   # start_instructions: 1
    ...                          # show instructions, wait for key
                                 # end_instructions: 1

with tc.event("instructions"):   # start_instructions: 2
    ...
                                 # end_instructions: 2

send_event

send_event(**kwargs)

The primitive underneath everything else: emits one raw event per keyword argument.

tc.send_event(pressed="k")                  # pressed: "k"
tc.send_event(pressed="k", correct=True)    # pressed: "k"
                                            # correct: True

A complete task: finger tapping

Everything above, assembled into a real experiment. This is the structure Kernel's finger-tapping task uses: a baseline rest, then ten left/right block pairs of thirteen trials each, cueing one finger per trial, with an inter-trial interval between trials and a jittered rest between blocks.

The cues here just print to the console so the script runs as-is; replace cue() with whatever drives your display, audio, or Unity front end.

import json
import random
import time

from kernel.sdk.task import TaskClient
from kernel.sdk.task_registry import TaskRegistry

BLOCK_PAIRS = 10          # each pair is one left block and one right block
TRIALS_PER_BLOCK = 13
TRIAL_DURATION = 0.75     # seconds the finger cue stays up
ITI_MEAN, ITI_SD = 0.5, 0.0
BASELINE_REST = 20.0
BLOCK_REST, REST_JITTER = 20.0, 5.0
FINGERS = ["index", "middle", "ring", "pinky"]

PARAMS = {
    "trials_per_block": TRIALS_PER_BLOCK,
    "blocks_per_side": BLOCK_PAIRS,
    "trial_duration": TRIAL_DURATION,
    "ITI_mean": ITI_MEAN,
    "ITI_std": ITI_SD,
    "baseline_rest": BASELINE_REST,
    "rest_dur": BLOCK_REST,
    "rest_jitter_max": REST_JITTER,
}


def cue(text):
    """Show the participant what to do. Swap this for your own display."""
    print(text, flush=True)


tc = TaskClient(experiment_name="finger_tapping")


def rest(seconds, label="REST"):
    cue(f"{label}  ({seconds:.1f}s)")
    tc.rest(seconds)


with tc.experiment(type=TaskRegistry.FT02) as experiment:
    tc.send_event(experiment_params=json.dumps(PARAMS))
    rest(BASELINE_REST)

    for _pair in range(BLOCK_PAIRS):
        sides = ["left", "right"]
        random.shuffle(sides)          # counterbalance which hand goes first

        for side in sides:
            with tc.block(type=side) as block:
                cue(f"{side.upper()} HAND")
                for i in range(TRIALS_PER_BLOCK):
                    finger = FINGERS[i % len(FINGERS)]
                    with tc.trial(type=finger) as trial:
                        cue(f"  tap {finger}  (trial {trial.current_count()})")
                        time.sleep(TRIAL_DURATION)
                    # the trial has ended; the ITI sits between trials
                    tc.iti(max(0.0, random.gauss(ITI_MEAN, ITI_SD)))

            rest(BLOCK_REST + random.uniform(-REST_JITTER, REST_JITTER))

cue("DONE")

What the operator sees

REST  (20.0s)
RIGHT HAND
  tap index  (trial 1)
  tap middle  (trial 2)
  tap ring  (trial 3)
  tap pinky  (trial 4)
  tap index  (trial 5)
  ...
REST  (17.3s)
LEFT HAND
  tap index  (trial 14)
  ...

What lands in the recording

experiment_meta             {"task_urn": "urn:kernel.com/task/flow_neuro_FT/02", ...}
start_experiment            1
experiment_type             urn:kernel.com/task/flow_neuro_FT/02
experiment_params           {"trials_per_block": 13, ...}
start_rest                  1
rest_interval               20.0
end_rest                    1
start_block                 1
block_type                  right
start_trial                 1
trial_type                  index
end_trial                   1
start_inter_trial_interval  1
inter_trial_interval        0.5
end_inter_trial_interval    1
start_trial                 2
trial_type                  middle
...

Why with tc.trial(...) in a plain range() loop instead of for t in tc.trial(num=13)? Ordering. The iterator form emits end_trial at the start of the next iteration, so an iti() in the loop body would be nested inside the trial. Using a with block per trial closes the trial first, which puts the ITI cleanly between trials — as the event stream above shows.

Counters run across the whole session, so trial 14 is the first trial of the second block, not trial 1 again. Use block.current_count() and trial.current_type() when you need position within a grouping.

Emitted event names

MethodEvents emitted
experiment()start_experiment, experiment_type, experiment_meta, end_experiment
task()start_task, task_type, end_task
block()start_block, block_type, end_block
trial()start_trial, trial_type, end_trial
iti()start_inter_trial_interval, inter_trial_interval, end_inter_trial_interval
rest()start_rest, rest_interval, end_rest
cue()start_cue, cue_duration, end_cue
stim()stimulus, plus one event per keyword argument
rt()reaction_time
sync()start_sync, end_sync
event(name)start_{name}, end_{name}

All of these arrive on the acquisition side through SdkClient.on_task().

Debugging

Construct with debug=True and read back everything the client would have sent:

tc = TaskClient(debug=True)

with tc.experiment() as e:
    tc.send_event(pressed="k")

for event in tc.debug_events:
    print(event)
    # {"id": 1, "timestamp": ..., "event": "start_experiment", "value": "1"}
    # {"id": 2, "timestamp": ..., "event": "pressed", "value": "k"}

Nothing is transmitted in debug mode. The client logs a warning at startup to make this obvious. Remember to turn it off before a real session.