Node Documentation

The intention of this library is to make the creation of Nodes easy. You do not need to know how to program GUIs or know Qt (PyQt5 or PySide), although it is useful to create advanced Node interactions.

Basic Construction of a Node

A node consists of a collection of classes including the top-level :class: nodeworks.node.Node, the NodeGraphic (which handles the drawing of the node), and an arbitrary number of Terminals. The Terminals contain the Pads for connections as well as a widget, if specified.

To create a custom Node, create a Python class, inherit from the Node class, and give the node a name:

from nodeworks import Node
class MyCustomNode(Node):
    name = 'my node'

Now we need to initialize the node by defining the terminal options and then calling the __init__ method of the Node class:

def __init__(self, parent=None):
    self.terminalOpts = {}
    Node.__init__(self, parent)

We now have a very simple node. To make the node actually perform an action when the Node Network is processed we need to overwrite the process method:

def process(self):
    print(self.name)

Putting the above code together we have:

from nodeworks import Node
class MyCustomNode(Node):
    name = 'my node'
    def __init__(self, parent=None):
        self.terminalOpts = {}
        Node.__init__(self, parent)
    def process(self):
        print(self.name)

This is the most basic node that can be produced, it does not have any Terminals and when run, simply prints its name. See the Terminal section below on how to create Terminals.

To add this node to the library, save the node in a *.py file located in the defaultnodes directory. To allow Nodeworks to find the node, two more items need to be added to the *.py file. A name for the group of nodes needs to be defined by setting the variable NAME to a string:

NAME = 'my custom nodes'

And a function named returnNodes needs to be defined that returns a list of the nodes defined in the *.py file:

def returnNodes():
    return [MyCustomNode]

Putting it all together, your *.py file should look something like this:

from nodeworks import Node

NAME = 'my custom nodes'

def returnNodes():
    return [MyCustomNode]

class MyCustomNode(Node):
    name = 'my node'
    def __init__(self, parent=None):
        self.terminalOpts = {}
        Node.__init__(self, parent)
    def process(self):
        print(self.name)

Terminals

Terminals are defined by a dictionary containing options for the terminals. The options define the names of the terminals, whether the terminal is an input or output, if the terminal has a widget and what that widget is, as well as others.

Building on the code in the Basic Construction of a Node section, we will define the self.terminalOpts dictionary. The entries of this dictionary define the terminal names as well as the options of each terminal. As an example, we will create a terminal named input:

self.terminalOpts = {
  'input': {},
  }

This is the simplest terminal. Since we defined an empty dictionary as the value of the dictionary entry, the defaults will be used.

Note

As of Python 3.6, the basic dict class in python now preserves the order of the items. As of Python 3.7, this has become a feature of Python and can be depended upon. This removes the need for OrderedDict.

Accessing/Setting Terminal Values

To access the terminal throughout the Node methods, the self.terminals attribute needs to be indexed. This can be accomplished one of two ways. If we have a terminal named input as defined in the self.terminalOpts attribute, we can access the terminal through indexing:

self.terminals['input']

or through an attribute:

self.terminals.input

Note: if their are spaces in the terminal name (like ``input int``), then only the indexing method works. Otherwise, both methods will work.

Once the terminal is accessed, the current value can be obtained by using the value property:

input = self.terminals.input.value

and the value can be set by calling the setValue(value) method:

self.terminals.input.setValue(10)

Terminal Options

Now we can start customizing the terminal by populating the terminal dictionary with values such as:

self.terminalOpts = {
    'input': {'widget':     'lineedit',
              'in':         True,
              'out':        False,
              'showlabel':  True,
              'multiinput': True,
              'dtype':      str,
              'value':      'enter text here'
              },
     }

Accepted keys

Key

Function

type

widget

sets the widget that is displayed

str

in

shows input pad

bool

out

shows output pad

bool

min

sets a minimum value

int/float

max

sets a maximum value

int/float

showlabel

show/hide terminal label

bool

multiinput

accept multiple inputs

bool

checkable

makes the widget checkable

bool

dtype

sets the dtype of the terminal

type

value

the initial value of the terminal

anything

items

list of items

list

buttons

list of button names

list of strs

columns

list of column names

list of strs

columedelegate

dictionary describing column delegates

dict

rows

list of row names

list of strs

rowdelegate

dictionary describing row delegates

dict

selectionBehavior

sets the selection behavior of a table

str

selectionMode

sets the selection mode of a table

str

browsetype

