SlowDash integrates Python scripts written by users, with features including:
SlowDash provides a Python library, slowpy, to provide functions for:
slowpy
, is provided to be imported by
the user task scripts, for:
from slowpy.control import ControlSystem
= ControlSystem()
ctrl
# make a control node for a SCPI command of "MEAS:V0" on a device at 192.168.1.43
= ctrl.ethernet(host='192.168.1.43', port=17674).scpi().command('MEAS:V0')
V0
while True:
# read a value from the control node, with a SCPI command "MEAS:V?"
= V0.get()
value ...
from slowpy.store import DataStore_PostgreSQL
= DataStore_PostgreSQL('postgresql://postgres:postgres@localhost:5432/SlowTestData', table="SlowData")
datastore
while True:
= ...
value ="ch00") datastore.append(value, tag
If you have a User Task Script like this:
def set_V0(value):
set(value) V0.
and write a SlowDash HTML panel like this:
<form>
<input name="value">
V0 value: <input type="submit" name="test.set_V0()" value="Set">
</form>
Then, clicking the Set
button will call the function
set_V0()
with a parameter from the value
input
field.
For control nodes V0
and V1
, defining an
_export()
function in the User Task Script will export
these node values, making them available in the SlowDash GUI in the same
way as values stored in the database.
= ctrl.ethernet(host='192.168.1.43', port=17674).scpi()
device = device.command('MEAS:V0')
V0 = device.command('MEAS:V1')
V1 def _export():
return [
'V0', V0),
('V1', V1)
( ]
Only the “current” values are available in this way. If you need historical values, store the values in a database.
In slowdash/ExampleProjects/SlowTask
there is a SlowDash
project to demonstrate some of the features described here.
$ cd slowdash/ExampleProjects/SlowTask
$ slowdash --port=18881
or
$ cd slowdash/ExampleProjects/SlowTask
$ docker compose up
SlowPy is a Python library (module) that provides functions such as:
The SlowPy library is included in the SlowDash package, under
slowdash/lib/slowpy
. By running
source slowdash/bin/slowdash-bashrc
, as instructed in the
Installation section, this path will be included in the environmental
variable PYTHONPATH
, so that users can use the library
without modifying their system. It is also possible to install the
library in the usual way: you can do
pip install slowdash/lib/slowpy
to install SlowPy into your
Python environment. You might want to combine this with
venv
to avoid messing up your Python environment.
SlowPy provides a unified interface to connect external software
systems and hardware devices; everything will be mapped into a single
“ControlTree” where each node has set()
and
get()
methods. The tree represents the logical structure of
the system. For example, a SCPI command of MEAS:V
to a
voltmeter connected via Ethernet would be addressed like
ControlSystem.ethernet(host, port).scpi().command('MEAS:V')
,
and set(value)
on this node will send a SCPI command of
MEAS:V?
to the voltmeter. The get()
method
makes a read access and returns a value.
Plugin modules can dynamically add branches to the control tree. For
example, the Redis plugin adds the redis()
node and several
sub-branches for functions provided by Redis, such as hash, JSON, and
time-series. Plugins are loaded to a node, not (necessarily) to the root
ControlSystem; for example, a plugin that uses Ethernet is loaded to an
Ethernet node, creating a sub-branch under the Ethernet node, and the
plugin can make use of the functions provided by the Ethernet node such
as send()
(which is set()
of the node) and
receive()
(which is get()
).
Here is an example of using SlowPy Controls. In this example, we use a power supply device that accepts SCPI commands through Ethernet.
from slowpy.control import ControlSystem
= ControlSystem()
ctrl
# make a control node for a SCPI command of "MEAS:V0" on a device at 192.168.1.43
= ctrl.ethernet(host='192.168.1.43', port=17674).scpi().command('MEAS:V0', set_format='V0 {};*OPC?')
V0
# write a value to the control node: this will issue a SCPI command "V0 10;*OPC?"
set(10)
V0.
while True:
# read a value from the control node, with a SCPI command "MEAS:V"
= V0.get()
value ...
A common starting point would be importing
slowpy.control
, and then creating an instance of the
ControlSystem
class.
The ControlSystem
already includes the
Ethernet
plugin, but if it were not, the plugin loading
code would have been:
'Ethernet') ctrl.load_control_module(
This will search for a file named control_Ethernet.py
from search directories, load it, and inject the ethernet()
Python method to the node class that loaded the plugin (which is
ControlSystem in this case). The Ethernet plugin already includes
sub-branches for SCPI; for specific protocols not already included, a
plugin would be loaded onto the Ethernet node.
Each node constructor takes parameters. In this example, the Ethernet
node, which sends and receives data to/from the Ethernet, takes the
host/IP and port parameters, and a SCPI command node, which is bound to
a specific SCPI command, takes the SCPI command parameter with optional
set_format
which overrides the default SCPI command for
writing. (The default SCPI commands to be written/read for
.scpi().command(CMD)
are CMD
and
CMD?
, respectively. Often, write operations should wait for
command completion, and SlowPy expects a return value from each command.
A technique to do this, regardless of the actual command behaviors of
the device, is to append OPC?
to the command, as done in
the example.)
Once you get the control node object, you can call
node.set(value)
to write the value, and call
value=node.get()
to read from it. As a shortcut,
node(value)
is equivalent to node.set(value)
,
and value=node()
is equivalent to
value=node.get()
. Also, control nodes define Python’s
__str__()
, __float__()
, etc., so
print(node)
and x = float(node)
will
implicitly call node.get()
.
If a control node has threading methods run()
or
loop()
, calling start()
on the node will
create a dedicated thread and start it. This is useful for:
set()
and get()
queries, such as PID loops.The run()
function is called once, and then
loop()
is called repeatedly. In the loop()
function, sleep()
or similar must be inserted to control
the frequency.
The thread is stopped by calling the stop()
method of
the node, or by a global stop request (such as
ControlSystem.stop()
or by signals). The run()
function must watch for stop requests (by
is_stop_requested()
) and terminate itself when a stop
request is made. loop()
will not be called after a
stop.
Naming convention: set()
, get()
, and
do_XXX()
are usual methods to do something. Methods with a
noun name return a sub-node.
ControlSystem.load_control_module('Ethernet')
cmd value
and waits for a
replycmd?
and waits for a reply, then returns
the replycmd value line_terminator
,
consumes echocmd line_terminator
, consumes echo, and
returns a reply until the next promptControlSystem.load_control_module('HTTP')
base_url
path
with value
as its contentbase_url
path
and returns the reply contentvalue
is a Python object to be converted to
JSONControlSystem.load_control_module('Shell')
cmd value
,cmd
and returns the resultadc0 = ctrl.shell('read_adc', '--timeout=0').arg('--ch=0')
ControlSystem.load_control_module('HTTP').load_control_module('Slowdash')
pong
ControlSystem.load_control_module('Redis')
All the control nodes (derived from
slowpy.control.ControlNode
) have the following methods:
get()
condition_lambda
returns True
if the
lambda is not None
; if condition_lambda
is
None
, wait until has_data()
returns
True
. On timeout, wait()
returns
None
, and on a stop request, it returns False
.
Otherwise, it returns True
.get()
which calls
parent_node.get()
set()
which calls
parent_node.set()
Nodes derived from ControlValueNode
have the following
methods:
set(value)
of the parent node.get()
set(0)
will stop the current ramping if it
is runningTrue
if ramping is in progress,
otherwise returns False
Nodes derived from ControlThreadNode
have the following
methods:
run()
and/or
loop()
, if the node has one of these methods (or both)Simple example of writing single values to a long-form table:
import time
from slowpy.control import RandomWalkDevice
from slowpy.store import DataStore_PostgreSQL
= RandomWalkDevice(n=4)
device = DataStore_PostgreSQL('postgresql://postgres:postgres@localhost:5432/SlowTestData', table="Test")
datastore
while True:
for ch in range(4):
= device.read(ch)
value ='%02d'%ch)
datastore.append(value, tag
1) time.sleep(
Example of writing a dict of key-values:
while True:
= { 'ch%02d'%ch: device.read(ch) for ch in range(4) }
record
datastore.append(record)1) time.sleep(
For SQL databases, the “long format” with UNIX timestamps is used by
default. To use other table schemata, specify a user-defined
TableFormat
:
class QuickTourTestDataFormat(LongTableFormat):
= '(datetime DATETIME, timestamp INTEGER, channel STRING, value REAL, PRIMARY KEY(timestamp, channel))'
schema_numeric = '(datetime DATETIME, timestamp INTEGER, channel STRING, value REAL, PRIMARY KEY(timestamp, channel))'
schema_text
def insert_numeric_data(self, cur, timestamp, channel, value):
f'INSERT INTO {self.table} VALUES(CURRENT_TIMESTAMP,%d,?,%f)' % (timestamp, value), (channel,))
cur.execute(
def insert_text_data(self, cur, timestamp, channel, value):
f'INSERT INTO {self.table} VALUES(CURRENT_TIMESTAMP,%d,?,?' % timestamp), (channel, value))
cur.execute(
= DataStore_SQLite('sqlite:///QuickTourTestData.db', table="testdata", table_format=QuickTourTestDataFormat()) datastore
SlowPy provides commonly used data objects such as histograms and graphs. These objects can be directly written to the database using the SlowPy Database Interface described above.
import slowpy as slp
= slp.Histogram(100, 0, 10)
hist
= ...
device = ...
datastore
while not ControlSystem.is_stop_requested():
= device.read(...
value
hist.fill(value)="test_hist") data_store.append(hist, tag
data_store.append(hist, tag=name)
will create a
time-series of histograms (one histogram for each time point). To keep
only the latest histogram, use
data_store.update(hist, tag=name)
instead.
import slowpy as slp
= slp.Graph(['channel', 'value'])
graph
while not ControlSystem.is_stop_requested():
for ch in range(n_ch):
= device.read(ch, ...
value
graph.fill(ch, value)="test_graph") data_store.append(graph, tag
data_store.append(graph, tag=name)
will create a
time-series of graphs (one graph for each time point). To keep only the
latest graph, use data_store.update(graph, tag=name)
instead.
import slowpy as slp
= slp.RateTrend(length=300, tick=10)
rate_trend
while not ControlSystem.is_stop_requested():
= device.read(...
value
rate_trend.fill(time.time())
'test_rate')) data_store.append(rate_trend.time_series(
If your device has some programming capability, such as Raspberry Pi GPIO, an easy way to integrate it into a SlowDash system is to implement the SCPI interface on it. This approach is also useful to integrate other non-Ethernet devices (such as USB) connected to a remote computer.
Here is an example to wrap a device with the Ethernet-SCPI capability:
from slowpy.control import ScpiServer, ScpiAdapter, RandomWalkDevice
class RandomWalkScpiDevice(ScpiAdapter):
def __init__(self):
super().__init__(idn='RandomWalk')
self.device = RandomWalkDevice(n=2)
def do_command(self, cmd_path, params):
'''
parameters:
cmd_path: array of strings, SCPI command split by ':'
params: array of strings, SCPI command parameters split by ','
return value: reply text (even if empty) or None if command is not recognized
'''
# Implemented Commands: "V0 Value" "V1 Value" "MEASure:V0?" "MEASure:V1?"
# Common SCPI commands, such as "*IDN?", are implemented in the parent ScpiAdapter class
if len(params) == 1 and cmd_path[0].startswith('V'):
try:
if cmd_path[0] == 'V0':
self.device.write(0, float(params[0]))
elif cmd_path[0] == 'V1':
self.device.write(1, float(params[0]))
else:
self.push_error(f'invalid command {cmd_path[0]}')
except:
self.push_error(f'bad parameter value: {params[0]}')
return ''
elif len(cmd_path) == 2 and cmd_path[0].startswith('MEAS'):
if cmd_path[1] == 'V0?':
return self.device.read(0)
elif cmd_path[1] == 'V1?':
return self.device.read(1)
else:
self.push_error(f'invalid command {cmd_path[0]}')
return ''
return None
= RandomWalkScpiDevice()
device = ScpiServer(device, port=17674)
server server.start()
Make this code start automatically on PC boot in your favorite way
(/etc/rc.local
, Docker, …).
If SlowPy Control Nodes are already available for the device, the nodes can be directly mapped to the SCPI interface:
from slowpy.control import ControlSystem, ScpiServer, ScpiAdapter
'DummyDevice')
ControlSystem.import_control_module(= ControlSystem().randomwalk_device()
device
= ScpiAdapter(idn='RandomWalk')
adapter
adapter.bind_nodes(['CONFIGure:WALK', device.walk().setpoint(limits=(0,None))),
('CONFIGure:DECAY', device.decay().setpoint(limits=(0,1))),
('V0', device.ch(0).setpoint()),
('V1', device.ch(1).setpoint()),
('MEASure:V0', device.ch(0).readonly()),
('MEASure:V1', device.ch(1).readonly()),
(
])
= ScpiServer(adapter, port=17674)
server server.start()
SlowTask is a user Python script placed under the SlowDash config
directory with a name like slowtask-XXX.py
. SlowDash GUI
can start/stop the script, call functions defined in the script, and
bind control variables in the script to GUI elements. The SlowPy library
was designed for use in user task scripts, but this is not a
requirement.
SlowTask script files (such as slowtask-test.py
) are
placed under the project configuration directory. By default, tasks are
“listed” in the Task Manager panel of the SlowDash web page, but they
will not be “loaded” until the start
button is clicked.
Auto-loading can be set by making an entry in the SlowDash configuration
file (SlowdashProject.yaml
):
slowdash_project:
...
task:
name: test
auto_load: true
parameters:
default_ramping_speed: 0.1
system:
our_security_is_perfect: false
The parameters are optional, and if given, these will be passed to
the _initialize(params)
function of the script (described
later).
Setting system/our_security_is_perfect
to
true
will enable editing of the Python scripts on the
SlowDash web page. While this is convenient, please keep in mind what it
means. Python scripts can do anything, such as
system("rm *")
. You can only use this feature when access
is strictly controlled. Some secure systems run processes only in Docker
containers, where the container system runs on a virtual machine (in
case the container is compromised) inside a firewall that accepts only
VPN or SSH connections from listed addresses. Additionally, to prevent
unintended destruction by novice operators, it may be safer to run two
instances of SlowDash: one with full features and one with limited
operations (without or only with selected tasks). The
slowdash
command has an option to specify which
configuration file to use.
Functions in SlowTask scripts can be called from the SlowDash GUI. If
a script (named test
) has a function like this
def destroy_apparatus():
#... do your work here
This can be called from the SlowDash GUI in several ways.
From an HTML form panel in SlowPlot, clicking a
<button>
with a name of the task module and the
function will call the function:
<form>
<input type="submit" name="test.destroy_apparatus()" value="Finish">
</form>
Here, the parentheses at the end of the button name indicate that this button is bound to a SlowTask function.
The prefix (test.
in this example) is the module name,
which is taken from the module file name by stripping the beginning
‘slowtask-’. If the module name includes -
, those will be
replaced with _
. The prefix can be explicitly specified in
the SlowdashProject.yaml
configuration file as:
task:
name: another_test
namespace:
prefix: test.
Note that the dot is a part of the prefix.
SlowTask functions can take parameters:
def set_V0(voltage:float, ramping:float):
#... do your work here
and these parameter values are taken from the form input values:
<form>
<input type="number" name="voltage" value="0"><br>
Voltage: <input type="number" name="ramping" value="1"><br>
Ramping: <input type="submit" name="test.set_V0()" value="Set">
</form>
By using the type notation in the SlowTask function as shown above, the parameter values are converted to the specified types. If the value literal cannot be converted to the specified type, an error response will be returned to the browser and the SlowTask function will not be called.
The SlowTask function can be either the standard def
or
async def
.
It is possible to place multiple buttons in one form:
<form>
<input type="number" name="ramping" value="1" style="width:5em">/sec
Ramping: <p>
<input type="number" name="V0" value="0"><input type="submit" name="parallel test.set_V0()" value="Set"><br>
V0: <input type="number" name="V1" value="0"><input type="submit" name="parallel test.set_V1()" value="Set"><br>
V1: <input type="number" name="V2" value="0"><input type="submit" name="parallel test.set_V2()" value="Set"><br>
V2: <input type="number" name="V3" value="0"><input type="submit" name="parallel test.set_V3()" value="Set"><br>
V3: <p>
<input type="submit" name="test.set_all()" value="Set All">
<input type="submit" name="parallel test.stop()" value="Stop Ramping"><br>
</form>
def set_V0(V0:float, ramping:float):
#...
def set_V1(V1:float, ramping:float):
#...
#...
The input values on the Web form will be bound to the function
parameters based on the names (<input name="V0">
will
be bound to the V0
parameter).
In the example above, some functions have the parallel
qualifier: by default, if a previous function call is in execution, the
next action cannot be accepted to avoid multi-threading issues in the
user code. The parallel
qualifier indicates that this
function can run in parallel to others. Another common qualifier is
await
, which instructs the web browser to wait for
completion of the function execution before doing anything else
(therefore, the browser will look frozen).
On a canvas panel, a button to call a task can be placed by:
{
...
"items": [
{
"type": "button",
"attr": { "x":400, "y": 630, "width": 130, "label": "Finish Experiment" },
"action": { "submit": { "name": "test.destroy_apparatus()" } }
},
...
For functions with parameters, a form can be used:
{
...
"items": [
{
"type": "valve",
"attr": { ... },
"metric": { "channel": "sccm.GasInlet", ... },
"action": { "form": "FlowControl" }
},
...
],
"forms": {
"FlowControl": {
"title": "Injection Flow",
"inputs": [
{ "name": "flow", "label": "Set-point (sccm)", "type": "number", "initial": 15, "step": 0.1 }
],
"buttons": [
{ "name": "ATDS.set_flow()", "label": "Apply Set-point" },
{ "name": "ATDS.stop_flow()", "label": "Close Valve" }
]
}
}
...
For more details, see the UI Panels section.
If a SlowTask script has functions of
_initialize(params)
, _finalize()
,
_run()
, and/or _loop()
, one dedicated thread
will be created, and these functions are called in a sequence:
function | description |
---|---|
_initialize(params) |
called once when the script is loaded. The params
values are given by the SlowDash configuration. |
_run() |
called once after _initialize() . If _run()
is defined, _halt() should also be defined to stop the
_run() function. _halt() will be called by
SlowDash when the user stops the system. |
_loop() |
called repeatedly after _initialize() , until the user
stops the system. To control the intervals, the function usually
contains time.sleep() or equivalent. |
_finalize() |
called once after _run() or _loop() |
These functions can be either the standard def
or
async def
.
Control variables can be exported to other SlowDash components
(typically web browsers) so that the other components can call
get()
and set()
of the nodes.
from slowpy.control import control_system as ctrl
= ctrl.ethernet(host, port).scpi().command("V")
V0 'V0') ctrl.export(V0,
The exported nodes are listed in the channel list and can be accessed in the same way as data stored in databases, except that only “current” values are available. Typically these are used in “Single Scalar” display panels, or in HTML panels:
<form>
<span sd-value="V0"></span>
V0: </form>
Python dict
and dataclass
instances can be
exported in the same way. Instances of class
can also be
exported, but with some restrictions (e.g., vars()
must be
able to serialize the instance). SlowPy analysis elements, such as
histograms and graphs, are also accepted.
from slowpy.control import control_system as ctrl
= ctrl.ethernet(host, port).scpi().command("V")
V0 ='V0')
ctrl.export(V0, name
from slowpy import Histogram
= Histogram(100, 0, 20)
h ='hist')
ctrl.export(h, name
def _loop():
h.fill(V0.get())1) ctrl.sleep(
To export a variable not of these types, make a temporary control node variable to wrap it:
class RampingStatusNode(ControlNode):
def get(self):
return {
'columns': [ 'Channel', 'Value', 'Ramping' ],
'table': [
'Ch0', float(V0), 'Yes' if V0.ramping().status().get() else 'No' ],
[ 'Ch1', float(V1), 'Yes' if V1.ramping().status().get() else 'No' ],
[ 'Ch2', float(V2), 'Yes' if V2.ramping().status().get() else 'No' ],
[ 'Ch3', float(V3), 'Yes' if V3.ramping().status().get() else 'No' ]
[
]
}
'Status') ctrl.export(RampingStatusNode(),
The current values of exported variables are “pulled” by the other components. In addition, the values can be “pushed” to the others (data streaming).
from slowpy.control import control_system as ctrl
= ctrl.ethernet(host, port).scpi().command("V")
V0 'V0') # exporting to be pulled
ctrl.export(V0,
async def _loop():
await ctrl.aio_publish(V0) # publishing (pushing to subscribers)
await ctrl.aio_sleep(1)
If the variable is already exported, the same export name is used.
Otherwise, the name must be provided as a name
argument of
the aio_publish()
function. In addition to the export-able
variables, aio_publish()
accepts many other types,
including Python numbers.
import random
from slowpy.control import control_system as ctrl
= 0
x async def _loop():
global x
= x + random.random()
x await ctrl.aio_publish(x, name="RandomWalk")
await ctrl.aio_sleep(1)
In addition to accessing the exported variables in the same way as
data in databases, the variables can be directly “bound” in web
browsers. If a SlowTask control variable is bound in SlowDash HTML,
changes to the variable on the browser call the .set()
method of the SlowTask variable.
<form>
<input type="number" sd-value="readout_frequency" sd-live="true">
Readout Frequency:
...</form>
from slowpy.control import control_system as ctrl
= ctrl.value(1.0)
readout_frequency 'readout_frequency') # to be set by GUI
ctrl.export(readout_frequency,
= ctrl.ethernet(host, port).scpi().command("V")
V
async def _loop():
await ctrl.aio_publish(V, name='V')
await ctrl.aio_sleep(readout_frequency.get())
In HTML, sd-live="true"
indicates that changes of the
value on the browser will trigger calling the .set()
method
of the bound variable.
More examples can be found in
ExampleProjects/Streaming
.
Instances of Matplotlib Figure can be directly published.
import matplotlib.pyplot as plt
from slowpy.control import control_system as ctrl
async def _loop():
= plt.subplots(2, 2)
fig, axes #... draw plots in the usual way
await ctrl.aio_publish(fig, name='mpl')
# If a figure is created in a loop, it must be closed every time.
plt.close() await ctrl.aio_sleep(1)
When a Matplotlib figure is published, a SlowDash layout (usually a
slowplot-XXX.json
file under config
) is
created dynamically for the same axes layout as the figure. The plotting
objects in the figure are extracted and converted to SlowDash objects
before publishing.
More examples can be found in
ExampleProjects/Streaming/Matplotlib
.