LoopIT

Writing onboard real-time scripts for the LoopIT (RALGOL)

neuroConn GmbH

2026-06-05

Introduction

For closed-loop experiments you want the control logic to run on the device, in its real-time loop, not across a network. The LoopIT runs small programs written in RALGOL, a purpose-built language for exactly this. A RALGOL script reads the device’s live signals, computes something, and writes outputs — every millisecond, deterministically.

RALGOL is a domain-specific language, not C or C++. You write a short script, the device compiles it to compact bytecode, and a small virtual machine executes that bytecode inside the main controller. This keeps timing predictable and keeps a script from destabilising the device.

This guide is written for someone new to the system who can program but has not seen RALGOL before. It builds up from the smallest possible script to a rolling-window analyser, and it shows how to develop your own scripts by compiling them and reading the compiler’s messages. Every script in this guide is checked by an automated test that compiles, assembles and runs it against the toolchain, so the examples are known to work.

How a script runs

A script goes through three stages:

  1. Compile — the text is parsed and checked, producing an intermediate representation.
  2. Assemble — the intermediate representation is turned into bytecode and linked against the functions and device configuration available on your device.
  3. Execute — the bytecode runs on the virtual machine, once per cycle.

The real-time engine runs the script every 1 ms. There is also a slower, event-driven engine for logic that does not need hard real-time; you select between them with a parameter (see Choosing the engine).

Deploying a script

You deploy a script over the parameter server (the JSON/TCP interface) by writing the script text, as a string, to the .protocol field of the ral module:

{"ral": {"0": {".protocol": "1w interface{1w reserved;} ral; script{};"}}}

The device compiles and checks the script before it runs. On success the reply echoes the script and, when the device has a timing model, includes an estimated_runtime with the per-cycle cost in microseconds (50th, 90th and 99th percentile). If the script does not compile, the reply contains the compiler’s error message; if it would not fit the real-time budget, the reply explains that instead. You can read the running script back by writing null to .protocol. The parameter server guide covers these messages in detail.

For development you do not need a device at all — you can compile and dry-run scripts locally with the toolchain, described in Developing your own script.

Anatomy of a script

Every script has two parts: an interface block that declares the script’s own data fields, and a script block that runs every cycle. Here is the smallest useful script — a counter that the device streams out over LSL:

1w interface {
  1w emit unsigned ticks;
} ral;

script {
  std::add(ral.ticks, 1) -> ral.ticks;
};

Reading it line by line:

  • 1w interface { ... } ral; declares an interface that is one word (32 bits) wide. An execution-engine interface is always named ral.
  • 1w emit unsigned ticks; is one field: one word wide, an unsigned integer, named ticks. The emit flag means the device streams the field continuously over LSL.
  • script { ... }; is the body that runs each cycle.
  • std::add(ral.ticks, 1) -> ral.ticks; calls the library function std::add, adds one to the current value of ral.ticks, and the arrow -> stores the result back into ral.ticks.

The interface block

The interface declares the fields your script owns. The LoopIT is a 32-bit machine, so sizes are given in words (1w = 32 bits) or bits (12b). A single field is at most 32 bits and may not straddle a word boundary, and the whole interface must be a whole number of words.

Field types:

  • unsigned and signed integers. You can attach a physical unit and a set of valid values, for example 1w unsigned {unit = 0.001 A, valid = (0, [1:2000])} target; — values are 0 or anything from 1 to 2000, interpreted in milliamps. The valid set is a parenthesised list of single values and [low:high] ranges.
  • bool — a one-bit true/false field.
  • enum { name = value, ... } — a small set of named integer states.
  • oneof — a tagged union, where one memory region is interpreted differently depending on a mode selector.
  • reserved — padding that fills space without exposing a field.

For a step-by-step tour of these — from a single boolean up to a multi-mode oneof parameter — see the interface design guide.

Field flags change how a field behaves:

Flag Meaning
emit Stream this field continuously over LSL.
protected Readable by clients but not writable from outside the script.
hidden Not shown in discovery queries (still usable if the name is known).
persistent Value survives across script reloads, so two scripts can share it.
const A fixed configuration value, set once.

Fields that the host should be able to set (see reacting to host parameters) must not be protected. persistent and const are not allowed on inputs that the hardware provides.

The script block

