LoopIT

Writing a RALGOL extension

neuroConn GmbH

2026-06-05

Introduction

The RALGOL standard libraries cover arithmetic, statistics, montages, phase utilities, and rolling-window analysis (see the language reference). When an experiment needs something they do not provide, the language can be extended with a small C++ function — an extension — that becomes callable from a script just like a built-in.

This guide explains how to write one. It is for someone comfortable with basic C++. A script itself stays in RALGOL; the extension is the one place where native code is allowed, and it is deliberately small and self-contained.

How extensions reach a device. An extension is native code running inside the real-time loop, so on a medical device it is handled as software of unknown provenance under IEC 62304: you write and test the function, then submit it to neuroConn, which reviews, tests, and deploys it through a managed software process. You do not deploy native code to a device yourself. This guide covers the part you own — writing and locally testing the function.

What an extension is

An extension is a single C++ function that takes a list of integer arguments and returns a list of integer results. It is compiled to a small loadable module and exposed to scripts under a namespace (the module’s name). A script then calls it exactly like a standard-library function:

myext::scale(ral.input, 3) -> ral.output;

The same integer ABI and conventions as the standard library apply (see Conventions): everything is a 64-bit integer, and fractional results use the x1000 scaling convention.

A minimal extension

#include "write_extension.hpp"

MOD_NAME("myext");
MOD_AUTHOR("Your Name");

// callable from a script as myext::add(a, b)
define(add, 2, 1)
{
    int64_t a = args.get<0, int64_t>();
    int64_t b = args.get<1, int64_t>();
    return {a + b};
}

Three pieces:

  • MOD_NAME("myext") sets the script-level namespace. The function above is called myext::add. Keep the name short and consistent with the module.
  • define(add, 2, 1) declares a function named add taking 2 arguments and returning 1 value. The numbers are part of the contract: a script that calls it with the wrong number of arguments is rejected at compile time.
  • The body reads its arguments and returns a result.

Arguments

Arguments arrive as a list. Read them by position with args.get:

define(scale, 2, 1)
{
    int64_t value  = args.get<0, int64_t>();   // first argument
    int64_t factor = args.get<1, int64_t>();   // second argument
    return {value * factor};
}
  • args.get<index, int64_t>() retrieves the argument at a zero-based position.
  • Reading past the end returns 0 (a safe no-op), so a missing argument never crashes.

A function can also be variadic — accept any number of arguments — which is how the statistics functions work. That uses a flattening helper to collect all the arguments (including array slices) into one sequence; ask neuroConn for the variadic template if you need it, as most extensions have a fixed argument count.

Return values

Return a braced list. Its length must match the count in define:

return {result};       // one value   (retcount 1)
return {a, b};         // two values  (retcount 2)
return {};             // no value    (retcount 0)

Conventions

Extensions follow the same conventions as the standard library, so they compose cleanly with it:

  • Integer ABI. Arguments and results are 64-bit integers. There is no floating-point type at the script boundary.
  • x1000 scaling. If your result is naturally fractional, scale it by 1000 and say so in a comment. Angles are in milli-radians or milli-degrees, frequencies in millihertz, and so on.
#include "write_extension.hpp"
#include <cmath>

MOD_NAME("trig");
MOD_AUTHOR("Your Name");

// myext-style sine: phase in milli-radians, result scaled x1000
define(sin_mrad, 1, 1)
{
    double phase = args.get<0, int64_t>() / 1000.0;   // unscale input
    double y = std::sin(phase);
    return {static_cast<int64_t>(std::round(y * 1000.0))};   // scale output
}

Reading a rolling window

Analyzers like a moving average or a single-bin DFT read a window of recent samples from a ringbuffer. The storage itself (init/append/reset/clear) is provided by the engine; an extension only reads the current window:

#include "write_extension.hpp"
#include "ringbuffer_consumer.hpp"

MOD_NAME("mystats");
MOD_AUTHOR("Your Name");

// peak-to-peak amplitude over a ringbuffer slot's window
define(peak_to_peak, 1, 1)
{
    auto *rb = eelib::current_ringbuffer();
    if (rb == nullptr)
        return {static_cast<int64_t>(0)};        // not bound; safe fallback

    auto slot = static_cast<std::size_t>(args.get<0, int64_t>());
    auto win  = rb->get_window(slot);
    if (win.empty())
        return {static_cast<int64_t>(0)};

    int64_t lo = win[0], hi = win[0];
    for (auto sample : win)                       // window is iterable
    {
        if (sample < lo) lo = sample;
        if (sample > hi) hi = sample;
    }
    return {hi - lo};
}

The window view offers empty(), size(), indexing, and iteration. A script binds a ringbuffer in its prolog and calls the analyzer through the method-call form, which passes the slot index for you:

script {
  prolog {
    let ringbuffer(256) -> @window;
  };
  @window::append(ads.0.voltage_chan_1);
  mystats::peak_to_peak(0) -> ral.amplitude;
};

Calling an extension from a script

Once deployed, an extension is just another namespaced function. There is nothing special to import — a script names the function and the device resolves it:

script {
  trig::sin_mrad(ral.phase) -> ral.wave;
};

If the device does not have that extension, the script is rejected at deploy time with a clear message, so a missing extension is caught before anything runs.

Best practices

  1. Keep it pure. A function should compute from its arguments (and, for analyzers, the current window) and return a result. Avoid hidden state.
  2. Guard the ringbuffer. current_ringbuffer() may be null and a window may be empty; return a safe value rather than dereferencing blindly.
  3. Document the units and scaling of every argument and return value in a comment — this is what the reviewer and the next user rely on.
  4. Keep names short and matched to the module name.
  5. One responsibility per function. Small, well-named functions compose better in a script than a single large one.

Submitting an extension

When the function does what you need and you have tested it locally, send it to neuroConn together with a short description of its purpose, the units and scaling of its arguments and result, and any test data you used. It then enters the managed software process (review, risk assessment, verification, and testing) before it is deployed to a device. This is what keeps native code on a medical device accountable.

References

  • Language reference — the syntax and the existing function libraries your extension joins.
  • Scripting guide — writing and deploying the scripts that call it.
  • The extension SDK (headers and the local build steps) is available from neuroConn.
LoopIT documentation · neuroConn