Now that you have the code running from Part 1 of this series, let’s dive into the structure of the...
KJX Framework 101 - Part 3: Developing an Instrument Control Software (ICS) Application
Now that we have our system configured from Part 2 of the series we will take a look at how to start developing a customer-facing application using this framework.
Different users will interact with the instrument in different ways. A mechanical or electrical engineer may require more granular and manual control of the individual devices on the instrument (perhaps they’re characterizing different flow sensors). In contrast, a researcher on the same machine may simply need to configure their experimental parameters, and kick off some chemistry. An external/customer facing application may prefer a wizard based approach versus exposing manual control to the user. Different users have different needs, and as such the application needs to reflect each use case.
State-Based Navigation
We have discussed how to expose manual controls to the user (Part 2), which may not be the desired experience for a customer. Requirements for end-user applications typically favor a more guided approach. For this, we use a state-based/wizard style approach to navigate between different views and gather the information required throughout the workflow.
Running the Control application will bring up the interface for a typical customer facing workflow.
Let us take a look at the different elements within the application. Firstly you’ll notice a breadcrumb control at the top that notifies the user of which point in the workflow they are currently at. The main region of the screen is where the contents for each of the different views will render; the Welcome screen will display a greeting to the user, while Gather Run Info will display a form to input the experimental conditions, etc. The footer of the application has both a dynamic navigation control, and a notification center that can be opened to display messages to the user.
These elements need to be orchestrated together so that navigation can either proceed or not depending on if a requirement at that step in the workflow has been met. For instance, see the behavior of the Initialize Devices screen.
The breadcrumb control now reflects the state in the workflow the user is currently on. As you can see, the Next button is now disabled since on-entry into this screen, the devices are not initialized. Once all the devices are initialized, the requirement for this state has been fulfilled and the user can proceed with their experiment.
Navigation in this application is state-based. That is, every view in the wizard is tied to a state in the state machine (see Stateless). Maintaining state throughout the application lifetime is crucial to ensure correct information is preserved and prevent erroneous workflow navigation.
For this example, we’ll discuss how to add a simple view with a single state (in this case: Initialize Devices). To add a view/state we’ll need to define the state, associate it with a view/view-model, and set up the navigation guards. Let’s assume the view/view-model are already implemented, and now they need to be exposed in the wizard.
First we’ll need to update the NavigationStates enum in NavigationService.cs:
public enum NavigationStates |
Now, we need to associate the view/view-model with the new state. Here, we can inject the NavigationState into the associated view-model for that state. In our example, we would inject NavigationStates.Initialize into our InitializationScreenViewModel’s constructor.
public InitializationScreenViewModel(INavigationService<NavigationStates, NavigationTriggers> navigationService, |
Then, we’ll need to ensure the breadcrumbs have knowledge of the new state. Here we can update the list of BreadcrumbStates in our NavigationService.cs:
public ObservableCollection<NavigationStateInfo<NavigationStates>> BreadcrumbStates { get; } = [ new NavigationStateInfo<NavigationStates>(NavigationStates.Welcome, "Welcome") { IsActive = true }, new NavigationStateInfo<NavigationStates>(NavigationStates.Initialize, "Initialize Devices"), new NavigationStateInfo<NavigationStates>(NavigationStates.GatherRunInfo, "Gather Run Info"), new NavigationStateInfo<NavigationStates>(NavigationStates.ReadyToSequence, "Ready to Sequence"), new NavigationStateInfo<NavigationStates>(NavigationStates.Sequencing, "Sequencing"), new NavigationStateInfo<NavigationStates>(NavigationStates.SequencingComplete, "Sequencing Complete"), new NavigationStateInfo<NavigationStates>(NavigationStates.Washing, "Washing"), ]; |
Configure the State Machine with allowable navigation. For the initialization screen, we’d like to be able to navigate to the previous and next screen (cancellation is also available if required). In AddTransitions() of StateMachine.cs we can configure the Initialize state navigation like so:
private void AddTransitions() . . . } |
Now we can navigate to and away from the initialization screen, without issue. However, we are still able to proceed forward in our workflow without validating that all the devices have initialized without error. How can we further configure our navigation services to prevent this? The constructor for the NavigationService contains custom logic for individual states that require further guards for navigation. We can prevent the user from proceeding forwards in the workflow by adding the following logic to our constructor:
Here we observe the initialization status of each device, and only trigger navigation based on this; the user is now unable to proceed without successful initialization of all the devices. Custom navigation logic for each individual screen you implement can be added in this manner.
public NavigationService( StateMachine stateMachine, SequencingService sequencingService, RunInfo runInfo, ISupportsInitialization[] initializables) { _stateMachine = stateMachine; _runInfo = runInfo; var handler = HandleStateChange; _stateMachine.WhenAnyValue(s => s.CurrentState) .Subscribe(handler); . . . // enable the Next button only when all have been initialized var needsInitialization = initializables .Select(item => item.WhenAnyValue(x => x.IsInitialized)) .CombineLatest() .Select(isInitializedArray => isInitializedArray.Any(isInitialized => !isInitialized)); needsInitialization.Subscribe(anyNotInitialized => UpdateTriggerEnabled(NavigationStates.Initialize, NavigationTriggers.Next, !anyNotInitialized)); . . . } |
Background Services and Notifications
Most systems have background services that monitor the machine for irregularities or errors that alert the user to an issue. These can be long running sensor or temperature monitoring services that throw an alert and stop the machine if values fall out of range. Since most users will walk away from the instrument after kicking off their experiment, any activity that occurs while they are away needs to be apparent to them upon their return.
Consider an instrument that has temperature sensors that need to be monitored for values out-of-range over the course of the instrument’s operation. Using the IBackgroundService interface, we can implement a TemperatureMonitoringService that can be started on app startup, or at any state we desire. In our App.axaml.cs, we can configure all background services to start once the SW has initialized:
public partial class App : Application { public IContainer? Container { get; private set; } public ILogger? Logger { get; private set; } public override void Initialize() { InitAutofac(); AvaloniaXamlLoader.Load(this); } private void InitAutofac() { . . . builder.RegisterType<TemperatureMonitoringService>().AsSelf().As<IBackgroundService>().WithAttributeFiltering() .SingleInstance(); . . . // resolve the services that need to be started var backgroundServices = Container.Resolve<IEnumerable<IBackgroundService>>(); foreach (var svc in backgroundServices) { svc.Start(); } . . . } |
Further logic in the Start() method for the TemperatureMontoringService can be implemented to restrict which states along the workflow to monitor the sensors.
Now we need a way to log and notify the user if a sensor went out of range during the course of operations, or any other event for that matter. The nlog.config comes pre-configured with this package and can be updated based on your preferences. Using the logger object in any of your services will log the message to this file. This is great for post-facto troubleshooting. However, if there is a catastrophic failure or a degradation over time, real-time alerts are critical.
We can take advantage of the InMemoryNotificationService (implements INotificationService), in the KJX.Core library. This is the only implementation in the package, but others can be implemented and configured as needed. Injecting the INotificationService into a service like the TemperatureMonitoringService exposes the AddNotification() method for use:
public TemperatureMonitoringService(SequencingService sequencingService, INotificationService notificationService, INavigationService<NavigationStates, NavigationTriggers> navigationService, [KeyFilter("TemperatureSensor1")] ISensor sensorToMonitor, TemperatureMonitoringServiceConfig config) { _notificationService = notificationService; . . . } private void ValueUpdated(double value) { _sum += value; // add the new value to the list _values.Enqueue((DateTime.Now, value)); bool haveEnoughValues = false; var cutoff = DateTime.Now.Subtract(TimeSpan.FromMilliseconds(_config.IntervalMs)); while (_values.Count > 0 && _values.Peek().Timestamp < cutoff) { _sum -= _values.Dequeue().Value; haveEnoughValues = true; } // issue an error notification and stop sequencing if the average exceeds the threshold // for the configured period of time var average = _values.Count > 0 ? _sum / _values.Count : 0; if (average >= _config.Threshold && haveEnoughValues) { StopMonitoring(); _notificationService.AddNotification(NotificationType.Error, $"Temperature threshold of {_config.Threshold} exceeded for {_config.IntervalMs / 1000.0} seconds"); _navigationService.SendTrigger(NavigationTriggers.Abort); } . . . } |
Now, running the application you will find messages begin to populate the notification toolbar at the footer of the application.
Next Steps
With these concepts, you are now ready to begin exploring this solution further and implementing your own devices and workflows. From here, you can start implementing your own custom services, devices and views in a modular and scalable way. Follow the patterns in our solution, and get started writing your own ICS application today.
Need more help? Have questions? Reach out to us.