The body runs top to bottom every cycle. Statements either call functions or move data with the arrow operator.

  • Assignment uses ->: the value on the left is stored into the variable on the right, as in expr -> ral.field;.
  • Function calls use a namespace and name: std::add(a, b). Calls can be nested, std::add(1, std::add(2, 3)), and the arithmetic shorthands + - * / map onto std::add, std::subtract, std::multiply and std::divide.
  • Literals are integers (you can write true/false for 1/0). There are no floating-point literals; physical scaling is handled by field units.
  • Comments are // to end of line, or /* ... */.

Referring to variables

A variable name has the form module.index.field. The index distinguishes multiple instances of the same module, counting from zero, so the first ADS amplifier’s first channel is ads.0.voltage_chan_1. For your own interface, address it as self.field (equivalently ral.self.field, or the shorthand ral.field).

Which modules and fields exist depends on the device. Discover them the same way you discover JSON parameters — by asking the device — and write your script against the fields it reports.

Control flow

RALGOL has if statements but no loops (a script is itself the loop, run every cycle). There are three forms.

A truish if runs its body when the expression is non-zero. This script counts cycles only while the host has set ral.enable:

2w interface {
  1w unsigned {valid = (0, 1)} enable;
  1w emit unsigned ticks;
} ral;

script {
  if (ral.enable) :
    std::add(ral.ticks, 1) -> ral.ticks;
  fi;
};

When all the body would do is copy a boolean, you do not need an if at all — assigning the condition directly is the simpler and faster form. This gates a digital output on the same flag, with no branch:

1w interface {
  1w unsigned {valid = (0, 1)} enable;
} ral;

script {
  ral.enable -> dio.0.digout_1;
};

A switch-like if compares an expression against integer cases with is, and an optional else catches the rest. This script flips a digital output every cycle:

1w interface {
  1w reserved;
} ral;

script {
  if (dio.0.digout_1)
    is 0: 1 -> dio.0.digout_1;
    is 1: 0 -> dio.0.digout_1;
  fi;
};

The else: branch catches every value no is case matched. Here the host selects a gain with ral.setting, and anything unexpected falls through to a safe default:

2w interface {
  1w unsigned setting;
  1w emit signed gain;
} ral;

script {
  if (ral.setting)
    is 1: 10 -> ral.gain;
    is 2: 20 -> ral.gain;
    else: 0 -> ral.gain;
  fi;
};

The else belongs to the switch-like form only; a truish if has no else branch.

Each if ends with fi;. The else branch, if present, must come last, and you cannot repeat the same is value twice — the compiler rejects both mistakes (you will see this in developing your own script).

Reacting to a parameter set by the host

The two interfaces work together. A field you declare in the ral block is the same field the host can write over the JSON parameter server, and your script reads it back by name. So the host sets a high-level parameter and the script consumes it in real time.

The control-flow examples above already do this: the host writes ral.enable over JSON, and the script counts cycles or gates a digital output on it. Use persistent on a field if you want its value to carry over when you reload the script — for example one script estimates a value, a second reuses it.

Working with live signals

Inputs from hardware modules are read just like any other variable. This script forms the difference of two amplifier channels (a bipolar derivation) and emits it:

1w interface {
  1w emit signed difference;
} ral;

script {
  mntg::bipolarize(ads.0.voltage_chan_1, ads.0.voltage_chan_2) -> ral.difference;
};

mntg::bipolarize is one of the library functions; others cover arithmetic and comparisons (std::) and montage helpers (mntg::), all listed in the function library. The exact set available on a device is validated when you deploy, so treat the namespaces here as representative rather than exhaustive.

A persistent field lets a script keep state across reloads. This one accumulates a running total of a channel:

1w interface {
  1w persistent signed total;
} ral;

script {
  std::add(ral.total, ads.0.voltage_chan_1) -> ral.total;
};

Rolling-window analysis

Some analyses need a window of recent samples — a moving average, a standard deviation, a single-bin frequency magnitude. RALGOL provides a ringbuffer for this. You declare one in a prolog block (which runs once, at start-up) and then call methods on it each cycle. This script keeps a 64-sample window of a channel and emits its moving average:

1w interface {
  1w emit signed average_x1000;
} ral;

script {
  prolog {
    let ringbuffer(64) -> @window;
  };
  @window::append(ads.0.voltage_chan_1);
  @window::mova() -> ral.average_x1000;
};

