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 you can use the shorthand ral.field, which means ral.0.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::), montage helpers (mntg::), and phase utilities (rgu::). 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 toolchain runs on a normal PC, so you can develop without a device. A helper script chains the three stages — compile, assemble, dry-run — for a given source file and device configuration:

ralgolitcc my_script.ralgol --dcf <device-config.json> --runs 5

It compiles my_script.ralgol, assembles it against the device configuration, and executes a few cycles so you can see it run. When something is wrong, the compiler prints a message with a line, a column and an error code, and a non-zero exit status tells the helper to stop. 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 ralgolitcc, read any message, fix, and repeat. Because the compiler checks both the language and the availability of every function and field you use, a script that compiles and assembles cleanly is very likely to run on the device.

Tooling

There is no dedicated editor or language server — you write the script as plain text and use the command-line toolchain. For everyday work, ralgolitcc (above) is all you need; under the hood it runs the compiler, the assembler and the executor in turn. In production you do not run the toolchain at all: you deploy the script text over the parameter server and the device compiles it.

The full toolchain runs on Linux. If you work on Windows, the compiler alone (ralgolitc) is also available there to check a script’s syntax and semantics and emit its compiled form; assembling and dry-running a script (the full ralgolitcc loop) is Linux-only. Either way the device itself always re-checks a script before it runs, so the platform you author on does not affect what the device accepts.

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? No dedicated IDE or language server. You edit plain text and use the command-line toolchain (ralgolitcc wraps the compile, assemble and dry-run steps); 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 every function library (with signatures and descriptions). This guide is the tutorial; the reference is the lookup table.

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 — full syntax and the function libraries with signatures.
  • Device-specific function lists and example scripts are available from neuroConn.
LoopIT documentation · neuroConn