sets the dialog type

str

Note: not accepted keys fail silently

Widgets

lineedit

../_images/lineedit.png

The lineedit widget provides a QLineEdit in the Terminal that a user can enter text into. This text will be converted to the type that is specified with the dtype option. For example, the following options creates a line edit that will convert the entered text into a float:

self.terminalOpts = {'input':{'widget':'lineedit',
                              'in':True,
                              'out':False,
                              'showlabel':True,
                              'dtype':float,
                              },
                     }

Note: ``float(‘text’)`` causes an exception.

display

../_images/display.png

The display widget provides a disabled lineedit that can be used to display values on the Node.

self.terminalOpts = {'input':{'widget':'display',
                              'in':True,
                              'out':False,
                              'showlabel':True,
                              'dtype':str,
                              },
                     }

checkbox

../_images/checkbox.png

The checkbox widget provides a QCheckBox in the Terminal that allows users to toggle True or False. The dtype should be generally bool, however, other types are acceptable as well such as str because str(True) is valid.

self.terminalOpts = {'input':{'widget':'checkbox',
                              'in':True,
                              'out':False,
                              'showlabel':True,
                              'dtype':bool,
                              },
                     }

combobox

../_images/combobox.png

The combobox widget provides a QComboBox in the Terminal that allows users to select an item from a pre-defined list. This list is defined with the items option.

Note: The items in the list must be strings

self.terminalOpts = {'input':{'widget':'combobox',
                              'in':True,
                              'out':False,
                              'showlabel':True,
                              'dtype':str,
                              'items':['one', 'Two', 'False', '100'],
                              },
                     }

spinbox/doublspinbox

../_images/spinbox.png

The spinbox and doublespinbox widgets provide int and float user inputs, respectively. The dtype option should be set accordingly. The min and max options set the minimum and maximum values that can be entered.

self.terminalOpts = {'input':{'widget':'spinbox',
                              'in':True,
                              'out':False,
                              'showlabel':True,
                              'dtype':int,
                              'min':-1,
                              'max':10,
                              },
                     }

browse

../_images/browse.png

The browse widget provides both a lineedit to show/edit the path as well as a button that opens a file dialog. The dtype option should be a str. The browsetype option can be either open, save, or directory, setting the dialog type to QFileDialog.getOpenFileName, QFileDialog.getSaveFileName, or QFileDialog.getExistingDirectory, respectively.

self.terminalOpts = {'input':{'widget':'browse',
                              'in':True,
                              'out':False,
                              'showlabel':True,
                              'dtype':str,
                              'browstype':'open',
                              },
                     }

pushbutton/toolbutton

../_images/pushbutton.png

The pushbutton and toolbutton widgets provide QPushButton and QToolButton terminal inputs, respectively. The only difference between these buttons is that the pushbutton is raised and the toolbutton autorasies when the mouse is hovered over the button. These buttons can be checkable by setting the checkable option to True.

self.terminalOpts = {'input':{'widget':'pushbutton',
                              'in':True,
                              'out':False,
                              'showlabel':False,
                              'dtype':bool,
                              'checkable':True,
                              },
                     }

Icons can be applied to the buttons using a *.png file by using a helper function provided in the tools module. This is typical set in the __init__ method of the Node:

