Controls Script

Contents

Overview

Applications

Structure

Simple Examples

Controlling a voltmeter with SCPI commands over Ethernet

from slowpy.control import ControlSystem
ctrl = ControlSystem()

# make a control node for a SCIP command of "MEAS:V0" on a device at 182.168.1.43
V0 = ctrl.ethernet(host='192.168.1.43', port=17674).scpi().command('MEAS:V0', set_format='V0 {};*OPC?')

# write a value to the control node: this will issue a SCPI command "V0 10;*OPC?"
V0.set(10)

while True:
  # read a value from the control node, with a SCPI command "MEAS:V?"
  value = V0.get()
  ...

Writing a data value to PostgreSQL database (using a default schema)

from slowpy.store import DataStore_PostgreSQL

datastore = DataStore_PostgreSQL('postgresql://postgres:postgres@localhost:5432/SlowTestData', table="SlowData")

while True:
    value = ...
    datastore.append(value, tag="ch00")

Calling a User Task function from SlowDash GUI Panels

If you have a User Task Script like this:

def set_V0(value):
    V0.set(value)

and write a SlowDash HTML panel like this:

<form>
  V0 value: <input name="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 in the value input field.

Displaying the readout values on the SlowDash panels

For a control node V0, and V1, defining _export() function in the User Task Script will export these node values, making them available in SlowDash GUI in the same way as the values stored in database.

device = ctrl.ethernet(host='192.168.1.43', port=17674).scpi()
V0 = device.command('MEAS:V0', set_format='V0 {};*OPC?')
V1 = device.command('MEAS:V1', set_format='V1 {};*OPC?')
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.

Demonstration Example Project

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: Controls Library

SlowPy is a Python library (module) that provides functions like:

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 PYTHONPAH, so that users can use the library without modifying users system. It is also possible to install the library in a usual way: you can do pip install slowdas/lib/slowpy to install SlowPy into your Python environment. You might want to combine this with pyenv not to mess up your Python.

Controls

SlowPy provides an 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(). The tree represents logical structure of the system, for example, a SCPI command of MEAS:V to a voltmeter connected to an ethernet would be addressed like ControlSystem.ethernet(host, port).scpi().command('MEAS:V'), and set(value) to 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, Redis plugin adds the redis() node and a number of sub-branches for functions that Redis provides, 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 function provided by the ethernet node such as send() (which is set() of the node) and receive() (which is get()).

Example

Here is an example of using SlowPy Controls. In this example, we use a power supply device that accepts the SCPI commands through ethernet.

from slowpy.control import ControlSystem
ctrl = ControlSystem()

# make a control node for a SCIP command of "MEAS:V0" on a device at 182.168.1.32
V0 = ctrl.ethernet(host='192.168.1.43', port=17674).scpi().command('MEAS:V0', set_format='V0 {};*OPC?')

# write a value to the control node: this will issue a SCPI command "V0 10;*OPC?"
V0.set(10)

while True:
  # read a value from the control node, with a SCPI command "MEAS:V"
  value = V0.get()
  ...

A common start would be importing the slowpy.control, and then creating an instance of the ControlSystem class.

The ControlSystem already includes the Ethernet plugin, but if it were not, the loading plugin code would have been:

ctrl.load_control_module('Ethernet')

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 command to be written/read .scpi().command(CMD) are CMD and CMD?, respectively. Often write operations should wait for the command completion, and SlowPy expects a return value from each command. A technique to do it, regardless to 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().

Control Node Threading

If a control node has a threading method of run() or loop(), calling start() of the node will create a dedicated thread and start it. This is useful for:

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 the stop request (by is_stop_requested()) and terminate itself when a stop request is made. loop() will not be called after stop.

Commonly used nodes

Naming convention: set(), get(), and do_XXX() are usual methods to do something. Methods with a noun name return a sub-node.

Ethernet, SCPI and Telnet

Loading (already included)

ControlSystem.load_control_module('Ethernet')

Nodes

HTTP (Web API)

Loading (already included)

