Developing a new controller
In eVolver, controllers are the components that implement experiment logic. They can read the state and history of the eVOLVER environment (e.g. the sensor values) and subsequently modify it (via effectors). In a typical eVOLVER setup controller logic is executed once during a device loop, after reading the state of all sensors. See eVOLVER concepts for more details on the device operation.
Controllers are implemented as Python classes that inherit from the
evolver.controller.Controller class, the logic of which is executed via
a call to the evolver.controller.Controller.control() method. Developing a
new controller is a matter of implementing this method. Within
evolver.controller.Controller.control(), code can get read-in sensor
values (e.g. by calling evolver.hardware.interface.SensorDriver.get()) and
make proposals to effect the environment based on read sensor values (e.g. by
calling evolver.hardware.interface.EffectorDriver.set() with appropriate
input).
Note
Be aware that get typically doesn’t directly read values from hardware,
and set typically doesn’t immediately send hardware commands. These
actions are done in the device loop. For more details on the control loop
operation - and in particular the differences between getting and reading
sensors and setting and committing effectors - see eVOLVER concepts.
As with other eVOLVER components, controllers contain Config classes that
define the configuration options for the experiment, which can be read from
on-disk config and modified via the API and UIs. Authors should provide a
Config class that inherits from evolver.controller.Controller.Config
with appropriate options for the experiment. See Config for
more details on configuration.
Getting started
A brief example will help illustrate what is required to implement a new controller. In the example below, we define a simple experiment:
from evolver.controller import Controller
from evolver.hardware.standard import Temperature
class MyController(Controller):
class Config(Controller.Config):
limit: int = 42
hysteresis: int = 2
low_temp: int = 30
high_temp: int = 38
def control(self):
for vial in self.vials:
density = self.evolver.hardware['od'].get[vial].density
if density > self.limit:
self.evolver.hardware['temp'].set(Temperature.Input(vial=vial, value=self.low_temp))
elif density < self.limit - self.hysteresis:
self.evolver.hardware['temp'].set(Temperature.Input(vial=vial, value=self.high_temp))
In the above you can note that all the business logic is contained within the
control method, while configuration parameters are available as attributes of
the controller (e.g. self.limit and self.hysteresis). Further, the
controller gains access to hardware state via the evolver property, which is
an instance of evolver.device.Evolver, and can be used to access
hardware drivers (e.g. self.evolver.hardware[‘temp’]) to get current state and
set desired state.
Note
The evolver property comes from hookup to the device (which happens for example in creating a new eVOLVER from configuration), see also eVOLVER hookup and portability below. vials comes from the base controller Config class, and represents a list of vials to operate on.
Support multiple vial configurations
You might note in the above example that:
We loop over a set of vials in the controller, and
We have scalar values for the config parameters, not an array of values for each vial.
The concept here is that each instance of a controller is a specific instance of
the experiment on a set of vials. In the simplest case one experiment with a
given set of configuration parameters is run on all vials on the box. The
eVOLVER accepts a list of controllers, which can represent either distinct
experiment logic on separate vials, or distinctly configured instances of the
same experiment on separate vials. Their control methods are run in order
during the control loop (wee eVOLVER concepts for more details on experiment
setup).
For example, given the above experiment, an end-user could run on half the vials with a different set of parameters as follows:
controllers:
- classinfo: mymodule.MyController
config:
vials: [0, 1, 2, 3, 4, 5, 6, 7]
limit: 42
hysteresis: 2
- classinfo: mymodule.MyController
config:
vials: [8, 9, 10, 11, 12, 13, 14, 15]
limit: 20
hysteresis: 1
Testing the controller
Because the controller’s main function is to modify the environment based on inputs, we can test a controller by mocking the hardware dependencies and asserting expected outputs are sent to particular hardware.
We are currently working on a testing framework for eVOLVER controllers (please see https://github.com/ssec-jhu/evolver-ng/issues/156), but in the meantime, an example for mocking hardware within an eVOLVER environment can be seen in the test_chemostat.py file in the eVOLVER repository.
Logging in the controller
All components in the eVOLVER framework contain an internal logger which is an instance of a python standard library logging.Logger. This logger can be used to emit messages from within a controller which, unless otherwise configured, will get routed through the eVOLVER logging mechanism.
Note
At present, the logger is configured only with basic handling, and will print to stdout. In future releases more advanced logging and event handling is planned, along with retrieval of logs from the API.
Example:
class MyController(Controller):
def control(self):
self.logger.info('Starting control loop')
for vial in self.vials:
self.logger.debug(f'Processing vial {vial}')
...
History
Some experiments may require access to historical data in order to make control decisions. While it is always possible to store a buffer of historic data for a given sensor in memory within the controller, this may have unintended consequences in the case of reboot or even a reconfiguration of the eVolver (which reallocates all objects): the buffer would be lost.
eVOLVER provides a history server which is backed by persistent storage designed to be queried via the API or within a controller. This means controller code can remain simple and focus on core logic, as opposed to maintaining file-based historic data or error-prone in-memory buffers.
To access the history server, the controller can use the self.evolver.history property which is automatically available to all controllers when used within the application (see eVOLVER hookup and portability below).
For example:
class MyController(Controller):
def control(self):
# Get the temperature history for all vials for past hour
temp_history = self.evolver.history.get('temp', t_start=time.time() - 3600)
for vial in self.vials:
mean_temp = np.mean([i.data[vial]['value'] for i in temp_history.data['temp']])
Note
The response from the history server
evolver.history.HistoryResult is amenable to transport over the
network, but presently has overhead in working with results in the
interpreter (as can be noted in the above example). Functionality like
to_dataframe or to_numpy can be provided in future versions.
eVOLVER hookup and portability
You might notice that the controller in the example above implicitly requires an instance of evolver.device.Evolver to be passed into its constructor, in order to access the hardware it needs. The framework normally does this for you (it will both construct the object and pass in the eVolver), but it may be desired to have a controller which depends only on that which is specified in its config and takes in hardware explicitly, which can operate independently of the application framework.
To do this while also supporting usage within the application, it is recommended to specify hardware in the Config which have types accepting either a string or hardware class, then a property which dispatches appropriately, for example:
class MyController(Controller):
class Config(Controller.Config):
temp_sensor: str|Temperature|ConfigDescriptor
@property
def temp_sensor_hw(self):
if isinstance(self.temp_sensor, str):
return self.evolver.hardware[self.temp_sensor]
return self.temp_sensor
In the above case, when the controller operates within the application, the temp_sensor will be specified as the name of the hardware in the eVolver configuration file, and the controller will use the hardware instance from the eVOLVER.
If the controller should be instantiated outside the framework (and without an eVOLVER instance), the instantiated Temperature object can be passed in directly:
temp_sensor = Temperature()
controller = MyController(temp_sensor=temp_sensor)
or with a dict, from create given the class info:
config = {'temp_sensor': {'classinfo': 'evolver.hardware.standard.temperature.Temperature'}}
controller = MyController.create(config)
Finally, if a ConfigDescriptor (the config object representing how to construct the appropriate class) is supplied, it will automatically be processed and instantiated:
descriptor = ConfigDescriptor(classinfo='evolver.hardware.standard.Temperature')
controller = MyController(temp_sensor=descriptor)