from nodeworks import Node, tools
class MyCustomNode(Node):
    name = 'my node'
    def __init__(self, parent=None):
        self.terminalOpts = {'input':{'widget':'toolbutton',
                                      'in':True,
                                      'out':False,
                                      'showlabel':False,
                                      'dtype':bool,
                                      'checkable':True,
                                      },
                             }
        Node.__init__(self, parent)

        self.terminals['input'].widget.setIcon(tools.getIcon('addbox))

See the examples/buttonnode.py for a complete example.

toolbuttons/pushbuttons

../_images/pushbuttons.png

The toolbuttons and pushbuttons widgets are simply a list of toolbutton and pushbutton, respectively. The number of buttons and their names are defined by setting the buttons option to a list of strings. for example, the following creates a terminal with three buttons:

self.terminalOpts = {'input':{'widget':'pushbuttons',
                              'in':False,
                              'out':False,
                              'showlabel':False,
                              'dtype':list,
                              'checkable':True,
                              'buttons':['push me', 'btn', 'stop'],
                              },
                     }

The individual buttons can be accessed through the index notation:

self.terminals['input'].widget['push me']

or through attributes

self.terminals.input.widget.stop

or both

self.terminals['input'].widget.btn

See the examples/multibuttonnode.py for a complete example.

textedit

../_images/textedit.png

The textedit widget provides a QTextEdit terminal input. This allows for multiline text input.

self.terminalOpts = {'input':{'widget':'textedit',
                              'in':False,
                              'out':True,
                              'showlabel':False,
                              'dtype':str,
                              'value':'\n'.join(['multi',
                                                 'line',
                                                 'text',
                                                 'input'])
                              },
                     }

table

../_images/table.png

The table widget provides a QtWidgets.QTableView with two different QAbstractTableModel as well as a custom QStyledItemDelegate. The table can work with dict(dict()), [], [[]], (), (()), [()], ([]), 2D numpy.ndarray, and pandas.DataFrame objects. Delegates can be applied column wise as well as row wise, however the columns take precedence. Simple example without column or row delegates:

self.terminalsOpts = {'table':{'widget':'table',
                               'in':False,
                               'out':True,
                               'dtype':list,
                               'multiinput':False,
                               'columndelegate':{},
                               'rowdelegate':{},
                               'columns':None,
                               'rows':None,
                               'value':[[1.1,2,3,10.0, True],
                                        [5,6,7,8, False]]
                               }
                      }

Columns and rows can be resized to fit the contents of the cells by calling the fitToContents method of the widget:

self.terminals['table'].widget.fitToContents()

Column and row delegates are created by passing dictionaries to the columndelegate and rowdelegate options, respectively. This dictionary contains other dictionaries that describe the delegates to use. For example, the following dictionary locks the first row/column, sets a combobox for the next row/column, a spinbox for the third, a doublespinbox for the fourth, and a checkbox for the fifth.

{0:{'widget':None},
 1:{'widget':'combobox',
    'items':[str(i) for i in range(100)],
    'dtype': str},
 2:{'widget':'spinbox',
    'dtype': int,
    'min':-10,
    'max':10,
    },
 3:{'widget':'doublespinbox',
    'dtype': float,
    'min':-1,
    'max':1,
    },
 4:{'widget':'checkbox',
    'dtype': bool,
    }
 }

The selection behavior and selection mode can also be set using the selectionBehavior and selectionMode keys. selectionBehavior can either be row, col, or cell to select row selection, columns selection or individual cell selections, respectively. The selectionMode can be set to either single or multi for single selections or multiple selections. Whenever the selection changes, the newSelection signal is emitted. The signal contains the from and to positions of the selection as [row, col].

from nodeworks import Node, tools
class MyCustomNode(Node):
    name = 'my node'
    def __init__(self, parent=None):



        self.terminalsOpts = {'table':{'widget':'table',
                                       'in':False,
                                       'out':True,
                                       'dtype':list,
                                       'multiinput':False,
                                       'columndelegate':{},
                                       'rowdelegate':{},
                                       'columns':None,
                                       'rows':None,
                                       'value':[[1.1,2,3,10.0, True],
                                                [5,6,7,8, False]],
                                       'selectionBehavior':'row',
                                       'selectionMode':'multi',
                                       }
                              }

        Node.__init__(self, parent)

        self.terminals['table'].widget.newSelection.connect(self.newSelection)

    def newSelection(self, from_, to):
        print('selection from:', from_, 'to', to)

see the examples/tablenode.py for a complete example.

Connecting Signals

Sometimes it is useful to make Nodes that adapt to user interaction. To make this work, we have to use Qt’s signals and slots. Every widget has a valueChanged signal that is emitted every time a widget’s value is changed. We can use this signal to call specific methods of the Node. For example, we can add a button terminal to a node and connect the signal to a method that prints the name of the node:

class MyCustomNode(Node):
    name = 'my node'
    def __init__(self, parent=None):
        self.terminalOpts = {'btn':{'widget':'pushbutton',
                                    'in':False,
                                    'out':False,
                                    'dtype':bool,
                                    'showlabel':False,
                                    }
                             }

        Node.__init__(self)

        self.terminals['btn'].valueChanged.connect(self.printMyName)

    def printMyName(self):
        print(self.name)

see the examples/dynamicnode.py for a complete example.

Thread Safe Graphic Updates

The application is multi-threaded, separating the widget “drawing” from the actual computation of the sheet. In order to prevent thread errors when updating graphics (mostly plots, the default terminals are already handled correctly), a signal needs to be used to trigger graphics updates since Qt’s signals and slots are thread safe.

To accomplish this, create a QtCore.Signal() and connect it to a method of the Node where the graphics update code is. Whenever the graphics need to be updated, emit the Signal. The following Node example demonstrates this by popping up a matplotlib window using this signal/slot mechanism.

from qtpy import QtCore
import matplotlib.pyplot as plt

from nodeworks.node import Node

class DummyPlotNode(Node):
    """Dummy plot, shows thread safe graphic update
    """
    name = 'Dummy Plot'
    updateGraphics = QtCore.Signal()

    def __init__(self, parent=None):
        Node.__init__(self, parent)
        self.updateGraphics.connect(self.refreshGraphics)
        self.x = self.y = None

    def process(self):
        self.x = range(10)
        self.y = [xi**2 for xi in self.x]

        # update graphics with signal because signals are thread safe
        self.updateGraphics.emit()

    def refreshGraphics(self):
        print('plot')
        plt.figure()
        plt.plot(self.x, self.y)
        cfm = plt.get_current_fig_manager()
        cfm.show()

Accessing the QObject

Since these widgets are Qt objects, all the methods of the object can be applied such as setStyleSheet to change the formatting of a widget. The widgets can be accessed by the widget attribute:

self.terminals['input'].widget

Error Handling

Primitive error checking/handling is provided so that a Node can interrupt the the solving of the flow sheet. This can be accomplished two ways. The first way is to check for errors before the process method of the Node is called. This is accomplished by overriding the errorCheck method of the Node:

class MyCustomNode(Node):
    name = 'my node'
    def __init__(self, parent=None):
        self.terminalOpts = {}
        Node.__init__(self)

    def errorCheck(self):
        '''
        Check for errors
        '''
        error = True

        if error:
            self.setError('Error message')

The second option is to call the setError method anywhere in the Node including the process method. For example:

class MyCustomNode(Node):
    name = 'my node'
    def __init__(self, parent=None):
        self.terminalOpts = {}
        Node.__init__(self)

    def process(self):
        try:
            result = 10/0.0
        except ZeroDivisionError:
            self.setError("Can't divide by zero")

The setError(message=’None’, terminal=None) method takes two optional arguments. The first argument (message) is a string that is displayed to the user. The second argument (terminal) is the name of a terminal that could be causing the error, as in the case of bad input. If a terminal name is specified, that terminal’s label will be highlighted in red. It is also possible to clear an error by calling the clearError method.

Node Documentation

To provide help and information about a Node, a docstring can be added to the Node. There are three possible Terminal categories including input terminals, output terminals, and user terminals (no output or input):

class MyCustomNode(Node):
    '''
    My node that does something awesome.

    Input Terminals
    ---------------
    name (str):
        your name

    Output Terminals
    ----------------
    age (int):
        a guess of your age


    User Terminals
    --------------
    gender (str):
        your gender

    '''
    name = 'my node'
    def __init__(self, parent=None):
        self.terminalOpts = {}
        Node.__init__(self)

Progress bar

A progress bar is built into the title of every node. To show progress in the process method while the sheet is running, simply emit the self.update_progress signal with the current progress as a float from 0 to 1. After the process is finished emit a None to clear the progress bar. Example long running node:

../_images/long_running.png
from time import sleep

class LongRunningNode(Node):
    name = 'long running node'
    def __init__(self, parent=None):
        self.terminalOpts = {}
        Node.__init__(self)

    def process(self):

        n = 10
        for i in range(n):
            sleep(1)
            self.update_progress.emit(i/n)

        self.update_progress.emit(None)  # clear the progress bar

Manually calling the process method

The process method of the node can be manually called. However, the node will not be run multi threaded and the progress bar will not update. If you would like to manually call the process method with multi threading, call the manual_process method instead.

The manual_process method:

  1. Disables the widgets so the user can not interact with the node

  2. Updates the terminal inputs

  3. Creates a WorkerThread which calls the process method

  4. Starts the thread

Example usage:

from time import sleep
from qtpy import QtWidgets

class LongRunningNode(Node):
    name = 'long running node'
    def __init__(self, parent=None):
        self.terminalOpts = {}
        Node.__init__(self)

        # add a button
        btn = QtWidgets.QPushButton('Push me')
        btn.pressed.connect(self.manual_process)
        self.nodeGraphic.layout.insertWidget(3, btn)

    def process(self):

        n = 10
        for i in range(n):
            sleep(1)
            self.update_progress.emit(i/n)

        self.update_progress.emit(None)  # clear the progress bar