ControlSystem.load_control_module('HTTP')

Nodes

Shell Command

Loading (already included)

ControlSystem.load_control_module('Shell')

Nodes

SlowDash server control

Loading (already included)

ControlSystem.load_control_module('HTTP').load_control_module('Slowdash')

Nodes

Redis Interface

Loading

ControlSystem.load_control_module('Redis')

Nodes

Node Functions

All the control nodes (derived from slowpy.control.ControlNode) have the following methods:

Nodes derived from ControlValueNode have the following methods:

Nodes derived from ControlThreadNode have the following methods:

Database Interface

Simple example of writing single values to a long form table:

import time
from slowpy.control import RandomWalkDevice
from slowpy.store import DataStore_PostgreSQL

device = RandomWalkDevice(n=4)
datastore = DataStore_PostgreSQL('postgresql://postgres:postgres@localhost:5432/SlowTestData', table="Test")

while True:
    for ch in range(4):
      value = device.read(ch)
      datastore.append(value, tag='%02d'%ch)

    time.sleep(1)

Example of writing a dict of key-values:

while True:
    record = { 'ch%02d'%ch: device.read(ch) for ch in range(4) }
    datastore.append(record)
    time.sleep(1)

For SQL databases, the “long format” with UNIX timestamps is used by default. To use other table schemata, specify an user defined TableFormat:

class QuickTourTestDataFormat(LongTableFormat):
    schema_numeric = '(datetime DATETIME, timestamp INTEGER, channel STRING, value REAL, PRIMARY KEY(timestamp, channel))'
    schema_text = '(datetime DATETIME, timestamp INTEGER, channel STRING, value REAL, PRIMARY KEY(timestamp, channel))'

    def insert_numeric_data(self, cur, timestamp, channel, value):
        cur.execute(f'INSERT INTO {self.table} VALUES(CURRENT_TIMESTAMP,%d,?,%f)' % (timestamp, value), (channel,))

    def insert_text_data(self, cur, timestamp, channel, value):
        cur.execute(f'INSERT INTO {self.table} VALUES(CURRENT_TIMESTAMP,%d,?,?' % timestamp), (channel, value))

datastore = DataStore_SQLite('sqlite:///QuickTourTestData.db', table="testdata", table_format=QuickTourTestDataFormat())

Analysis Components

SlowPy provides commonly used data objects such as histograms and graphs. These objects can be directly written to the database using the SlowPy Daabase Interface described above.

Histogram

import slowpy as slp
hist = slp.Histogram(100, 0, 10)

device = ...
datastore = ...

while not ControlSystem.is_stop_requested():
  value = device.read(...
  hist.fill(value)
  data_store.append(hist, tag="test_hist")

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.

Graph

import slowpy as slp
graph = slp.Graph(['channel', 'value'])

while not ControlSystem.is_stop_requested():
  for ch in range(n_ch):
    value = device.read(ch, ...
    graph.fill(ch, value)
  data_store.append(graph, tag="test_graph")

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.

Trend

import slowpy as slp
rate_trend = slp.RateTrend(length=300, tick=10)

while not ControlSystem.is_stop_requested():
  value = device.read(...
  rate_trend.fill(time.time())

  data_store.append(rate_trend.time_series('test_rate'))

Scpizing Your Device

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 splitted by ':'
          params: array of strings, SCPI command parameters splited 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
        
device = RandomWalkScpiDevice()
server = ScpiServer(device, port=17674)
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

ControlSystem.import_control_module('DummyDevice')
device = ControlSystem().randomwalk_device()

adapter = ScpiAdapter(idn='RandomWalk')
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()),
])

server = ScpiServer(adapter, port=17674)
server.start()

SlowTask: GUI-Script Binding

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. Using the SlowPy library from a SlowTask script is assumed for this design, but this is not a requirement.

Starting, Stopping and Reloading the Slow-Task Scripts

SlowTask script files (such as slowtask-test.py) are placed under the project configuration directory. The files are enabled by making an entry in the project configuration file (SlowdashProject.yaml):

