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¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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¶
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 setStyeSheet
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:
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:
Disables the widgets so the user can not interact with the node
Updates the terminal inputs
Creates a
WorkerThread
which calls theprocess
methodStarts 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