![]() |
ChimeraTK-ApplicationCore 04.08.00
|
ApplicationCore is a framework for writing control system applications. The framework is designed to allow a simple construction of event driven data processing chains, while keeping each element of the chain self-contained and abstracted from implementation details in other elements.
Applications written with ApplicationCore are hence divided into modules. A module can have any number of input and output process variables. All program logic is implemented inside modules. Each module should implement ideally one single, self-contained functionality.
One fundamental principle of ApplicationCore is that the inputs and outputs of modules can be connected to arbitrary targets like device registers, control system variables, other modules or even multiple different targets at the same time. The program logic inside the modules does not depend on how each variable is connected, so the author of the module does not need to keep this in mind while coding.
There are the following types of modules:
An application module represents a relatively small task of the total application, e.g. one particular computation. The task will be executed in its own thread, so it is well separated from the rest of the application. Ideally, each module should be somewhat self-contained and independent of other modules, and only ApplicationCore process variables should be used for communication with other parts of the application.
An application module is a class deriving from ChimeraTK::ApplicationModule, e.g.:
In this small example, two input and one output process variable is defined. For each variable, the name, engineering unit and a short description needs to be provided. One of the two inputs is push-type, which means it can be used to trigger the computations when the variable changes. The other input is poll-type so just the current value can be obtained. This will be explained in more details in the next section.
The code processing the data needs to go into the implementation of the ApplicationModule::mainLoop() function implementation. In our small example, we implement a simple, fixed-gain proportional controller like this:
The ApplicationModule::mainLoop() function literally needs to contain a loop, which runs for the lifetime of the application. Any preparations which need to be executed once at application start should go before the start of the loop.
During shutdown of the application, any read/write operation on the process variables will function as an interruption point, so the programmer does not have to take care of this.
At the start of the mainLoop function, all inputs will already contain proper initial values (without executing a read operation). Every module is expected to pass on result based on these initial values to their outputs, hence the order inside the infinite loop is usually: compute, write, read. See Section Initial values for more details.
What has been previously in this document referred to as a process variable is actually only the accessor to it. Accessors are already known from ChimeraTK DeviceAccess, where they allow reading and writing from/to device registers. In ApplicationCore the concept is extended to a higher abstraction level, since accessors cannot only target device registers.
The process variable is a logical concept in ApplicationCore. Each process variable is exposed to the control system, can be accessed by an accessor of one or more ApplicationModules and can be connected to a device.
A process variable has a data source, which is called the feeder, and one or more so-called consumers. The feeder as well as any consumer can each be either a device register, a control system variable (e.g. from an operator panel) or an output accessor of an application module. Process variables have a name, a type, a physical unit, a description and of course a value.
Accessors can have either push or poll type transfer modes. In push mode, the feeder initiates the data transfers, while in poll mode the consumer does. In both cases, a consuming application module still needs to execute a read operation before the value becomes visible in the application buffer of the accessor.
Push inputs of application modules are accessors with the ChimeraTK::AccessMode::wait_for_new_data flag (see DeviceAccess documentation) and hence have a blocking ChimeraTK::TransferElement::read() operation which waits until data is available for reading. They also support ChimeraTK::TransferElement::readNonBlocking() and ChimeraTK::TransferElement::readLatest(), and can be used inside a ChimeraTK::ReadAnyGroup.
Poll inputs do not have the AccessMode::wait_for_new_data flag, and hence all read operations do not block and do not inform about changed values.
Outputs of application modules are always push-type, so they can be connected to either a push-type or a poll-type input. Device registers do not necessarily support the AccessMode::wait_for_new_data flag, in which case a direct connection with only poll-type readers is possible. To circumvent this, a trigger can be used which determines the point in time when a new value shall be polled, see the Section Device modules for more details. Control system variables are always considered push-type, so they can block on a read until new data becomes available.
Previously, ApplicationModules have been introduced which contain the actual application code. All ApplicationModules must be combined to form the actual application. This is done by creating an application class deriving from ChimeraTK::Application:
Every Application needs to call the Application::shutdown() function in its destructor:
In this first example, only the Controller ApplicationModule from above is instantiated in the application. This alone would be quite useless, since the module would not be connected to any device.
All variables from the Controller module will be published to the control system. The names of the variables are hierarchical using the slash as a hierarchy separator, similar to a Unix file system, with the modules being the equivalent of directories. This example would hence publish the following variables:
To add more functionality to the application, additional ApplicationModules need to be created. In this example, we add a module that averages the output of the Controller module:
Note that the name of the input is the relative path to the output of the controller module: "../Controller/heatingCurrent" directs the framework to look one level up starting from the AverageCurrent module (which is in this case the ExampleApp application instance) and then descend down into a module called "Controller" to find the variable "heatingCurrent". This will cause the framework to pass on any value written by the Controller module to the AverageCurrent module (in addition to publishing the value to the control system).
The implementation computes the initial value for its output differently as for the later computations and hence has a different order of the operations in the infinite loop compared to the Controller module:
ApplicationModules can be organised hierarchically by placing them inside a ChimeraTK::ModuleGroup. A Module group can contain any number of ApplicationModules. It can also contain other ModuleGroups, to form deeper hierarchies. Note that the Application itself is also a ModuleGroup.
In our example, we place the Controller module and the AverageCurrent module into a ModuleGroup called ControlUnit:
Since this ModuleGroup is in this example not used anywhere else, we can declare it as a nested class inside the application class.
If we now take another look at the definition of the current input in the AverageCurrent module:
we can see that the name of the variable is specified as a relative name. The two dots at the beginning refer to the parent "directory", which in this context is the ControlUnit ModuleGroup. With such relative names, it is possible to refer to the output of the Controller module without knowing the name of the common parent ModuleGroup. This would be especially useful if we would instantiate the ControlUnit group multiple times with a different name for each instance (e.g. by placing the instance into two different applications). The relative names would always work, while absolute names would have to be changed for each instantiation.
Note that module and variable names can also use absolute paths (starting with "/") or even refer to the application root by using multiple "../" components. The variable hierarchy is completely independent of the C++ ownership hierarchy of the modules, as each variable specifies its position in the hierarchy explicitly via its name.
To connect to a device, a DeviceModule needs to be instantiated in the Application, similar to instantiating an ApplicationModule:
This will publish all registers from the catalogue of the specified device "oven" to the control system and to the application modules. Since "oven" is a device alias and not a CDD, we also need to specify the DMAP file by creating an instance of SetDMapFilePath before the DeviceModule instance like this:
The call to getName() returns the name of the Application, which will be provided later to the Application constructor. This allows us to influence the file name for automated tests. The DMAP file in our example specifies two devices:
It is a good practise to use a logical name mapping device to allow more control over the representation of the device. The second device is the actual hardware device which will be used as a target for the logical name mapping device. We are using a sharedMemoryDummy backend (map file DemoDummy.map), so we can run the application locally and interact with the dummy device through QtHardMon or the test framework.
The example xlmap mapping file generates the following variable hierarchy:
In ApplicationCore all device registers are treated as unidirectional, and read/write registers will be used in the write direction only. Hence, the first 3 variables will have the device as a consumer, receiving values either from an ApplicationModule or from the control system. The other variables will use the device as a feeder, so the device will provide values to the ApplicationModules and the control system.
After this detour about the logical name mapping, we are now coming back to the instantiation of the DeviceModule. In case of our example, the device expects the application to poll the data (no registers support AccessMode::wait_for_new_data as the device does not support interrupts). Hence, the name of a push-type variable triggering the readout needs to be specified as a 3rd argument to the DeviceModule. This trigger variable will affect only registers which need a trigger and can even come from a different hierarchy level or from nowhere (in which case the control system is the default source). The connection between device registers and the application modules is made automatically based on matching variable names and paths.
For now, the only ApplicationModule in our application is the Controller module, which connects to the /Controller/temperatureReadback as its input and to the /Controller/heatingCurrent as its output (variables/registers with identical name and path will be connected). Since /Controller/temperatureReadback is the push input of the Controller module, updates to this variable will trigger the Controller computations which will then write its result back to the /Controller/heatingCurrent register on the device.
Because /Controller/temperatureReadback is a push-type input of the Controller module but a poll-type register of the device, a trigger is required to initiate the transfer. As specified, the variable /Timer/tick will be used for this.
When a device is opened (both at application start and after recovery from an exception), an optional initialisation handler can be executed. This is specified through the initialisationHandler argument of the DeviceModule constructor, or by calling DeviceModule::addInitialisationHandler(). The handler is a std::function<void(ChimeraTK::Device&)> which receives the opened device reference and performs any necessary setup steps, such as writing configuration registers, enabling outputs, or setting operational modes.
A ready-to-use implementation is the ChimeraTK::ScriptedInitHandler, which executes an external script (e.g. a Python script) to initialise the device. It can be instantiated in the Application after the DeviceModule called oven in the example). In the example application, it is used like this:
The ScriptedInitHandler automatically registers itself with the DeviceModule and publishes the script's stdout/stderr output as a process variable at /Devices/oven/initScriptOutput and its exit code at /Devices/oven/initScriptExitCode.
If the initialisation handler fails, the device will be closed and re-opened, and the handler will be retried. After a failed run, the handler waits for a configurable grace period before retrying, to avoid log file spamming.
So far, there is no source specified for the variable /Timer/tick. In this case, the control system will automatically be used as a source. Since all control system variables are considered push type, they can be used as a trigger. Any write to this variable from the control system side will now trigger the device readouts, which could be realised e.g. as a button on a control system panel. While this might be useful in some cases, we cannot run a control loop like this.
To provide a trigger for periodic tasks, ApplicationCore provides a generic ApplicationModule called ChimeraTK::PeriodicTrigger. We can instantiate it in the application like this:
This will publish the following variables (with the given instance name "Timer"):
/Timer/period allows to configure the trigger period and will default to 1000ms. /Timer/tick then is the trigger output. It is of the type uint64_t and will contain the trigger counter (starting with 0 at application start).
Because the name for the output has been already specified as a trigger in the DeviceModule, it will initiate the device readout and hence indirectly the Controller module computations. The type and value do not matter for this purpose, so the trigger counter is discarded when being used as a DeviceModule trigger, but it remains visible to the control system.
ChimeraTK::ConfigReader is another generic application module which reads an XML configuration file and exposes the values as constant process variables. These variables are published to the control system and can also be connected to other modules, e.g. to provide configuration parameters like setpoints, limits or periods.
The configuration file follows a simple structure, using <module> tags for hierarchy and <variable> tags for values. Example:
Supported types are: int8, uint8, int16, uint16, int32, uint32, int64, uint64, float, double, string, boolean.
The configuration file is named after the application with a -config.xml suffix, e.g. ExampleApp-config.xml. Every application has a built-in ConfigReader instance which looks for this file automatically. It is accessible via Module::appConfig() or Application::getConfigReader().
The values from the config file can be accessed in two ways:
The configuration can also be used to set the a PeriodicTrigger's period, e.g. by setting /Timer/period in the config XML file instead of using the default period.
A ChimeraTK::VariableGroup allows organising process variable accessors hierarchically within an ApplicationModule (or even inside another VariableGroup). The name of the VariableGroup will be part of the fully qualified variable name as seen by the control system, devices and other ApplicationModules. The same fully qualified variable names can be achieved with and without the use of VariableGroups by using relative variable names like "../someName" or "Subdirectory/someName", or even VariableGroup names like "." which do not change the hierarchy of the child variables. Hence VariableGroups can be considered mainly a C++ source code organisation feature.
Key features:
Usage example:
Note that the variable hierarchy as published to the control system is determined by the name parameter of each accessor, combined with the name of the VariableGroup. So in the example above, the variable names would be Inputs/setpoint, Inputs/readback, and Inputs/output (relative to the name of the module).
ApplicationCore maintains an internal model of the entire application structure, which is a graph representing all modules, process variables, and their relationships. This model is implemented as a Boost Graph adjacency list stored internally insinde in ChimeraTK::Model and exposed through typed proxy classes.
The model graph contains different types of vertices (nodes) and edges (relationships):
ownership: C++ ownership (e.g. Application owns ModuleGroup)parenthood: PV directory hierarchy (how variables are organised in the control system)pvAccess: Data flow between modules and process variables (with direction)neighbourhood: Relationship between a module and its PV directorytrigger: Trigger connection from a PV to a DeviceModuleThe model is primarily used internally by the framework to make variable connections (see ConnectionMaker). However, it can also be useful for:
When a process variable has multiple consumers, the framework automatically creates FanOut instances to copy and distribute the data. Even though the user does not need to take care of this, knowledge of this automatism is helpful for the understanding. There are four types of FanOuts, each used in different scenarios:
ChimeraTK::FeedingFanOut operates within the thread of the ApplicationModule that writes the output. When an ApplicationModule writes to one of its outputs, the FeedingFanOut makes copies of the data and distributes them to all connected consumers (other modules, device registers, control system variables). This is used for distributing outputs of ApplicationModules to multiple receivers like other ApplicationModules, devices and the control system.
Key characteristics:
withReturn flag)ChimeraTK::ThreadedFanOut is used when data originates from a push-type source that is not an ApplicationModule, such as a control system variable or a push-type device variable. It runs in its own thread, waiting for new data, and then distributing it to all consumers.
Key characteristics:
ChimeraTK::TriggerFanOut handles poll-type device variables that need a trigger to initiate readout. It acts like a special internal module: it waits for a trigger signal, then reads a group of poll-type device variables (using a TransferGroup) and distributes all values simultaneously.
Key characteristics:
ChimeraTK::ConsumingFanOut is a special case used when a poll-type device register has exactly one poll-type consumer (an ApplicationModule doing direct polling). In this case no trigger signal is needed - the ApplicationModule initiates the read operation directly.
Key characteristics:
This type of FanOut may have the most visible implications on the behaviour of an Application, and hence might be a common pit fall. The presence of a single poll-type consumer of a read-only device register has an influence on other, push-type readers of the same register. If the DeviceModule has a trigger, the expectation might be that push-type readers will receive data whenever the trigger arrives. This will not be the case here, since the single poll-type consumer will decide when data is read from the device and hence also when it will be distributed to the other push-type receivers (ApplicationModules and also the control system). If this behaviour is not wanted, change all inputs into push-type and use readLatest() where polling behaviour is needed.
Initial value propagation is a crucial concept in ApplicationCore. At application startup, all process variables must receive their initial values before the ApplicationModule::mainLoop() functions start executing. This ensures that modules start with consistent and meaningful data.
The framework follows a well-defined sequence:
If two ApplicationModules mutually depend on each other's outputs as inputs, a circular dependency exists. Both modules would block waiting for initial values from each other. To break this cycle, at least one of the modules must write its initial value in ApplicationModule::prepare(), before the mainLoop() blocking reads begin.
The framework detects circular dependencies and prints an error message if a deadlock occurs. The developer must resolve this by ensuring one module provides its initial value in prepare().
All receiving-end accessors start with ChimeraTK::DataValidity::faulty after construction. The initial value propagation usually sets the validity to ok. If a device is in error state at startup, the initial read will return with DataValidity::faulty. This flag is then propagated through all connected modules to the control system (and will be visible there by whatever means the control system provides, e.g. in DOOCS with a "stale data" error).
Device exceptions (e.g. communication timeouts, device disconnects) are handled automatically by the framework and do not require explicit catching in user code.
When a device access throws a ChimeraTK::runtime_error, the following happens (automatically, by the framework):
/Devices/<alias>/status is set to 1 (error)/Devices/<alias>/message is set to the error descriptionApplicationModules connected to a device variable will see the DataValidity::invalid flag on their inputs. The main loop continues to run normally (it is not blocked while the device is recovering, unless waiting for a push-type input receiving data from the broken device). Once the device is back online, the data validity returns to ok and fresh data is delivered.
If the application needs to react to device state changes explicitly, it can monitor the following automatically published variables:
/Devices/<alias>/deviceBecameFunctional - void process variable, triggers when device is fully recovered/Devices/<alias>/status - status value, sent when device is going into error state and when it is fully recovered.These can be used as inputs to an ApplicationModule to trigger specific actions on device recovery.
User code can also report exceptions by calling DeviceModule::reportException(), which triggers the same recovery mechanism. This can be used e.g. when it has been detected that a re-initialisation using the initialisation handler is necessary.
ApplicationCore has a built-in mechanism to propagate data validity flags through the processing chain. Each process variable carries a ChimeraTK::DataValidity flag which can be either ok or faulty.
The propagation works as follows:
faulty.faulty. This flag propagates through all connected modules, appearing in the control system.ApplicationCore applications are integrated into various control systems (DOOCS, EPICS, OPC UA, Tango, etc.) through a control system adapter. The adapter is chosen at compile time and links the application's process variables to the corresponding control system protocol.
The control system adapter is a library implementing the ChimeraTK ControlSystemAdapter interface. It connects the process variables defined in the Application to the outside world:
The adapter is selected using a CMake macro provided by the ApplicationCore package. For example, to use the DOOCS adapter:
Each adapter may have its own configuration file controlling features like:
For example, the DOOCS adapter uses a configuration file (typically <appname>-DoocsVariableConfig.xml) to specify location names, history settings, and other DOOCS-specific parameters, in addition to the standard DOOCS config file <executable_name>.conf, which configures the network ports etc.
The optional name mapping feature allows mapping the internal variable hierarchy to a different external naming scheme. This is useful when the control system requires a particular naming convention that differs from the internal application structure.
This section explains how to structure a new ApplicationCore project and configure its CMake build system, based on the example project provided in the example/ directory.
A typical ApplicationCore project has the following directory layout:
.cc) containing mainLoop() bodies and the Application destructor. FactoryInstance.cc that instantiates the application factory (see below). The entry point of every ApplicationCore server is a static instantiation of the ChimeraTK::ApplicationFactory template. This is placed in a dedicated source file under src_factory/:
Only the factory file is compiled with the control system adapter, while all modules and the Application class are compiled into a static library linked independently. This separation allows generating the XML generator and test executables without re-compiling the whole application.
The following demonstrates a complete CMakeLists.txt for an ApplicationCore-based server. Each step is explained together with the corresponding code from the example/ project.
The project() command sets the project name, which is then referenced throughout as ${PROJECT_NAME}.
Set major, minor and patch version variables, then include the set_version_numbers.cmake script. This script typically generates a VersionInfo.h header from the version_info_template.h.in template and provides a ${PROJECT_NAME}_VERSION variable.
Append the cmake/ directory to CMAKE_MODULE_PATH so the helper scripts can be found. The helper scripts set_default_build_to_release.cmake and set_default_flags.cmake set sensible default build types, enable warnings, and add common compiler flags.
Call find_package(ChimeraTK-ApplicationCore 03.00 REQUIRED). This makes the ChimeraTK::ChimeraTK-ApplicationCore target available. It also transitively provides ChimeraTK::DeviceAccess. The version should match the minimum required ApplicationCore version.
Include cmake/set_control_system_adapter.cmake. During configuration the user passes -DADAPTER=DOOCS|OPCUA|EPICSIOC|TANGO to choose which control system protocol to build against. If no adapter is given, a fatal error message lists the valid options. The script creates an alias target ChimeraTK::SelectedAdapter that can be linked independently of the concrete choice.
Use aux_source_directory() to collect all .cc files from src/ (module implementations), include/ (headers, to track dependencies), and src_factory/ (the factory file). Alternatively, list files explicitly for finer control.
A static library ${PROJECT_NAME}lib is built from all module sources. It links against ChimeraTK::ChimeraTK-ApplicationCore. This library contains all application logic but not the factory or control system adapter.
The main server executable ${PROJECT_NAME} is built from the factory file only. It links ${PROJECT_NAME}lib (the modules) and ChimeraTK::SelectedAdapter (the chosen control system adapter). This structure ensures the adapter is only linked once, into the final executable.
A second executable ${PROJECT_NAME}-xmlGenerator is built from the same factory file, but with the preprocessor define -DGENERATE_XML. This triggers the EnableXMLGenerator.h header to replace the server main() with one that prints the application's XML description and exits. Since it links only ${PROJECT_NAME}lib (not the adapter), it does not require a control system adapter to be installed.
Use file(COPY ...) to copy configuration files to the build directory so they are available when running tests or starting the server.
At minimum, add a smoke test that runs the XML generator to verify the variable structure is valid. More comprehensive tests can be added using the ChimeraTK::TestFacility.
Install the server and XML generator executables to bin/ (or to a server-named directory for DOOCS installations under /export/doocs/server). Configuration files should be installed separately, typically generated per-instance.
The cmake/ helper scripts (listed above) are maintained in a shared repository: https://github.com/ChimeraTK/project-template. This repository is merged into client projects via git merge –allow-unrelated-histories, allowing updates to propagate easily. The helper scripts should never be modified inside a client project; changes should be made in the project-template repository and merged back. See HowTo_project-template.md in the example directory for detailed instructions.
Replace DOOCS with the desired adapter. The build produces:
my_project — The control system server executablemy_project-xmlGenerator — A utility executable for generating the XML application descriptionThe ChimeraTK::TestFacility provides an in-process test framework that simulates devices and control system connections without requiring a live device or control system infrastructure. Tests can be integrated using CTest by adding test targets with add_test() in CMakeLists.txt.