slowdash_project:
  ...

  task:
    name: test
    auto_load: true
    parameters:
      default_ramping_speed: 0.1

  system:
    our_security_is_perfect: false

Tasks will be listed in the Task Manager table of SlowDash page, and can be controlled from there.

The parameters are optional, and if given, these will be passed to the _initialize(params) function of the script (described later).

By setting system/our_security_is_peferct to true will enable editing of the Python scripts on the SlowDash web page. While this is convenient, be aware what it means. Python scripts can do anything, such as system("rm *"). Enable this feature only when the access is strictly controlled. Some careful systems run processes only in Docker containers, where the container system runs on a virtual machine (in case the container is cracked) inside a firewall which accepts only VPN or SSH accesses from listed addresses. On top of that, in order to prevent unintended destruction by novice operators, it would be safer to run two instances of SlowDash, one with full features and one with limited operations (no or selected tasks); the slowdash command have an option to specify which configuration file to use.

Callback Functions (Command Task)

Functions in SlowTask scripts can be called from SlowDash GUI. If a script (of name test) has a function like this

def destroy_apparatus():
  #... do your work here

This can be called from SlowDash GUI in several ways.

HTML Form Panel

From a 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 parenthesis at the and of the button name indicate that this button is bound to a SlowTask function.

SlowTask functions can take parameters:

def set_V0(voltage, ramping):
  #... do your work here

and these parameter values are taken from the form input values:

<form>
  Voltage: <input type="number" name="voltage" value="0"><br>
  Ramping: <input type="number" name="ramping" value="1"><br>
  <input type="submit" name="test.set_V0()" value="Set">
</form>

It is possible to place multiple buttons in one form:

<form>
  Ramping: <input type="number" name="ramping" value="1" style="width:5em">/sec
  <p>
  V0: <input type="number" name="V0" value="0"><input type="submit" name="async test.set_V0()" value="Set"><br>
  V1: <input type="number" name="V1" value="0"><input type="submit" name="async test.set_V1()" value="Set"><br>
  V2: <input type="number" name="V2" value="0"><input type="submit" name="async test.set_V2()" value="Set"><br>
  V3: <input type="number" name="V3" value="0"><input type="submit" name="async test.set_V3()" value="Set"><br>
  <p>
  <input type="submit" name="test.set_all()" value="Set All">
  <input type="submit" name="async test.stop()" value="Stop Ramping"><br>    
</form>

In that case, some input fields might not be used by some buttons. Since all the input field values are passed to the function parameters, it may cause a Python error of unexpected parameters. To absorb the unused parameters, a best practice is always adding **kwargs to the SlowTask function parameters:

def set_V0(V0, ramping, **kwargs):
  #... do your work here

In the example above, some functions have the async qualifier: by default, if a previous function call is in execution, a next action cannot be accepted to avoid multi-threading issues in the user code. The async qualifier indicates that this function call can be run in parallel to others. Another common qualifier is await, which instruct the GUI to wait for completion of the function execution before doing any other things (therefore it will look frozen).

Canvas Panel

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.

Thread Functions (Routine Task)

If a SlowTask script has functions of _initialize(params), _finalize(), _run(), and/or _loop(), one dedicated thread will be created and these function 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 be also 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()

Control Variable Binding

Control variables (instances of the Control Node classes) can be bound to GUI elements if a SlowTask script exports them with the _export() function:

def _export():
    return [
      ('V0', V0),
      ('V1', V1),
      ('V2', V2),
      ('V3', V3)
    ]

Here the return value is a list of tuples of (name, control_node_variable). In the GUI, the exported entries can be used in the same way as data from a database. To export a variable that is not a control node, wrapping it with a control node is easy:

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' ]
            ]
        }
    
def _export():
    return [
        ('V0', V0),
        ('V1', V1),
        ('V2', V2),
        ('V3', V3),
        ('Status', StatusNode())
    ]

Here the new node StatusNode returns a table object.

Distributed System / Network Deployment

SlowDash Interconnect

SlowTask Scpization