What is new here:

  • prolog { ... }; is a one-time setup section. It must be the first thing in the script block.
  • let ringbuffer(64) -> @window; creates a 64-sample ring buffer and names it @window (the @ marks a stateful instance, as opposed to a data field).
  • @window::append(...) adds the latest sample; @window::mova() returns the moving average. Other methods include sd (standard deviation), fft and fft_phase (single-bin magnitude and phase). These return their result scaled by 1000 so it fits in the integer interface — hence the field name average_x1000.

Choosing the engine and stopping a script

A companion field, .cycle_time_in_ms, selects which engine runs your script. A value of zero or less selects the 1 ms real-time engine; a positive value selects the slower event-driven engine for logic that does not need hard timing. Set it before deploying the script.

To stop whatever is running, deploy a do-nothing script. This minimal script is valid, declares nothing and does nothing, and so replaces and halts the previous program:

1w interface {
  1w reserved;
} ral;

script {};

Developing your own script

The fastest way to learn the language is to compile often and read the compiler’s messages. The ralgolitc compiler runs on a normal PC — Linux or Windows — so you can write and check a script without a device. Pass it a source file:

ralgolitc my_script.ralgol

It checks the script’s syntax and semantics; on success it prints the compiled form and exits with status 0. When something is wrong it prints a message with a line, a column and an error code, and exits non-zero. Reading those messages is the core skill. Here are three mistakes you will make, and what the compiler says.

A field that is too wide for a word — 40 bits will not fit in a 32-bit word:

1w interface {
  40b unsigned huge;
} ral;

script {};

The compiler reports error[E208] (the field exceeds 32 bits), along with the follow-on word-alignment and size errors. Shrink the field, or split it.

A forgotten semicolon — every statement ends with one:

1w interface {
  1w emit unsigned ticks;
} ral;

script {
  std::add(ral.ticks, 1) -> ral.ticks
};

The compiler reports error[E303] (a missing semicolon) and points at the line.

An else branch that is not last in a switch-like if:

1w interface {
  1w reserved;
} ral;

script {
  if (dio.0.digout_1)
    else: 1 -> dio.0.digout_1;
    is 1: 0 -> dio.0.digout_1;
  fi;
};

The compiler reports error[E308] (the else must come last). Move it to the end.

The workflow, then, is: write a little, compile with ralgolitc, read any message, fix, and repeat. The compiler runs the same syntax and semantic checks the device runs before it accepts a script, so a clean compile means a well-formed script; the device makes the final check — that every function and field you use exists on that particular device — when you deploy. The compiler guide lists the full options and the error-code families.

Tooling

You write a script as plain text and check it with the ralgolitc compiler (above), which runs on both Linux and Windows. For editing, the VS Code extension runs ralgolitc on save and shows its diagnostics inline. In production you do not run a toolchain at all: you deploy the script text over the parameter server, and the device compiles, admits and runs it — so the device always has the final say, whatever platform you authored on.

Extending the language

A script is a single file: there are no user-defined functions, includes or modules. You compose behaviour from the built-in library namespaces. Where an experiment needs something the library does not offer, you can write a small C++ extension that adds a function to the library — see writing a RALGOL extension. Because such customer-supplied code becomes part of a medical device, neuroConn reviews, tests, and deploys it through a controlled software process (risk management, verification and testing) before it runs.

Frequently asked questions

Is the onboard language a dedicated DSL or C/C++? A dedicated language, RALGOL. It compiles to bytecode that runs on the device’s virtual machine in the 1 ms real-time loop. See How a script runs.

Is there an IDE for writing and deploying scripts? There is no language server, but a VS Code extension gives syntax highlighting and runs the ralgolitc compiler on save to show diagnostics inline. Otherwise you edit plain text and run ralgolitc yourself. Deployment to a device is a single parameter write to .protocol. See Tooling and Deploying a script.

Does the language support functions or multiple files? A script is one file with no user-defined functions or imports; you compose from the library namespaces, and deeper functionality is added by neuroConn as reviewed extensions. See Extending the language.

Is there fuller documentation of the language? Yes — the language reference lists the full syntax, and the function library lists every function with signatures and descriptions. This guide is the tutorial; those are the lookup tables.

Can a script read parameters set over the JSON interface? Yes. A field you declare in the ral interface is written by the host over JSON and read back by the script as ral.field; use persistent to share values across reloads. See reacting to a parameter set by the host.

References

  • Language reference — the full RALGOL syntax.
  • Function library — every function, with signatures.
  • Device-specific function lists and example scripts are available from neuroConn.
LoopIT documentation · neuroConn