Lab automation built from independent instrument modules. Every instrument runs as its own small service with its own GUI; a launcher starts them, and one scan engine drives any combination of them through N-dimensional measurements. Nothing in the engine knows what a magnet or a lock-in is -- a new instrument is a new folder, and it appears in the launcher, the control panel and the scan builder by itself. The companion data viewer is AaltoView.
Several people can work on one setup at once: the first GUI to connect controls an instrument, every other window on another PC is a live viewer, and the STOP / RF-off buttons work from all of them -- see Control.
Aalto is Finnish for "wave". AaltoFlow was born as the Python replacement of the LabVIEW software of the TR-MOKE (time-resolved magneto-optical Kerr effect) setup of the NanoSpin group, Aalto University, and was called TRMOKE until 2026-09-24. An installation can still say which setup it drives: Setup asks for a setup name (e.g. "TR-MOKE"), and every window title leads with it.
The point of the rewrite is not a one-for-one port. It is to separate the
measurement engine from the instruments, so that arbitrary N-dimensional
acquisition is a generic capability rather than something hardcoded for one
experiment. The old ControlTRMOKEV21setparam.vi had a hard two-loop ceiling and
a fixed parameter set; adding one knob meant editing seven places. Here, adding a
knob means registering one Parameter.
Status: mostly simulation. Every module runs, is tested and has a GUI. Hardware passes are under way on the lab PC: pm16 (Thorlabs PM16 power meter), kim (KIM101 inertia stage), the IDS camera and the Signal Hound SA44B have run on the real instruments. Verified instruments lists what was checked, the tests and commits behind it, and the caveats found. Elsewhere, every unverified hardware call is marked
# VERIFY. See Hardware passes.
One-page flyers (PDF and PNG) for the suite, the camera, clMag, the KIM stage
and the Navigator are in docs/flyers/; two demo videos, a short
one and a screen recording on the lab setup, are in docs/video/.
Three layers, each of which can be run, tested and replaced on its own.
flowchart TB
MC["mission-control<br/><i>launcher: spawns services and GUIs</i>"]
SC["scan-core<br/><i>recipe + registry + N-D engine</i><br/><b>no instrument knowledge</b>"]
subgraph SVC["instrument services -- one process per instrument"]
direction LR
M["clMag<br/>5555/6"]
S["smb<br/>5557/8"]
ST["stage<br/>5559/60"]
P["piezo<br/>5561/2"]
C["camera<br/>5563/4"]
Z["zpiezo<br/>5565/6"]
K["kim<br/>5567/8"]
H["hf2<br/>5569/70"]
PM["pm16<br/>5571/2"]
V["vna<br/>5573/4"]
M2["mag2d<br/>5575/6"]
M2C["mag2dcal<br/>5577/8"]
PP["ppms<br/>5579/80"]
end
G["GUIs and consoles<br/><i>clients, not owners</i>"]
SC -- "ZeroMQ" --> SVC
G -- "ZeroMQ" --> SVC
C -. "drives XY" .-> P
C -. "drives Z" .-> Z
V -. "listens to the field" .-> M2
V -. "or the DynaCool's" .-> PP
MC -.->|spawns| SVC
MC -.->|spawns| G
1. Instrument services. One process per instrument, and that process is the
single source of truth for it. Each speaks the same wire contract: ZeroMQ,
REQ/REP JSON for commands, PUB/SUB for telemetry. Commands are
fire-and-forget — a reply of {"ok": true} means accepted, not done; the
caller polls status for the effect. Every module has the same universal verbs
(status, info, get_config, set_config, describe, shutdown) plus one
verb per setter.
Because every service accepts many clients at once, each one also decides who may change the instrument: one PC has control, every other GUI is a live viewer whose knobs are locked, and STOP-type buttons work from everywhere (see Control).
Because the transport is the network, a client does not care whether it runs on
the same PC or across the lab Ethernet — only the host changes. camera-control
demonstrates this: it owns no motion hardware at all and drives XY through
piezo-control and Z through zpiezo-control as an ordinary client.
2. scan-core — the coordinator. This is the generic part, and it contains no TR-MOKE physics:
- A scan is data. A recipe is YAML:
fixedvalues, an ordered list ofaxes(outer → inner, no length limit),detectors, andhooks. You can save it, diff it, version it and re-run it headless. - Every knob is a registered Parameter. A
Settablehas limits, a blockingsetand aget; aGettablehas aget. Adding an instrument means adding Parameters — they then appear in the builder and the engine with no other edits. - The engine is an odometer over the compiled dimensions. It sets only the
axes whose index changed, fires hooks, reads every detector, and returns an
xarray.Datasetwith named coordinates, units and the full recipe in its metadata, written to netCDF. 1-D and 5-D are the same code path.
Nothing in recipe.py, registry.py or engine.py knows what a magnet is, and
scan-core imports no instrument package at all: it reaches the services through
one generic ZeroMQ client, because they all speak the same contract. Connecting
an instrument is a declaration in scan_core/lab.py — which verb sets the knob,
which status field reads it back, and how you know it has arrived — not new
engine code.
3. mission-control. The launcher. It finds the modules (every folder
modules/<category>/<name>-control with a module.toml), starts their services, opens their GUIs, lets you change ports and
add services running on other PCs, and shows each module's variables as the
service reports them. It never imports the instrument packages; it only spawns
their scripts, so there is no version coupling. The measurement suite follows
what the launcher has.
Every module is a network service, so many clients can be connected to one instrument at the same time: GUIs on several PCs, the measurement suite, scan-core, another module (the camera drives the piezo stages), scripts and consoles. That is what makes remote work and teaching possible -- and it is also how a trainee's click on another PC ends up moving a stage under somebody else's measurement. AaltoFlow therefore has one rule for all 38 modules:
One PC controls an instrument; everybody else watches. The first GUI to connect gets control. Every later GUI opens as a viewer: it sees all readouts live, but nothing in it can be changed -- except the buttons that make the instrument safe (STOP, RF off, output off, ...). Control changes hands only by a deliberate "Take control".
The GUI that holds control shows a quiet line at the top of its window -- who else is watching, and which programs are driving the instrument as well:
The same instrument seen from another PC. The amber bar says who has control and since when. Every knob swallows its clicks; a blocked click flashes the bar red and says why, and RF Off still works because it is a safety verb:
A module window started on its own (no --connect) owns its instrument
in-process and has nobody to share it with, so it has no lock and no bar.
Everything below applies to a window connected to a service, and that is
how the launcher opens a GUI whenever the module's service is running.
stateDiagram-v2
[*] --> Asking: the window opens and connects
Asking --> Control: nobody holds control
Asking --> Viewer: another PC holds control
Control --> Viewer: Release, or another PC takes over
Control --> Viewer: silent for 10 s (lease ran out)
Viewer --> Control: Take control (asks first if another PC holds it)
Viewer --> Viewer: a click on a knob is swallowed, the bar flashes red
- At start the window asks once. When the window has been built, it asks the service for control without force. If nobody holds control, it gets it, and the log says "this window has control". If another PC holds control, the window opens as a viewer, and the log says who holds control. It never takes control from somebody by itself, not even later, when control becomes free. That is why the first GUI gets control and why control changes hands only when somebody clicks.
- The bar follows the service, not the window. Every status frame the
service broadcasts (several per second) carries
control: {holder, clients, lease_s}. The window's status timer passes it to the bar, and the bar redraws only when something in it changed:- holder (this PC): "You have control (this PC) · watching: · also driving: ", with a Release button;
- viewer: an amber bar, "VIEWER -- has control since hh:mm; nothing can be changed here", with a Take control button;
- nobody holds control (the holder released it or went silent): still a viewer, "nobody has control at the moment", and one click on Take control makes it yours. Nothing becomes yours without a click, so a person who walks up to a screen always knows whether it can change the instrument.
- A viewer's inputs swallow their input. The bar installs one event filter for the whole application. In viewer mode it throws away mouse presses, releases, double clicks, the wheel and key presses on every input widget of the main window: buttons, number boxes, combo boxes, text fields, sliders. A blocked press turns the bar red for a moment, so the person sees why nothing happened. Everything that only shows keeps working: readouts, live plots and camera pictures, tabs, scrolling, selecting text in the log.
- Safety buttons are exempt. A button marked with
mark_always(STOP, RF off, Kill AF, Cancel zero, Settings, Refresh, ...) passes the filter, because the service accepts its verb from anyone. A module with one on/off toggle and no separate off button (kepco, chopper, cs260, mag2d, ...) marks the toggle while it reads "off": a viewer can then switch the output off (the toggle sends the safety verb), but not on. - Dialogs are not guarded. A viewer may open Settings and look. Its OK goes to the service, and the service refuses it with a message that names the holder.
Why an event filter and not setEnabled(False): every GUI already enables
and disables its own widgets from its status timer (during an autofocus, while
a stage is homing, when the instrument is disconnected). Greying the window
from outside would fight that code, and switching everything back on at the
next hand-over would enable buttons that should stay off. The filter leaves the
widgets exactly as their own code wants them and only drops what a person does
to them.
Why the GUI locks at all, if the service already refuses: the service is what protects the instrument. Without the lock in the window, a viewer could turn a number box to a new value and click Set; the box would show the new value, and only then would an error arrive. The window would then show a setting the instrument does not have. With the lock, what a viewer window shows is always what the instrument is doing.
- The service enforces it, not the GUI. Every request may carry a
"client": {"id", "kind", "name", "host"}. The first thing a service does with a command (ControlLease.handle, incontrol.py) is check who sent it. A command that would change something is refused unless it comes from the holder's PC, with{"ok": false, "refused": "control", "error": "read-only: <who> has control since hh:mm ... take control first"}. The client libraries raiseControlRefusedon that reply, so a script never believes a setting was taken when it was not. The viewer bar in the GUI only makes this visible -- it is not what protects the instrument. - Control belongs to a PC, not to a window. Any client whose
host(user@PC) names the holder's PC may change things, keep control alive and release it: a GUI and a console on the same PC work together. A window on a different PC is a viewer. - Always allowed, for everyone: reading (
status,info,describe,get_config, everyget_*/read_*/list_*verb, plus a module's own read verbs such asstream_read), the module's safety verbs (below),shutdown(the launcher's clean stop) and the control verbs themselves. A person who sees a stage run away must be able to stop it from any window. - Safety verbs only make things safe. If a module's only "off" is a
setter that can also switch something on (
set_rf(false),set_output(false)), the module has a dedicated verb that can only switch off (rf_off,output_off,ramp_to_zero, ...). Acquisition triggers (acquire,take_reference) are not safety: a new trigger replaces the sample another client is waiting for. Every safety verb is also an action in the module'sdescribe, so the suite's Control tab can offer it to a viewer. - Machines are not locked out. A client with
kind: "machine"passes the lock: the camera moving the piezo stages during an autofocus, the signal-generator module driving the Signal Hound's tracking generator, scan-core during a scan. Opening a GUI must never break a running autofocus. So that a moving stage is never a mystery, a machine that changed something in the last 10 s is shown as "also driving: " in every control bar and in the suite. - A silent holder loses control. Clients send a
heartbeatevery 2 s. A holder that has been silent for 10 s (a crashed GUI, a closed laptop) loses control, so no instrument stays locked for good. - Taking over is announced.
take_controlsucceeds when nobody holds control.take_control(force=True)("Take control" in a viewer,take!in a console) takes it over from another PC (the GUI asks first); the old holder becomes a viewer, and its log says who took over. - Nobody holding control means no lock. With no holder every command passes, with or without an identity, exactly as before. A headless setup and an old script therefore keep working unchanged.
- A script is a client like any other. A script or console (
kind: "script") can always read and stop. While a GUI on another PC holds control it must take control before it may change anything, and that take-over is visible to the GUI it took it from.
- A scan needs control. Before anything moves, scan-core claims every
instrument the scan uses (
claim_scan). The claim is refused while another PC holds control of one of them: the scan stops withScanBusynaming the holder, and nothing has been sent. If the scan's own PC holds control, the person keeps it afterwards. If nobody holds control, the scan's PC takes it for the length of the scan, and it is freed again at the end (also after an abort or an error). - One scan at a time per instrument. While a scan holds the claim, a
second scan engine is refused (
busy: scan '<label>' from <who> ... since hh:mm), whether it runs on the same PC or on another one. Heartbeats keep the claim alive through long settles, and a crashed scan frees it after 10 s. Control bars and the Control tab showscan '<label>' running (<PC>).
The suite's Control tab counts as a person, not a machine: its clicks go out as a GUI client, while its scan engine stays a machine. A strip of chips at the top shows the state of every connected module -- green ● you have control, amber ◆ another PC has it, grey ○ nobody has it (▶ while a scan runs). The tooltip says who and since when, and a click on a chip offers Take control or Release. A padlock on each module in the AVAILABLE tree shows the same state. While another PC holds control, the tab greys that module's knobs, except the actions the service still accepts. The suite does not take control by itself when it connects.
Above, this PC holds clMag (●), a GUI on another PC holds kim and hf2 (◆, closed padlocks), and smb and piezo are free (○).
For an EXPERIMENT -- set, wait, focus, scan, repeat -- use scan-core's scripting API, docs/SCRIPTING.md: a script there is treated exactly like a scan (it claims each instrument it changes, and is refused while another PC holds control). Talking to one module's client directly, as below, is for small tools such as an alignment helper.
from kim.net.client import KimClient
c = KimClient(host="lab-pc", kind="script", name="my alignment script")
c.start() # starts the heartbeat as well
c.take_control() # False if a GUI on another PC holds it ...
c.take_control(force=True) # ... this takes it over (the GUI is told)
c.move_to_step(0, 4000) # raises ControlRefused if control was lost
c.release_control()
c.close()Every console (scripts/<module>_console.py) has take, take! (take
over), release and clients (who holds control, who is connected), and
sends heartbeats while it is open.
| module | safety verbs (always allowed) | extra read verbs |
|---|---|---|
| agilis, ddr25, smaract | stop |
stream_read |
| elliptec, piezo, stage | stop |
|
| kim | stop, abort_px_calibration |
stream_read |
| chopper | stop (wheel to standby) |
|
| camera | kill_af, cancel_laser_target |
stream_read, camera_features |
| clMag | ramp_to_zero |
aux_read_ai |
| mag2d, mag2dcal | zero, output_off (mag2dcal: zero also aborts a calibration) |
|
| kepco | output_off (ramped), output_off_now |
ping |
| k2450, dsphase | output_off |
|
| sr830 | output_off (SINE OUT to its minimum, AUX OUTs to 0 V) |
stream_read |
| sr7230 | output_off (oscillator to 0 V) |
stream_read |
| smb, hp8648, dssg, shsg | rf_off |
|
| windfreak | all_rf_off |
|
| afg | outputs_off |
|
| scope | abort, stop |
|
| superk | emission_off |
ping |
| dsamp | amp_off |
|
| tc200 | heater_off |
|
| cs260 | abort, close_shutter |
|
| gsp818 | abort, tg_off |
|
| signalhound | abort, tg_abort |
tg_grid |
| ccs200, shsna, vna | abort |
|
| pm16 | cancel_zero |
stream_read |
| pm400 | cancel_zero |
|
| hf2 | -- (drives no output) | stream_read |
| ls455 | -- (a gaussmeter: nothing to switch off) | reread_probe |
| ppms | -- (every verb is a setpoint or a rate) | |
| usb6001 | -- (an output value that is safe depends on the setup) | |
| zpiezo | -- (a voltage is a focus position; nothing moves on its own) |
On its own, the lock guards against mistakes between people who follow the
rules. It is not security: the kind and the PC name are declared by the
client itself, and whoever reaches the port can send anything. Encryption
(next section) is what makes those claims checkable. The full rules and the
reasons behind them are in
docs/DEVELOPER_NOTES.md, section 4 ("Control");
how to add control to a new module is in
INSTRUMENT_MODULE_GUIDE.md, section 6.
Every module speaks it (since 2026-10-04), and so do the generic clients (scan-core, the launcher, the consoles). Until the lab switches it on, nothing changes.
Without encryption, anybody on the lab network can read what the modules say, send them commands, pretend to be one of them, or claim to be a "machine" and so pass the control lock. AaltoFlow can use CurveZMQ, the encryption built into ZeroMQ. It needs nothing extra installed, and the JSON commands, the GUIs and the control lock stay exactly as they are; only the pipe between them becomes private and checked.
Every PC gets a padlock and its key. Each lab PC has a key pair. The public key is like a padlock: you can hand it out freely, and it is one small text file. The secret key is the only key that opens it, and it never leaves the PC.
The lab has a keyring. This is a folder, on a network share for example,
that holds the public-key file of every trusted PC and a policy.json. Only the
lab's administrator should be able to write to it, because whoever can put a
file there is trusted.
When a GUI on PC-B connects to kim on PC-A:
PC-B (GUI) PC-A (kim service)
| "hello, I am PC-B" (proved with B's key) |
|-------------------------------------------->| Is PC-B in the keyring? yes -> ok
| "hello, I am PC-A" (proved with A's key) |
|<--------------------------------------------| B checks: really PC-A's key? yes -> ok
|==== everything after this is encrypted ====|
| {"cmd": "move_to_step", ...} | and kim knows it came from PC-B
| someone tries to... | result |
|---|---|
| read the traffic (commands or telemetry) | sees only noise |
| send commands from a PC whose key is not in the keyring | no answer; the command never reaches the instrument |
| pretend to be the kim service | the GUI does not believe it (it knows PC-A's key) |
claim "kind": "machine", or another PC's name |
refused: "refused (security): ... claims to be a machine, and pc-b may not act as one" |
The last row is what makes the control lock real. The service knows which PC's
key sent each message, so the PC name in the client's identity must be that PC.
The identity may say machine only if the keyring allows that PC to act as a
machine. A program on the service's own PC (the camera next to kim) may act
as a machine by default.
Everything is in Mission Control > Security... (next to Instruments...; the badge beside it says the lab's mode: Security: off, warn (all modules), enforce (3 modules)). The window has three tabs: This PC (what is set up here, and one line saying what to do next), Trusted PCs (the keyring) and Lab policy.
Once, for the lab (on the lab PC, by whoever runs the lab):
- Make an empty folder for the keyring, on a share every lab PC can read, and make it writable only for yourself: whoever can put a file into it is trusted.
- This PC > Choose keyring folder... > pick that folder. It has no policy yet, so the window offers to make it the lab's keyring. Say yes: the policy starts as off, so nothing changes yet.
- Make this PC's key. Tick this PC may run scans (machine) if it runs scan-core or the camera. Its public key goes straight into the keyring.
Every other PC (about two minutes):
- This PC > Choose keyring folder... > the same folder.
- Make this PC's key (tick may run scans where scans run). If the PC may write to the keyring, its key lands there and you are done.
- Usually it may not (only the administrator may). Then Save public key to file... and bring that file to the lab PC (USB stick, e-mail, the share). It holds only the public half, so it is safe to share.
Adding a PC, on the lab PC: Trusted PCs > Add a PC from its key file... > the file > check the name and may run scans > Add. The window writes the file into the keyring fresh, from the lab PC. That matters on a share that maps Linux permissions, where a file dropped in from another PC can be unreadable for everyone else (gotcha #44). A key file that cannot be read shows as a red row; adding it again this way fixes it. A file that holds a secret key is refused.
Retiring a PC (a laptop that left, a PC re-installed): Trusted PCs >
select it > Retire PC... It can no longer reach any secured module, and
that includes connections it has open right now: a secured service looks up
the key of every message, so within a few seconds those are refused too. In
warn mode they are let through, with one line in the service's log. The key
file is not deleted but moved to the keyring's retired/ folder, where it is
not trusted; Restore undoes the retirement. Retiring the PC you are
sitting at asks twice, because it locks that PC out of the other PCs'
secured modules.
Switching the lab on: Lab policy > the mode (warn first) and the modules (All modules, or Only these) > Apply to the whole lab... The confirmation says what will change. Before enforce it also lists the PCs in the keyring and which may run scans, because any PC that is not listed gets no answer. After Apply the window lists the services on this PC that still run in their old mode, and Restart them stops and starts them the way the Stop and Service buttons do. Services on the other PCs need a restart there.
tools/keys.py does the same steps. The window and the script share one
implementation (suite-common/src/suite_common/keyadmin.py).
Once, for the lab (on any PC):
python tools/keys.py init \\server\share\aaltoflow-keyring --mode warn --modules "*"
For every PC, this one included:
python tools/keys.py use \\server\share\aaltoflow-keyring
uv run --with pyzmq python tools/keys.py new # --machine if this PC runs scan-core
new makes the PC's key pair and copies its public half into the keyring. If
the keyring is read-only from that PC, new leaves the file next to you; bring
it to the lab PC and keys.py add it there. That is all. A new module, GUI or
script on a trusted PC needs nothing, because keys belong to PCs, not to
modules.
Everyday:
python tools/keys.py status |
this PC: its key, the keyring, the policy, "in the keyring: yes/no" |
python tools/keys.py list |
the trusted PCs (and the retired ones) |
python tools/keys.py export my-pc.key |
this PC's public key, to bring to the lab PC |
python tools/keys.py add my-pc.key [--machine yes] |
trust the PC of a key file (written fresh into the keyring) |
python tools/keys.py machine lab-pc-1 yes |
programs on lab-pc-1 may act as a machine (scan-core, the camera) |
python tools/keys.py retire old-laptop |
stop trusting a PC (remove is the old name); its file goes to retired/ |
python tools/keys.py restore old-laptop |
trust a retired PC again |
python tools/keys.py policy --mode enforce |
switch the whole lab to enforce |
Adding or retiring a PC takes effect within seconds, because the keyring is
re-read. A change of mode or of the module list takes effect when a module's
service restarts. Until then the clients still reach it: a request that gets
no answer is tried once more in the other mode (plain or encrypted), so the
GUIs, scans and Mission Control's Stop keep working. keys.py policy (and the
Security window) lists the services on this PC that still run in their old
mode; restart them. The policy is lab-wide: services on the other PCs need a
restart too.
policy.json sits in the keyring, so the whole lab switches together:
{"mode": "warn", "modules": ["kim", "camera"]}off(also for a PC that was never set up): plain, exactly as before.warn(switch this on first): everything is encrypted, and unknown keys and false identities are let through. Each one is written to the service's log once ("security: a PC whose key is not in the keyring connected ..."). After a week without warnings, switch to enforce.enforce: unknown keys get no answer, and false identities are refused.
modules lists the modules that speak CurveZMQ; ["*"] = all of them (every
module can since 2026-10-04). A module that is not listed keeps talking
plain, and every client -- which reads the same list -- talks plain to it. So
a lab can switch modules on one by one (kim first, piezo later).
- A client has no key, or its PC's security is off, while the module is
secured: the service does not answer at all ("no reply ... within 2000 ms").
Look at Mission Control > Security... > This PC on the client PC (or run
python tools/keys.py status): it says what is missing. - "refused (security): this PC's key is not in the keyring (retired?)": the PC was retired while connected. Restore it, or add its key again.
- "no key for '10.0.0.7' in the keyring": the client reaches that PC by an
address the keyring does not know. Add it with
tools/keys.py new --addresson that PC, or connect by the PC's name. - "this PC has no AaltoFlow key yet": run
tools/keys.py newon this PC. - "N key file(s) could not be read here" (
keys.py list/status, or "security: keyring file skipped" in a service's log): that PC's key file is not readable from this PC -- on a share that maps Linux permissions, a file written from one PC can be. Add it again from the lab PC (Security...Trusted PCs > Add a PC from its key file..., or
keys.py add): a file written from there is readable everywhere. Orchmod 644it. - The consoles (
scripts/<module>_console.py) use their module'ssecure.pywhen they sit in their module folder. A console copied elsewhere talks plain, so a secured module will not answer it.
How it is built (suite-common/src/suite_common/secure.py, copied into each
module like control.py) is described in
docs/DEVELOPER_NOTES.md, section 4 ("Encryption").
Nothing lists modules by hand. A folder with a module.toml (name, description,
icon, default ports, scripts) is a module, and the launcher, scan-core and the
tools all find it. The module's controls and measured variables are not in that
file: the running service reports them itself through describe.
The modules are sorted by what they are for: modules/<category>/<name>-control,
where the category (motion, imaging, detector, source, field,
environment) is the one written in the module's module.toml. The suite's own
projects -- mission-control, scan-core, suite-common -- and tools,
installer, docs stay in the top folder.
python tools/new_module.py vna --like smb --category detector --name "Network analyser" --description "R&S ZNB"
cd modules\detector\vna-control
uv sync --extra gui
uv run pytest -q # passes as generated
python ../../../tools/check_modules.py vna --livenew_module.py copies a working template under the new name and takes the next
free port pair. Then you replace its insides. Contract and walkthrough:
INSTRUMENT_MODULE_GUIDE.md, sections 10 and 11.
Everything below runs with no hardware attached -- against the simulator, or against services running in simulation mode. The data in the screenshots is simulated FMR from a patterned sample: discs and bars of different sizes, each resonating at its own frequency because its shape anisotropy differs, on a continuous film between them. That is not decoration -- a spatial map that changes with frequency and field is what makes the difference between a viewer that shows one picture and one that lets you move through a cube.
The measurement suite is the operator application: one window, five tabs. Its Control tab is built entirely from what each module reports about itself, so it knows nothing about magnets or piezo stages -- it asks, and lays out what it is told:
Define a scan on the Scan tab: axes stacked outer to inner (indented by loop depth, so which one is the slow one is visible rather than deduced), and underneath them the conditions -- parameters held at a single value for the whole run -- and the routines that run before, during and after it (set the magnet to a reference field, take a VNA reference; autofocus at the start of every row, or every 100 points; field back to 0 at the end). All of it is saved with the definition and inside the measurement file, so a file says what it was taken under and how, not only what was swept:
Then watch it run on the Measurement tab. Defining takes a minute; running takes an hour, and they want different screens. While it runs, the header says where it is -- point n of N, each axis's value with its position along the axis, the measured time left and the routine step in progress (an autofocus, say) -- and the live map outlines the point just measured:
Several scans can run one after another. Select more than one definition in Load scan… (recipes, measurement files, or a saved queue) and a dialog lets you name them, put them in order and drop the ones you do not want. Every entry is checked against the connected instruments first; one that names a module that is not running is shown in red and the queue will not start until it is fixed or removed:
While it runs the Measurement tab says which scan it is on and how long the rest will take. Each scan goes into its own file. Abort skips to the next scan, Stop queue ends the whole queue, and an error stops it too, since a module that died would fail the next scan the same way:
A finished scan is a cube, and the Data tab shows it two dimensions at a time: choose which dims are the image axes, and every dimension left over gets a control of its own -- hold it at one value, or average over it. Below is a frequency x Y x X scan of the array, held at 900 MHz: the film between the elements is near its own resonance, so the pattern reads as dark elements on a lit background, and the one element whose resonance sits at that frequency is lit right through. Drag the frequency slider and a different element lights up. The controls are built from the data, so a five-dimensional scan needs no new code:
cd scan-core
uv run python apps/suite.py # simulator
uv run python apps/suite.py --modules clMag,smb # live servicesThe Data tab is also a program of its own, the data viewer (the successor of the LabVIEW AaltoView). It needs no instruments: it lists the data folder newest first, draws maps with a cursor and row/column cuts, overlays 1-D curves (from one file or several, normalised and stacked), and exports what is on screen as a figure (PNG/PDF/SVG), as data (.dat/.csv, or the clipboard), into a running Origin (worksheet or matrix, with a graph), or as a Jupyter notebook that recomputes the view from the measurement files:
cd scan-core
uv sync --extra gui --extra origin # origin = the optional Origin push
uv run python apps/viewer.py [file.nc] [--folder D:\data]The viewer is its own (private) repository, FlashLukas/AaltoView, so colleagues who only analyse data can install it without the instrument suite. scan-core installs it as a dependency.
Mission Control starts every service and opens every GUI from one place:
Instruments… (next to Rescan) shows every instrument this PC can reach and
at which address: GPIB, USB and LAN instruments through VISA, each asked who it
is (*IDN?), and every COM port with what its USB chip says about it. A COM
port is sent nothing until you press Ask this port (a stray query at the
wrong baud rate can upset a motor controller or a laser), and an address a
running service holds is never opened -- the row says which module holds it.
Use for module… offers the selected address to every module it fits and
remembers the choice on this PC; the service gets it at its next start with
real ticked, and the card's real box shows it. An instrument on the network
that does not announce itself: type its IP and press Test.
It needs uv sync --extra instruments in mission-control (pyvisa, pyvisa-py,
pyserial) and, for GPIB, the VISA library of the GPIB card's maker (NI-VISA or
Keysight IO Libraries).
Instruments that are neither VISA nor COM -- a Thorlabs Kinesis controller, an IDS camera, an NI DAQ card, a Signal Hound, a Zurich lock-in, a Thorlabs power meter on its own driver -- show up too, from two sources that only LIST (no device is opened, no byte is sent): each module's probe, run in that module's own environment with its vendor library (so the launcher needs no vendor SDK), and the USB device list Windows keeps, which names known devices and the module that drives them. A device a running service holds may be missing from its vendor's list (FTDI does not list an open Kinesis controller); the USB list still shows it, marked as held. USB devices that are not a known instrument (keyboards, webcams) are hidden until show every USB device is ticked.
The Scan Builder stacks axes outer-to-inner with no length limit, and runs the engine against whichever registry it was given -- simulated here, real instruments on the bench:
Each instrument has its own panel, shown in its own README: clMag · smb · stage · piezo · camera · kim · hf2 · pm16 · vna · mag2d · mag2dcal · ppms · kepco · windfreak · gsp818 · signalhound · dsphase · dssg · dsamp · agilis · smaract · sr830 · cs260 · ccs200 · ddr25 · elliptec · chopper · superk · tc200 · ls455 · pm400 · hp8648 · sr7230 · k2450 · zpiezo (headless -- a console, not a window).
They are rendered offscreen and reproducibly, so they do not go stale:
python tools/render_all.py # refresh every panel in front-panels/
python tools/render_all.py clMag # or just one| # | project | package | cmd/pub | hardware |
|---|---|---|---|---|
| 0 | clMag-control |
clMag |
5555/5556 | Kepco BOP + GMW 3470 dipole, Hall probe on NI USB-6259, plus AUX analog/digital I/O |
| 1 | smb-control |
smb |
5557/5558 | Rohde & Schwarz SMB100A RF generator |
| 2 | stage-control |
stage |
5559/5560 | Thorlabs BSC203, 3-axis coarse stepper stage |
| 3 | piezo-control |
piezo |
5561/5562 | piezosystem jena d-Drive + PXY-200 XY flexure |
| 4 | camera-control |
camera |
5563/5564 | IDS uEye+ U3-38J0XCP — spot/pattern tracking, stabilisation, autofocus |
| 5 | zpiezo-control |
zpiezo |
5565/5566 | Thorlabs KCube piezo, Z focus (headless) |
| 6 | kim-control |
kim |
5567/5568 | Thorlabs KIM101 + 3× PIA25 piezo-inertia stage |
| 7 | hf2-control |
hf2 |
5569/5570 | Zurich Instruments HF2LI 50 MHz lock-in — 2 demodulator channels + aux inputs |
| 8 | pm16-control |
pm16 |
5571/5572 | Thorlabs PM16 USB power meter (PM16-121) — verified on hardware |
| 9 | vna-control |
vna |
5573/5574 | Keysight PNA-X N5222A or Copper Mountain C1209 (both untested on the instrument) or a simulated YIG film: S-parameters, reference, permeability u and ln(S/S_ref) |
| 10 | mag2d-control |
mag2d |
5575/5576 | 2-axis vector electromagnet on an NI DAQ: field + angle, PI in mT, water-cooling interlock |
| 11 | mag2dcal-control |
mag2dcal |
5577/5578 | the same magnet, controlled the way the 1-axis one is: measured B(V) calibration, PI trim, freeze, long-term stabilizer |
| 12 | ppms-control |
ppms |
5579/5580 | Quantum Design DynaCool through MultiVu (MultiPyVu): field, temperature, chamber (untested on the instrument) |
| 13 | kepco-control |
kepco |
5581/5582 | Kepco BOP 20-10 bipolar power supply (GPIB), its own module -- not a field loop (simulation; untested on the instrument) |
| 14 | windfreak-control |
windfreak |
5583/5584 | Windfreak SynthHD PRO v2, two-channel RF synthesizer (USB serial) (simulation; untested on the instrument) |
| 15 | gsp818-control |
gsp818 |
5585/5586 | GW Instek GSP-818 spectrum analyzer with tracking generator (simulation; untested on the instrument) |
| 16 | signalhound-control |
signalhound |
5587/5588 | Signal Hound SA44B / SA124B with USB-TG44A tracking generator (sa_api.dll) (simulation; untested on the instrument) |
| 17 | dsphase-control |
dsphase |
5589/5590 | DS Instruments 6 GHz digital RF phase shifter (USB) (simulation; untested on the instrument) |
| 18 | dssg-control |
dssg |
5591/5592 | DS Instruments SG12000L 12 GHz signal generator (USB or Ethernet) (simulation; untested on the instrument) |
| 19 | dsamp-control |
dsamp |
5593/5594 | DS Instruments 6 GHz variable-gain RF amplifier (USB) (simulation; untested on the instrument) |
| 20 | agilis-control |
agilis |
5595/5596 | Newport Agilis 2-axis piezo stage on an AG-UC2 (simulation; untested on the instrument) |
| 21 | smaract-control |
smaract |
5597/5598 | SmarAct CLL42 linear positioner on an SCU controller (simulation; untested on the instrument) |
| 22 | sr830-control |
sr830 |
5599/5600 | Stanford Research SR830 DSP lock-in (GPIB) (simulation; untested on the instrument) |
| 23 | cs260-control |
cs260 |
5601/5602 | Newport / Oriel Cornerstone 260 monochromator (GPIB) (simulation; untested on the instrument) |
| 24 | ccs200-control |
ccs200 |
5603/5604 | Thorlabs CCS200/M CCD spectrometer (TLCCS) (simulation; untested on the instrument) |
| 25 | ddr25-control |
ddr25 |
5605/5606 | Thorlabs DDR25/M direct-drive rotation stage on a K-Cube (simulation; untested on the instrument) |
| 26 | elliptec-control |
elliptec |
5607/5608 | Thorlabs ELL14K Elliptec rotation mount (simulation; untested on the instrument) |
| 27 | chopper-control |
chopper |
5609/5610 | Thorlabs MC2000B-EC optical chopper (MC1F10HP, MC1F60 blades) (simulation; untested on the instrument) |
| 28 | superk-control |
superk |
5611/5612 | NKT SuperK EXTREME EXW-12 + SELECT / SELECT2 AOTFs on one RF driver (simulation; untested on the instrument) |
| 29 | tc200-control |
tc200 |
5613/5614 | Thorlabs TC200 heater controller with a PT100 (simulation; untested on the instrument) |
| 30 | ls455-control |
ls455 |
5615/5616 | Lake Shore 455 DSP gaussmeter, axial Hall probe (simulation; untested on the instrument) |
| 31 | pm400-control |
pm400 |
5617/5618 | Thorlabs PM400 power/energy meter console (TLPMX) (simulation; untested on the instrument) |
| 32 | hp8648-control |
hp8648 |
5619/5620 | HP / Agilent 8648D RF generator (GPIB) (simulation; untested on the instrument) |
| 33 | sr7230-control |
sr7230 |
5621/5622 | Ametek Signal Recovery 7230 DSP lock-in (simulation; untested on the instrument) |
| 34 | k2450-control |
k2450 |
5623/5624 | Keithley 2450 SourceMeter (simulation; untested on the instrument) |
| 38 | afg-control |
afg |
5631/5632 | Tektronix AFG1062 two-channel function generator, CH2 can follow CH1 as a synchronous trigger (simulation; untested on the instrument) |
| 39 | scope-control |
scope |
5633/5634 | Two-channel oscilloscope (Siglent SDS1000CML+ / RS PRO RSDS1102CML+, on the instrument since 2026-10-07; or a Digilent Analog Discovery 2/3 with its W1/W2 generator and V+/V- supplies, simulation + fake-dwf tested): averaged triggered traces in physical units, zero-phase filter, XY view |
Each project folder lives in modules/<category>/ (the links above go there).
Instrument n gets cmd = 5555 + 2n and pub = cmd + 1 by default, declared in
its module.toml; the launcher can change a module's ports on one PC.
Updating a checkout from before 2026-09-27 (when the modules moved into
modules/<category>/): if you changed tracked lab files on that PC
(camera.ini, objectives.ini, px_calibration.json, clMag's Calibrations),
commit them or git stash them before git pull (and git stash pop after).
Then run python tools/migrate_layout.py (a dry run) and
python tools/migrate_layout.py --apply: it carries the files git does not
track (tuned .ini files, calibrations, notes, data) from each old
<name>-control folder into the new one, and removes the old folder. Finally
re-sync each module's environment.
Requires uv and Python ≥ 3.11. Everything runs in simulation, so you need no hardware and no vendor drivers.
# the whole suite from one dashboard
cd mission-control
uv run python mission_control.pyOr drive one instrument on its own:
cd modules\field\clMag-control
uv sync --extra gui
uv run scripts/run_service.py # add --real for hardware
uv run scripts/run_gui.py --connect localhost # add --theme light
uv run scripts/magnet_console.py --connect localhostA GUI started without --connect runs its own private local simulation. To
see the state shared with the service, start the service and connect to it.
And the scan engine:
cd scan-core
uv sync --extra gui
uv run python run_demo.py # 2-D, 3-D and XY-raster scans -> out/*.nc
uv run python apps/scan_builder.py
uv run python examples/temperature_series.py # a scripted experiment (sim)Scripts (set, wait, scan in a loop): docs/SCRIPTING.md.
Start a scan on the lab PC, watch it from the office. Start the Scan server card in Mission Control on the lab PC and tick Run scans on this PC's scan server on the measurement suite's Settings tab: scans then run in that service, not in the window, and keep running when a window closes. In the office, Add remote... the lab PC (port 5551) and press the new card's GUI button: the Measurement tab shows the lab's scan live -- progress, live map, log, pause banners -- with Abort (always allowed) and the pause answers (with control). Starting scans from the office is the next phase. Details: scan-core/README.md, "The scan server".
The quick start above assumes a developer's machine — git, uv, a terminal. For
a PC that only has to run the experiment there is a Setup.exe, built from
installer/:
powershell -ExecutionPolicy Bypass -File installer\build_installer.ps1Out comes installer\dist\AaltoFlow-Setup-<date>-<commit>.exe. Whoever runs it
ticks the modules that PC needs; each lands as a folder with its own .venv,
built from that module's committed uv.lock, plus a private uv.exe — so the
target machine needs neither Python nor uv beforehand. It installs per user, so
no admin rights.
Two things keep the installer from drifting away from the suite:
- The checkbox list is generated from the
module.tomlfiles, not written by hand — the same manifests the launcher discovers modules from. Adding a module needs no change ininstaller/. - It builds from
git archive HEAD, not the working tree, so a Setup.exe names the commit it carries. Commit first; the script warns if you did not.
Copying files works offline, but building the environments does not (uv sync
fetches the interpreter and the packages). On a PC where that is blocked, untick
"Build the Python environments now" and run Start menu ▸ AaltoFlow ▸ Rebuild
Python environments later from a network that works.
All 875 tests run offline — no hardware, no network beyond localhost, GUI tests rendered offscreen.
cd <project>
uv run pytest -q| project | tests | project | tests | |
|---|---|---|---|---|
| clMag-control | 22 | hf2-control | 52 | |
| smb-control | 29 | pm16-control | 44 | |
| stage-control | 50 | vna-control | 110 | |
| piezo-control | 37 | scan-core | 265 | |
| camera-control | 119 | mission-control | 12 | |
| zpiezo-control | 14 | suite-common | 45 | |
| kim-control | 87 | mag2d-control | 46 | |
| ppms-control | 43 | mag2dcal-control | 96 | |
| kepco-control | 50 | windfreak-control | 58 | |
| k2450-control | 65 | gsp818-control | 60 | |
| signalhound-control | 77 | dsphase-control | 96 | |
| dssg-control | 62 | dsamp-control | 56 | |
| agilis-control | 76 | smaract-control | 55 | |
| sr830-control | 92 | sr7230-control | 95 | |
| cs260-control | 60 | ccs200-control | 56 | |
| ddr25-control | 64 | elliptec-control | 66 | |
| chopper-control | 55 | superk-control | 54 | |
| tc200-control | 66 | ls455-control | 71 | |
| pm400-control | 77 | hp8648-control | 54 | |
| total | 2536 |
Beyond unit tests, python tools/check_modules.py --live starts every module's
service on scratch ports and checks it against the module contract. The data
viewer's 42 tests live in its own repository.
scan-core is tested against a fake service that speaks the wire contract, so it
needs no instrument package installed.
Per module, on the lab PC: uncomment the
vendor dependency in pyproject.toml, uv sync, run the service with --real,
exercise it from the console before the GUI, and work through every # VERIFY.
Keep the simulation path working. The per-module checklists are in
docs/DEVELOPER_NOTES.md section 11.
After a pass, record it in docs/VERIFIED_INSTRUMENTS.md:
what was checked, the commit, the caveats.
docs/DEVELOPER_NOTES.md architecture, wire contract, conventions, gotchas
docs/SCRIPTING.md driving the lab from a Python script (scan_core.api)
docs/flyers/ one-page flyers (PDF + PNG)
docs/video/ demo videos (mp4)
INSTRUMENT_MODULE_GUIDE.md the blueprint for building a new instrument module
<instrument>-control/ eight instrument modules, one uv project each (each with module.toml)
suite-common/ module discovery, control and encryption masters, shared by all
scan-core/ the N-D scan engine, Scan Builder and measurement suite
(the data viewer comes from the aaltoview repo)
mission-control/ the launcher
installer/ Setup.exe: the wizard, the component generator, the post-install step
front-panels/ reference renders of each GUI
spikes/ earlier QCoDeS experiments, kept as reference
tools/ new_module, check_modules, keys (encryption), panel renderer, deploy
suite_local.json THIS PC's ports / real flags / remote services (not in git)
Each project's own README describes its verbs, configuration and hardware status.
uv and OneDrive fight over .venv, which shows up as
Access is denied (os error 5). Use dev.ps1 (in clMag-control, copyable
anywhere) to put the environment in %LOCALAPPDATA%\uv-venvs\<project> for that
shell only. Never set UV_PROJECT_ENVIRONMENT globally to a single path — every
project then shares and corrupts one environment.
If you publish scientific work with data measured or processed using AaltoFlow,
we would be grateful for a citation. The repository's CITATION.cff
has the details (GitHub's "Cite this repository" button gives it as BibTeX or
APA); in short:
L. Flajšman, AaltoFlow: lab automation built from independent instrument modules, NanoSpin group, Aalto University, https://github.com/FlashLukas/AaltoFlow, doi:10.5281/zenodo.22959230
DOI: 10.5281/zenodo.22959230 (always the latest version; Zenodo lists the DOI of each release too). We are also happy to hear what AaltoFlow was used for -- open an issue and tell us.
AaltoFlow was developed by Lukáš Flajšman in the NanoSpin group of Prof. Sebastiaan van Dijken, Aalto University. The copyright is held by Aalto University.
MIT — see LICENSE. One optional dependency is GPL-3.0: kim-control's hardware driver uses pylablib, so kim-control as distributed together with pylablib falls under the GPL-3.0; the rest of the suite does not depend on it.











