Writing onboard real-time scripts for the LoopIT (RALGOL)
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:
- Compile — the text is parsed and checked, producing an intermediate representation.
- Assemble — the intermediate representation is turned into bytecode and linked against the functions and device configuration available on your device.
- 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 namedral.1w emit unsigned ticks;is one field: one word wide, an unsigned integer, namedticks. Theemitflag 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 functionstd::add, adds one to the current value ofral.ticks, and the arrow->stores the result back intoral.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:
unsignedandsignedintegers. You can attach a physical unit and a set of valid values, for example1w unsigned {unit = 0.001 A, valid = (0, [1:2000])} target;— values are 0 or anything from 1 to 2000, interpreted in milliamps. Thevalidset 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 inexpr -> 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 ontostd::add,std::subtract,std::multiplyandstd::divide. - Literals are integers (you can write
true/falsefor 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 includesd(standard deviation),fftandfft_phase(single-bin magnitude and phase). These return their result scaled by 1000 so it fits in the integer interface — hence the field nameaverage_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.