Categories
ChatGPT TornadoFX User Interface

ChatGPT and Code Writing

Introduction

Perhaps you already know what ChatGPT is but I’m going to let it introduce itself by submitting the prompt: “Describe ChatGPT”. The answer it gave me is this:

“I am ChatGPT, a large language model developed by OpenAI. I am based on the GPT-3.5 architecture, which is a powerful neural network that has been trained on an enormous amount of text data. This enables me to understand natural language input and generate human-like responses.

I have access to a vast amount of information and knowledge from various sources, including books, articles, websites, and other online resources. I can understand and process information in multiple languages, and I can answer a wide range of questions, from factual queries to more complex ones that require reasoning and interpretation.

As a language model, I am designed to communicate with humans in natural language, using both text-based and voice-based interfaces. I can be used for a variety of applications, including chatbots, virtual assistants, customer service, and more.”

As you can tell, ChatGPT has a pretty lofty opinion about itself and feels it can converse with humans in natural language. I decided to test its abilities by seeking help on a coding problem for which I did not know the answer but could definitely check if I got a reasonable response. Below is my dialogue with ChatGPT.

Conversation with ChatGPT

Here is my question: “I have a user interface written in TornadoFX. I would like to add a button that will bring up a modal window where I can change the values of some parameters in the model. How do I do this?”

This was the response:

To create a button that brings up a modal window in TornadoFX, you can use the button function to create the button and then use the action function to define what happens when the button is clicked. You can create a custom modal window by extending the Fragment class and then use the openModal function to open it.

Here’s a simple example that demonstrates how to create a button that opens a custom modal window with some input fields for changing parameter values:

  1. First, create a class for the custom modal window that extends Fragment. In this example, we’ll call it ParameterEditor.
import tornadofx.*

class ParameterEditor : Fragment("Edit Parameters") {
    val parameter1 = SimpleStringProperty()
    val parameter2 = SimpleIntegerProperty()

    override val root = form {
        fieldset("Parameters") {
            field("Parameter 1") {
                textfield(parameter1)
            }
            field("Parameter 2") {
                textfield(parameter2) {
                    filterInput { it.controlNewText.isInt() }
                }
            }
        }
        buttonbar {
            button("Save") {
                isDefaultButton = true
                action {
                    // Implement logic to save the updated parameters
                    // Close the window when done
                    close()
                }
            }
            button("Cancel") {
                isCancelButton = true
                action {
                    // Close the window without saving changes
                    close()
                }
            }
        }
    }
}
  1. Next, add a button to your main UI, and use the action function to open the custom modal window when the button is clicked.
import tornadofx.*

class MainView : View("TornadoFX Example") {
    override val root = vbox {
        button("Edit Parameters") {
            action {
                find<ParameterEditor>().openModal()
            }
        }
    }
}

Now, when you run the application and click the “Edit Parameters” button, it will open the ParameterEditor modal window where you can edit the values of the parameters. After making changes, you can click “Save” to save the updated values, or “Cancel” to close the window without saving any changes.

This was certainly a good start, I thought, but left me wondering how I would get the parameter values into the modal window and how to write out changes to the model? I looked at the documentation for TornadoFX and saw a suggestion to inject a ViewController into the window in order to get values in and out. But, there was no specific description in the documentation of how to do that so I decided to ask ChatGPT:

“How do I inject a ViewController into the ParameterEditor?”

To inject a ViewController into the ParameterEditor, you’ll first need to create a class for the ViewController. In this example, we’ll call it ParameterViewController. You can use TornadoFX’s dependency injection mechanism to inject the controller into the ParameterEditor fragment.

  1. Create a class for the ViewController:
import tornadofx.*

class ParameterViewController : Controller() {
    fun updateParameters(parameter1: String, parameter2: Int) {
        // Implement logic to update the model with new parameter values
        println("Parameter 1: $parameter1, Parameter 2: $parameter2")
    }
}
  1. Use the inject function in the ParameterEditor fragment to inject an instance of ParameterViewController:
import tornadofx.*

class ParameterEditor : Fragment("Edit Parameters") {
    val parameter1 = SimpleStringProperty()
    val parameter2 = SimpleIntegerProperty()

    // Inject the ParameterViewController
    private val viewController: ParameterViewController by inject()

    override val root = form {
        fieldset("Parameters") {
            field("Parameter 1") {
                textfield(parameter1)
            }
            field("Parameter 2") {
                textfield(parameter2) {
                    filterInput { it.controlNewText.isInt() }
                }
            }
        }
        buttonbar {
            button("Save") {
                isDefaultButton = true
                action {
                    // Call the updateParameters function in the viewController
                    viewController.updateParameters(parameter1.value, parameter2.value.toInt())

                    // Close the window when done
                    close()
                }
            }
            button("Cancel") {
                isCancelButton = true
                action {
                    // Close the window without saving changes
                    close()
                }
            }
        }
    }
}

Now, when you click “Save” in the ParameterEditor window, it will call the updateParameters function in the injected ParameterViewController instance. This function can be used to update your model with the new parameter values.

Nice, now I could envision how to get ahold of the parameters in my model and change them as well. Only one problem remained namely how to identify the source of the the object requesting the modal window? I had seen in the documentation that you could transfer parameters along with opening modal windows. I thought perhaps I could capture the name of the sending object? I asked ChatGPT how to do that with the following question:

“How do I transfer parameters by params?”

To transfer parameters between views or fragments using params in TornadoFX, you can pass a map of key-value pairs when you open a new window. The receiving fragment or view can then access these parameters using the params property.

Here’s how you can pass parameters using params:

  1. In your main view or fragment, create a button that opens the ParameterEditor fragment with the parameters you want to pass.
import tornadofx.*

class MainView : View("TornadoFX Example") {
    private val currentParameter1 = "Initial value"
    private val currentParameter2 = 42

    override val root = vbox {
        button("Edit Parameters") {
            action {
                find<ParameterEditor>(
                    params = mapOf(
                        "parameter1" to currentParameter1,
                        "parameter2" to currentParameter2
                    )
                ).openModal()
            }
        }
    }
}
  1. In the ParameterEditor fragment, use the params property to access the passed parameters and set the initial values for the SimpleStringProperty and SimpleIntegerProperty.
import tornadofx.*

class ParameterEditor : Fragment("Edit Parameters") {
    val initialParameter1: String by param()
    val initialParameter2: Int by param()

    val parameter1 = SimpleStringProperty(initialParameter1)
    val parameter2 = SimpleIntegerProperty(initialParameter2)

    // Inject the ParameterViewController
    private val viewController: ParameterViewController by inject()

    override val root = form {
        fieldset("Parameters") {
            field("Parameter 1") {
                textfield(parameter1)
            }
            field("Parameter 2") {
                textfield(parameter2) {
                    filterInput { it.controlNewText.isInt() }
                }
            }
        }
        buttonbar {
            button("Save") {
                isDefaultButton = true
                action {
                    // Call the updateParameters function in the viewController
                    viewController.updateParameters(parameter1.value, parameter2.value.toInt())

                    // Close the window when done
                    close()
                }
            }
            button("Cancel") {
                isCancelButton = true
                action {
                    // Close the window without saving changes
                    close()
                }
            }
        }
    }
}

Now, when you open the ParameterEditor fragment, it will display the initial values passed through the params property. You can update these values and click “Save” to save the changes using the ParameterViewController.

Results

The kind of responses I got from ChatGPT were good enough to implement my own parameter changing window. My application was for tuning PID controllers by giving them different gains and reset times. This is how my code turned out.

package controller

import javafx.beans.property.SimpleDoubleProperty
import tornadofx.*

class ParameterEditor : Fragment("Edit Parameters") {
    val controllerName: String by param()
    // Inject the ViewController
    private val viewController: ViewController by inject()
    val pidView = viewController.findPIDViewControllerByName(controllerName)
    val gain = SimpleDoubleProperty(pidView.pid.gain)
    val resetTime = SimpleDoubleProperty(pidView.pid.resetTime)

    override val root = form {
        fieldset("Parameters") {
            field("Gain") {
                textfield(gain)
            }
            field("Reset Time") {
                textfield(resetTime) {
                    filterInput { it.controlNewText.isDouble() }
                }
            }
        }
        buttonbar {
            button("Save") {
                isDefaultButton = true
                action {
                    viewController.setGainForController(controllerName, gain.value)
                    viewController.setResetForController(controllerName, resetTime.value)
                    // Close the window when done
                    close()
                }
            }
            button("Cancel") {
                isCancelButton = true
                action {
                    // Close the window without saving changes
                    close()
                }
            }
        }
    }
}

I already had a viewController where all interface- and model logic are coordinated. I’m showing the few lines I had to add to this viewController to make the scheme work.

    fun findPIDViewControllerByName(name: String): PIDViewController {
        val controller = controllerMap[name] ?: throw IllegalArgumentException("PIDViewController not found")
        return controller
    }

    fun setGainForController(name:String, gain: Double) {
        val controller = controllerMap[name] ?: throw IllegalArgumentException("PIDViewController not found")
        controller.pid.gain = gain
    }

    fun setResetForController(name:String, reset: Double) {
        val controller = controllerMap[name] ?: throw IllegalArgumentException("PIDViewController not found")
        controller.pid.resetTime = reset
    }

The actual invocation of the modal window happens in the following function when the “Tune” button is pressed on a controller face plate in the GUI.

    fun tuneController() {
        find<ParameterEditor>(
            params = mapOf(
                "controllerName" to name,
            )
        ).openModal()
    }

We can summarize what happens as a few steps:

  • When the Tune button on a controller face plate is pressed, the tuneController function is executed. It opens the ParameterEditor window and sends its name along under the key “controllerName”
  • The ParameterEditor decodes the string associated with the parameter controllerName and now knows the name of the controller initiating the call.
  • Now the main ViewController is found and the ParameterEditor uses the controller name to find the actual PID controller in the ViewController.
  • With the PID controller identified we can retrieve and display the current parameter values. When these are changed we can also save the new values and close the modal window.

Conclusions

This non-trivial example demonstrates the power of ChatGPT, at least as a code writing assistant. I’m fairly certain that I could have come up with a solution without ChatGPT but it would have taken me a longer. What is particularly impressive is that you can refine the answer by asking more questions. ChatGPT remembers the previous context, and answers, so there is no need to reformulate the original question, just add to it and ask for more details.

Categories
Dynamic Modeling Integration Methods Kotlin Stiff Models TornadoFX User Interface

A Humidification Column GUI

Introduction

Previously I showed how straightforward it is to build a user interface with TornadoFX and how expedient it is to re-use modules from a well-designed MVC application. In this post I capitalize on these facts and build a Humidification Column App.

The Humidification column is described in detail in ref. 1. It consists of a packed column where warm water from the top is contacted with dry, warm air from the bottom. The water is cooled on its way down the column from evaporative cooling while the moisture content in the air increases. A PI-controller senses the water exit temperature and adjusts the incoming air rate in order to hold the water temperature at setpoint. The column is modelled by three partial differential equations plus the PI controller and valve equation.

These equations are easily implemented in Kotlin. Here I just show the code for calculating the derivatives. Please note that I have not followed strict Kotlin conventions in naming my variables. This is because I have translated the original FORTRAN code almost verbatim.

    override fun updateDerivativeVector(derivativeVector: DoubleArray, time: Double): DoubleArray {

        val dydt = derivativeVector.copyOf()

        val e = tl[0] - TLSET
        x = XSS + KC * (e + EI / TI)
        if (x < 0) {
            x = 0.0
        }
        if (x > 1) {
            x = 1.0
        }
        V = CVDP * x
        val P1 = V / (G * S)

        val ep = tg.copyOf()
        val ys = tg.copyOf()
        for (I in 0 until N) {
            tg[I] = (ev[I] - y[I] * DHVAP) / (CVA + y[I] * CVV)
            ep[I] = CPA * tg[I] + y[I] * (CPV * tg[I] + DHVAP)
            val exponent = 7.96681 - 3002.4 / (378.4 + 1.8 * tl[I] + 32.0)
            val P = pow(10.0, exponent)
            ys[I] = P / (760.0 - P)
            if (ys[I] < 0) {
                ys[I] = 0.0
            }
        }

        y[0] = 0.01
        tl[N-1] = 43.33
        tg[0] = 43.33
        ep[0] = CPA * tg[0] + y[0] * (CPV * tg[0] + DHVAP)

        val yz = derivativesAtGridpoints(xL = 0.0, xU = ZL, n = N, u = y).b
        val epz = derivativesAtGridpoints(xL = 0.0, xU = ZL, n = N, u = ep).b
        val tlz = derivativesAtGridpoints(xL = 0.0, xU = ZL, n = N, u = tl).b

        var index = 0
        for (I in 0 until N) {
            dydt[index] = -P1 * yz[I] + P2 * (ys[I] - y[I])
            index += 1
        }
        for (I in 0 until N) {
            val P7 = CVV * tg[I] + DHVAP
            dydt[index] = -P1 * epz[I] + P3 * (tl[I] - tg[I]) + P2 * (ys[I] - y[I]) * P7
            index += 1
        }
        for (I in 0 until N) {
            val P7 = CVV * tg[I] + DHVAP
            dydt[index] = P4 * tlz[I] - P5 * (tl[I] - tg[I]) - P6 * (ys[I] - y[I]) * P7
            index += 1
        }
        dydt[index++] = e
        dydt[index] = (x - valveLag) / tau

        dydt[0] = 0.0
        dydt[N] = 0.0
        dydt[3 * N - 1] = 0.0

        return dydt
    }

The User Interface

The user interface will consist of a few controls and a couple of plots to show the controlled temperature and the air rate as computed by the PI-controller. It should be possible to make one run after the other while changing the chosen integration method and the number of grid points. Here are screenshots of the final interface.

This interface was almost trivial to construct especially since I copied most of it directly from other models. Here is the TornadoFX code for the interface.

import controller.ViewController
import javafx.geometry.Pos
import javafx.scene.chart.NumberAxis
import tornadofx.*

var viewController = find(ViewController::class)

class MainView: View() {
    override val root = borderpane {
        left = vbox {
            alignment = Pos.CENTER_LEFT
            button("Run Simulation") {
                action {
                    viewController.runSimulation()
                }
            }
            button("Pause Simulation") {
                action {
                    viewController.pauseSimulation()
                }
            }
            button("Resume Simulation") {
                action {
                    viewController.resumeSimulation()
                }
            }
            combobox(viewController.selectedIntMethod, viewController.intMethods)
            combobox(viewController.selectedDiscretizer, viewController.dicretizers)
            combobox(viewController.selectedHpMethod, viewController.hpMethods)
            label("  Initial StepSize:")
            textfield(viewController.initStepSize)
            label("  Number of grid points:")
            textfield(viewController.nGridPoints)
            label("  UI update delay (ms):")
            label("1000=Slow updates, 1=Fast")
            textfield(viewController.simulator.sliderValue)
            slider(min=1, max=1000, value = viewController.simulator.sliderValue.value) {
                bind(viewController.simulator.sliderValue)
            }
        }
        center = tabpane {
            tab("Liquid Temperature") {
                linechart("Exit temperature", NumberAxis(), NumberAxis()) {
                    xAxis.isAutoRanging = false
                    val xa = xAxis as NumberAxis
                    xa.lowerBound = 0.0
                    xa.upperBound = 2.0
                    xa.label = "Time, h"
                    yAxis.isAutoRanging = false
                    val ya = yAxis as NumberAxis
                    ya.lowerBound = 30.0
                    ya.upperBound = 38.0
                    ya.label = "Temperature, oC"
                    series("liqTemp") {
                        data = viewController.liqTempData
                    }
                }
            }
            tab("Vapor Rate") {
                linechart("Vapor Rate", NumberAxis(), NumberAxis()) {
                    xAxis.isAutoRanging = false
                    val xa = xAxis as NumberAxis
                    xa.lowerBound = 0.0
                    xa.upperBound = 2.0
                    xa.label = "Time, h"
                    yAxis.isAutoRanging = false
                    val ya = yAxis as NumberAxis
                    ya.lowerBound = 0.0
                    ya.upperBound = 25000.0
                    ya.tickUnit = 5000.0
                    ya.label = "Vapor Rate"
                    series("vapRate") {
                        data = viewController.vapFlowData
                    }
                }
            }
        }
    }
}

The view depends heavily on the (View-)controller for its behavior. The view-controller code is not complicated either and it makes good use of JavaFX’s collection classes. Recall that these are observable objects meaning that they automatically update as changes in the interface are detected.

import Notification.IObserver
import javafx.beans.property.SimpleDoubleProperty
import javafx.beans.property.SimpleIntegerProperty
import javafx.beans.property.SimpleStringProperty
import javafx.collections.FXCollections
import javafx.scene.chart.XYChart
import model.HumidificationColumn
import model.Simulator
import tornadofx.*


class ViewController: Controller(), IObserver {

    var model = HumidificationColumn(21)
    var endTime = 2.0
    var simulator = Simulator(model)

    var liqTempData = FXCollections.observableArrayList<XYChart.Data<Number, Number>>()
    var vapFlowData = FXCollections.observableArrayList<XYChart.Data<Number, Number>>()



    val dicretizers = FXCollections.observableArrayList("Euler", "RungeKutta", "RKFehlberg")
    val intMethods = FXCollections.observableArrayList("SingleStep", "HpMethods")
    val selectedDiscretizer = SimpleStringProperty("Euler")
    val selectedIntMethod = SimpleStringProperty("SingleStep")
    val hpMethods = FXCollections.observableArrayList("Eulex", "Eulsim")
    val selectedHpMethod = SimpleStringProperty("Eulex")
    val initStepSize = SimpleDoubleProperty(0.001)
    val nGridPoints = SimpleIntegerProperty(model.N)


    init {
        simulator.addIObserver(this)
    }

    fun runSimulation() {
        model = HumidificationColumn(nGridPoints.value)
        simulator.ode = model
        simulator.discretizer = selectedDiscretizer.value
        simulator.intMethod = selectedIntMethod.value
        simulator.hpMethod = selectedHpMethod.value
        simulator.initialStepSize = initStepSize.value
        simulator.runSimulation()
    }

    fun pauseSimulation() {
        simulator.pauseSimulation()
    }

    fun resumeSimulation() {
        simulator.resumeSimulation()
    }


    override fun update(theObserved: Any?, changeCode: Any?) {
        val code = changeCode as String
        when (code) {
            "reset" -> {
                if (liqTempData.size > 0) {
                    liqTempData.remove(0, liqTempData.size)
                    vapFlowData.remove(0, vapFlowData.size)
                }
            }
            "update" -> {
                val time = model.time
                liqTempData.add(XYChart.Data(time, model.tl[0]))
                vapFlowData.add(XYChart.Data(time, model.V))
                if (time > 0.333) {
                    model.TLSET = 36.0
                }
                if (time > 0.67) {
                    model.TLSET = 32.0
                }
                if (time > 1.67) {
                    model.TLSET = 36.0
                }
            }
            else -> {}
        }
    }
}

Stiff Problems

In all my previous posts I have used dynamic models that are non-stiff, meaning that the separation of time constants (inverse of eigenvalues) is not large. If we define the largest time constant as the time-scale of the problem then the smallest time constant is the inverse of the numerically largest eigenvalue of the Jacobian matrix. The ratio of the largest time constant to the smallest is called the stiffness ratio.

Why do we care about the stiffness ratio? The reason is that the maximum allowable step size for an explicit integrator like Euler and Runge-Kutta is limited by the smallest time constant of the problem. Thus, the integrator has to evaluate the derivatives roughly as many times as the magnitude of the stiffness ratio. For stiffness ratios < 10^4 we usually don’t notice any performance degradation in using an explicit integrator but as the stiffness ratio grows we start noticing a problem.

Let me illustrate with the Humidification column. Stiffness increases with the number of grid points, N, chosen for the spatial derivative approximations. With the default value of N = 21, the explicit integrator RKFehlberg requires a step size of h = 0.00012 h (= 0.4 seconds) for stability. The time to complete 2 h of process time is roughly 2.2 seconds on my computer. That’s plenty fast.

However, when I double the number of grid points to N = 41, I have to lower the fixed step size to h = 0.00006 to retain numerical stability. The time for 2 h integration triples to 6.6 seconds even though the number of derivative evaluations only doubled. The reason is the nonlinearities in the derivative evaluations. The execution speed is no longer fast but tolerable.

The situation becomes problematic when we double the number of grid points one more time to N = 81. Predictably, the stable step size is h = 0.00003 h (= 0.1 second) but the execution speed is now 23.5 seconds! That’s impracticably slow for evaluating multiple scenarios on a model.

Extrapolation Methods

The general idea of an extrapolation method is to have an integrator that tries to take long time steps, H, by extrapolating the results of several sub-steps, p, within the long step. The long step and the number of sub-steps are adjusted by a step controller to give a desired accuracy of integration. These methods are sometimes referred to as Hp-methods. Here I will not go into details of extrapolation methods but merely refer to a couple of relevant references (2, 3).

The Hp methods come in two flavors, an explicit integrator called Eulex and a semi-implicit version called Eulsim. Eulex has similar stability properties to Euler, Runge-Kutta and RKFehlberg and is not expected to do any better than those. In fact, because of the extra sub-steps involved in an Hp method, we would expect Eulex to do slightly worse than a fixed-step Euler using its largest, stable step size. However, Eulex has the advantage over a fixed-step Euler that the step controller will automatically find a stable step size for a given accuracy.

In contrast to the explicit methods, a semi-implicit method like Eulsim, has a much wider stability region for the step size and should be expected to do better for stiff problems.

The following table summarizes the results of a few runs with the model and its interactive interface. It is pretty clear that for stiff problems the choice of integrator becomes important.

# Grid pointsRKFehlberg, secondsEulex, secondsEulsim, seconds
212.22.71.1
416.68.41.2
8123.533.23.8
CPU time for integrating the Humidification column with various methods

The boldface entries in the table are visualized in a short video that also demonstrates the use of the interface.

Short demonstration of the Humidification column interface.

Summary

I have covered a few different topics in this post. First, I showed once more how easy it is to construct a user interface with TornadoFX and Kotlin. Second, I demonstrated the power of MVC when it comes to re-use of view- and controller modules for entirely different models. Finally, the concepts of stiff problems and implicit integrators were discussed.

References

  1. Silebi, C.A. and Schiesser, W.E. Dynamic Modeling of Transport Process Systems, Academic Press, Inc., San Diego, CA, 1992
  2. Deuflhard, P. “Order and Stepsize Control in Extrapolation Methods”, Numer. Math., 41, 1983, pp 399-422.
  3. Deuflhard, P., “Recent Progress in Extrapolation Methods”, SIAM Rev. vol 27, 1985, pp. 505-535.
Categories
Distillation control Dynamic Modeling Kotlin Model-View-Controller TornadoFX User Interface

Column Simulation Revisited

Background

Earlier this year I posted the description, and implementation, of a distillation column simulation. The focus then was on object-oriented modeling in dynamic simulations. I collected the results of a 16 hour run in arrays which were later plotted using Let’s plot.

Since then I have come to appreciate TornadoFX as a UI tool. In my most recent post I showed how it is possible to build an interactive simulation app to plot the results of a fairly simple model, namely Burgers’ Equation. The simulation was batch in the sense that it only ran for a pre-specified amount of time. Thus, while interactive, this feature had somewhat limited use (e.g. “Pause”, “Resume”, and “Speed up” for example). The real benefit of interactive is for continuous time simulations. As I will show here, this can be done with TornadoFX as well.

The ideal candidate for continuous time simulation is a model of a continuously operating process. The distillation column is such a candidate. Now let me make another plug for MVC. Because I always separate the model from the UI, I could readily take the exact same distillation column model, that I had previously used with Let’s plot, and now use it with TornadoFX.

Results

Since I had the model and the basic forms of the View and ViewController, I could build on those pieces to enhance the interface as shown below:

Interactive user interface for distillation column simulation.

As you can see I have kept most of the controls on the left panel but added several more plot-tabs in addition to two (PID)-controller faceplates. I attach a short video of how I use the interface.

Video showing the use of the column simulation interface.

Implementation

In the spirit of sharing and teaching I show most of the relevant code for making this interface. For example, the code below is the entire code for the main view. Notice that it describes only what you will see, not how the UI responds. The latter is the task for the ViewController.

The layout in this case is guided by three very useful items: “border pane”, “vbox”, and “hbox”. The border pane gives the overall structure of the interface, whereas the two boxes are used to position related items either vertically or horizontally. By alternating the use of these boxes you can get your buttons and fields to fall in whichever place you desire.

Both buttons and textfields have the option to use an “action” in which you call the appropriate function in the ViewController to respond to user requests.

package view

import controller.ViewController
import javafx.geometry.Pos
import javafx.scene.chart.NumberAxis
import javafx.scene.layout.Priority
import tornadofx.*

var viewController = find(ViewController::class)

class MainView: View() {
    override val root = borderpane {
        left = vbox {
            alignment = Pos.CENTER_LEFT
            button("Run Simulation") {
                action {
                    viewController.runSimulation()
                }
            }
            button("Pause Simulation") {
                action {
                    viewController.pauseSimulation()
                }
            }
            button("Resume Simulation") {
                action {
                    viewController.resumeSimulation()
                }
            }
            button("Stop Simulation") {
                action {
                    viewController.endSimulation()
                }
            }
            combobox(viewController.selectedDiscretizer, viewController.dicretizers)
            combobox(viewController.selectedStepController, viewController.stepControllers)
            label("  Initial StepSize:")
            textfield(viewController.initStepSize)
            label("  Number of Trays:")
            textfield(viewController.numberOfTrays)
            label("  TC tray Location")
            textfield(viewController.temperatureTrayLocation)
            label("  Feed tray Location")
            textfield(viewController.feedTrayLocation)
            label("  UI update delay (ms):")
            label("1000=Slow updates, 1=Fast")
            textfield(viewController.simulator.sliderValue)
            slider(min=1, max=1000, value = viewController.simulator.sliderValue.value) {
                bind(viewController.simulator.sliderValue)
            }
        }
        center = vbox {
            hbox {
                vbox {
                    label("TC")
                    hbox {
                        label("PV:")
                        textfield(viewController.tc.pv)
                    }
                    hbox {
                        label("SP:")
                        textfield(viewController.tc.sp) {
                            action {
                                viewController.tc.newSP()
                            }
                        }
                    }
                    hbox {
                        label("OP:")
                        textfield(viewController.tc.op) {
                            action {
                                viewController.tc.newOP()
                            }
                        }
                    }
                    hbox {
                        togglebutton("Auto", viewController.tc.toggleGroup) {
                            action { viewController.tc.modeChange() }
                        }
                        togglebutton("Man", viewController.tc.toggleGroup) {
                            action { viewController.tc.modeChange() }
                        }
                        togglebutton("Casc", viewController.tc.toggleGroup) {
                            action { viewController.tc.modeChange() }
                        }
                        togglebutton("Tune", viewController.tc.toggleGroup) {
                            action { viewController.tc.modeChange() }
                        }
                    }
                }
                vbox {
                    label("FC")
                    hbox {
                        label("PV:")
                        textfield(viewController.fc.pv)
                    }
                    hbox {
                        label("SP:")
                        textfield(viewController.fc.sp) {
                            action {
                                viewController.fc.newSP()
                            }
                        }
                    }
                    hbox {
                        label("OP:")
                        textfield(viewController.fc.op) {
                            action {
                                viewController.fc.newOP()
                            }
                        }
                    }
                    hbox {
                        togglebutton("Auto", viewController.fc.toggleGroup) {
                            action { viewController.fc.modeChange() }
                        }
                        togglebutton("Man", viewController.fc.toggleGroup) {
                            action { viewController.fc.modeChange() }
                        }
                        togglebutton("Casc", viewController.fc.toggleGroup) {
                            action { viewController.fc.modeChange() }
                        }
                        togglebutton("Tune", viewController.fc.toggleGroup) {
                            action { viewController.fc.modeChange() }
                        }
                    }
                }
            }
            tabpane {
                vgrow = Priority.ALWAYS
                tab("TProfile") {
                    scatterchart("Tray Temperature", NumberAxis(), NumberAxis()) {
                        //createSymbols = false
                        yAxis.isAutoRanging = false
                        val xa = xAxis as NumberAxis
                        xa.lowerBound = 1.0
                        xa.upperBound = 40.0
                        xa.tickUnit = 5.0
                        xa.label = "Tray #"
                        val ya = yAxis as NumberAxis
                        ya.lowerBound = 60.0
                        ya.upperBound = 120.0
                        ya.tickUnit = 10.0
                        ya.label = "Temperatures oC"
                        series("TProfile") {
                            data = viewController.tempProfile
                        }
                    }
                }
                tab("TrayTT") {
                    linechart("Tray Temperature", viewController.xAxisArray[0], NumberAxis()) {
                        createSymbols = false
                        yAxis.isAutoRanging = false
                        val ya = yAxis as NumberAxis
                        ya.lowerBound = 65.0
                        ya.upperBound = 120.0
                        ya.tickUnit = 5.0
                        ya.label = "Temperatures oC"
                        series("Temperature") {
                            data = viewController.tempList
                        }
                    }
                }
                tab("LT") {
                    linechart("Levels", viewController.xAxisArray[1], NumberAxis()) {
                        createSymbols = false
                        yAxis.isAutoRanging = false
                        val ya = yAxis as NumberAxis
                        ya.lowerBound = 40.0
                        ya.upperBound = 80.0
                        ya.tickUnit = 10.0
                        ya.label = "Level %"
                        series("Reboiler Level") {
                            data = viewController.reboilLevelList
                        }
                        series("Condenser Level") {
                            data = viewController.condenserLevelList
                        }
                    }
                }
                tab("PT") {
                    linechart("Column Pressure", viewController.xAxisArray[2], NumberAxis()) {
                        createSymbols = false
                        yAxis.isAutoRanging = false
                        val ya = yAxis as NumberAxis
                        ya.lowerBound = 0.75
                        ya.upperBound = 1.25
                        ya.tickUnit = 0.05
                        ya.label = "Pressure, atm"
                        series("Pressure") {
                            data = viewController.pressureList
                        }
                    }
                }
                tab("Boilup") {
                    linechart("Reboiler Boilup", viewController.xAxisArray[3], NumberAxis()) {
                        createSymbols = false
                        yAxis.isAutoRanging = false
                        val ya = yAxis as NumberAxis
                        ya.lowerBound = 1500.0
                        ya.upperBound = 2000.0
                        ya.tickUnit = 100.0
                        ya.label = "Flow, kmol/h"
                        series("Temperature") {
                            data = viewController.boilupList
                        }
                    }
                }
                tab("TCout") {
                    linechart("TC output signal", viewController.xAxisArray[4], NumberAxis()) {
                        createSymbols = false
                        yAxis.isAutoRanging = false
                        val ya = yAxis as NumberAxis
                        ya.lowerBound = 60.0
                        ya.upperBound = 100.0
                        ya.tickUnit = 10.0
                        ya.label = "Signal value %"
                        series("TCout") {
                            data = viewController.tcOutList
                        }
                    }
                }
                tab("MeOH") {
                    linechart("Methanol in Reboiler", viewController.xAxisArray[5], NumberAxis()) {
                        createSymbols = false
                        yAxis.isAutoRanging = false
                        val ya = yAxis as NumberAxis
                        ya.lowerBound = 0.0
                        ya.upperBound = 10000.0
                        ya.tickUnit = 2000.0
                        ya.label = "ppm"
                        series("MeOH") {
                            data = viewController.meohBtmsList
                        }
                    }
                }
                tab("H2O") {
                    linechart("Water in condenser", viewController.xAxisArray[6], NumberAxis()) {
                        createSymbols = false
                        yAxis.isAutoRanging = false
                        val ya = yAxis as NumberAxis
                        ya.lowerBound = 0.0
                        ya.upperBound = 400.0
                        ya.tickUnit = 50.0
                        ya.label = "ppm"
                        series("H2O") {
                            data = viewController.h20OHList
                        }
                    }
                }
                tab("Flows") {
                    linechart("Feed, Btms and Dist flow", viewController.xAxisArray[7], NumberAxis()) {
                        createSymbols = false
                        yAxis.isAutoRanging = false
                        val ya = yAxis as NumberAxis
                        ya.lowerBound = 0.0
                        ya.upperBound = 2000.0
                        ya.tickUnit = 200.0
                        ya.label = "kmol/h"
                        series("Feed") {
                            data = viewController.feedRateList
                        }
                        series("Btms") {
                            data = viewController.btmsFlowList
                        }
                        series("Dist") {
                            data = viewController.distList
                        }

                    }
                }
                tab("FeedX") {
                    linechart("MeOH in Feed", viewController.xAxisArray[8], NumberAxis()) {
                        createSymbols = false
                        yAxis.isAutoRanging = false
                        val ya = yAxis as NumberAxis
                        ya.lowerBound = 30.0
                        ya.upperBound = 60.0
                        ya.tickUnit = 5.0
                        ya.label = "Composition, mole-%"
                        series("FeedX") {
                            data = viewController.feedCmpList
                        }
                    }
                }

            }
        }
    }
}

I’m not showing the ViewController code here as it is quite similar to what I had previously shown in my most recent post. However, there is a piece of new code that is quite important and sits between the model and the interface. This is a class to handle the display and actions of the PID-controller faceplates. This code is shown here.

package controller

import instruments.ControlMode
import instruments.PIDController
import javafx.beans.property.SimpleDoubleProperty
import javafx.scene.control.ToggleButton
import javafx.scene.control.ToggleGroup

class PIDViewController(var pid: PIDController) {
    val pv = SimpleDoubleProperty(pid.pv)
    val sp = SimpleDoubleProperty(pid.sp)
    val op = SimpleDoubleProperty(pid.output)
    var mode = "Auto"

    val toggleGroup = ToggleGroup()
    fun update() {
        pv.value = pid.pv
        if (mode != "Man") {
            op.value = pid.output
        }
        if (mode == "Casc" || mode == "Man") {
            sp.value = pid.sp
        }
    }
    fun modeChange() {
        val button = toggleGroup.selectedToggle as? ToggleButton
        val buttonText = button?.text ?: "Null"
        when (buttonText) {
            "Auto" -> {
                pid.controllerMode = ControlMode.automatic
                mode = "Auto"
            }
            "Man" -> {
                pid.controllerMode = ControlMode.manual
                mode = "Man"
            }
            "Casc" -> {
                pid.controllerMode = ControlMode.cascade
                mode = "Casc"
            }
            "Tune" -> {
                pid.controllerMode = ControlMode.autoTune
                mode = "Tune"
            }
            else -> {}
        }
    }
    fun newSP() {
        pid.sp = sp.value
    }
    fun newOP() {
        pid.output = op.value
    }
}

This class is akin to a view controller in that has properties that can be displayed in the main interface. However, it also owns a reference to an actual PID controller in the process model. That way we can interactively interpret user input and convey them to the model.

Conclusions

Interactive dynamic simulations are extremely useful tools in the exploration, understanding and control of real processes. Over the years I have built many models with different interfaces for different platforms. I find Kotlin and TornadoFX to be a very powerful combination for desktop applications compiled for the JVM.

Categories
Dynamic Modeling Kotlin Model-View-Controller TornadoFX User Interface

Building an Interactive Process Simulator from Scratch

Introduction

In previous posts I have introduced dynamic integration techniques, MVC designs, Kotlin and TornadoFX. It is now time to put all the pieces together in a free-standing app with a responsive user interface. The final result will look like these two screen shots.

Figure 1. User interface with time dependent velocity profiles shown.
Figure 2. Tab 2 of the UI showing the fluid velocity as a function of time at three different x-positions.

Let’s get started putting this app together piece by piece.

Create the Project Template

In previous posts I have shown how to do this so I will move quickly through this section.

Step 1. Make a new project with JetBrains IntelliJ.

Figure 3. The new project screen. Use Kotlin, Gradle, Groovy and JDK 1.8

Step 2. Link the project to any local, supporting projects you might have (in my case SyMods), assign dependencies in build.gradle and finally provide empty folders (packages) for the “app”, “model”, “view” and the “controller” as in MVC.

Figure 4. Model structure ready for source files.

Since the build.gradle is such an important part of the project I show the script more clearly here:

plugins {
    id 'org.jetbrains.kotlin.jvm' version '1.7.10'
}

group = 'org.example'
version = '1.0-SNAPSHOT'

repositories {
    mavenCentral()
}

dependencies {
    testImplementation 'org.jetbrains.kotlin:kotlin-test'
    implementation 'org.example:SyMods'
    implementation 'no.tornado:tornadofx:1.7.20'
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4"
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-javafx:1.6.4"
}

test {
    useJUnitPlatform()
}

compileKotlin {
    kotlinOptions.jvmTarget = '1.8'
}

compileTestKotlin {
    kotlinOptions.jvmTarget = '1.8'
}

The Model

In most versions of MVC designs, the model is the only part that is completely independent of the other pieces. In other words, the model has no knowledge of either the view or the (view-) controller. The only contract the model has with the outside world is that it implements the Integratable interface. That way the same model, without substantial modifications, can be used with a completely different user interface implementation (e.g. Java/Swing or Swift/Cocoa).

The model I have chosen for this example is borrowed from the field of fluid dynamics. Such systems are described by a set of nonlinear partial differential equations expressing the conservation of mass and momentum for incompressible Newtonian fluids. The fluid velocity in three dimensions is captured in the Navier Stokes equation of fluid dynamics (see Ref. 1 for a complete derivation).

If we limit the flow model to the fluid velocity component in one dimension, and neglect pressure and gravitational effects, we obtain the one-dimensional Burgers’ equation.

Figure 5. Model of the one-dimensional velocity component, u, for a viscous fluid.

We can think of this equation as representing the velocity in the x-direction as the fluid is moving freely along a path. Note that the fluid is viscous as captured by the kinematic viscosity, v (m^2/s).

According to Ref. 1, Burgers’ equation is a standard test problem for numerical methods for two reasons (1) It is a nonlinear equation with known analytical solutions, (2) It can be made increasingly more difficult to solve numerically as the viscosity decreases.

The equation is first order in time and second order in space. Thus, it requires one initial condition and two boundary conditions. The initial condition is taken from the analytical solution at time = 0. The boundary conditions are shown above. They state that the spatial velocity derivative is zero at both ends of the flow path at all times.

Numerical techniques for solving partial differential equations like this typically involve breaking up the x-direction in a large number (N-1) of small segments, dx, flanked by N grid points to help with the approximation of the spatial derivatives. This way we end up with a set of N ordinary differential equations that can be solved with standard integrators such as Euler or Runge-Kutta.

For this example I’m choosing to use a Spline Collocation Method to approximate the first and second order spatial derivatives (See Ref. 2 for details of this method). The complete code for the model is shown here:

package model

import integrators.Integratable
import utilities.derivativesAtGridpoints
import kotlin.math.exp

class BurgersEquation(var nGridPoints: Int): Integratable {

    val length = 1.0
    var dx = length / (nGridPoints - 1)
    var time = 0.0
    var vis = 0.003
    var x = DoubleArray(nGridPoints) { it * dx }
    var u = DoubleArray(nGridPoints) { phi(x[it], time) }

    override fun initialConditionsUsingArray(xin: DoubleArray): DoubleArray {
        x = DoubleArray(nGridPoints) { it * dx }
        val u0 = xin.copyOf()
        for (i in 0 until nGridPoints) {
            u0[i] = phi(this.x[i], time)
        }
        return u0
    }

    override fun updateStatesFromStateVector(x: DoubleArray, time: Double) {
        this.time = time
        u = x
    }

    override fun updateDerivativeVector(df: DoubleArray, time: Double): DoubleArray {
        val ux = derivativesAtGridpoints(xL=0.0, xU=length, n=nGridPoints, u=u).b
        ux[0] = 0.0
        ux[nGridPoints-1] = 0.0
        val uxx = derivativesAtGridpoints(xL=0.0, xU=length, n=nGridPoints, u=ux).b
        val ut = df.copyOf()
        for (i in 0 until nGridPoints) {
            ut[i] = vis * uxx[i] - u[i] * ux[i]
        }
        return ut
    }

    override fun dimension(): Int {
        return nGridPoints
    }

    override fun stiff(): Boolean {
        return false
    }

    fun phi(x: Double, t: Double): Double {
        //
        // Function phi computes the exact solution of Burgers' equation
        // for comparison with the numerical solution.  It is also used to
        // define the initial and boundary conditions for the numerical
        // solution.
        //
        // Analytical solution
        val a = (0.05 / vis) * (x - 0.5 + 4.95 * t)
        val b = (0.25 / vis) * (x - 0.5 + 0.75 * t)
        val c = (0.5 / vis) * (x - 0.375)
        val ea = exp(-a)
        val eb = exp(-b)
        val ec = exp(-c)
        return (0.1 * ea + 0.5 * eb + ec) / (ea + eb + ec)
    }
}

The View

Next to the model, the view is similarly isolated from the other software components. In particular, it has no direct knowledge of the model implementation. This is important from a code reuse point of view. For example, it would be quite easy to use this view for another model by just changing a few text labels.

I’m using TornadoFX to build the interface. Without further explanations of the code, I think you can see how the declarative statements below result in the interfaces shown in Figures 1 and 2.

package view

import controller.ViewController
import javafx.geometry.Pos
import javafx.scene.chart.NumberAxis
import tornadofx.*

var viewController = find(ViewController::class)

class MainView: View() {
    override val root = borderpane {
        left = vbox {
            alignment = Pos.CENTER_LEFT
            button("Run Simulation") {
                action {
                    viewController.runSimulation()
                }
            }
            button("Pause Simulation") {
                action {
                    viewController.pauseSimulation()
                }
            }
            button("Resume Simulation") {
                action {
                    viewController.resumeSimulation()
                }
            }
            combobox(viewController.selectedDiscretizer, viewController.dicretizers)
            combobox(viewController.selectedStepController, viewController.stepControllers)
            label("  Initial StepSize:")
            textfield(viewController.initStepSize)
            label("  Number of grid points:")
            textfield(viewController.nGridPoints)
            label("  Model Parameter, viscosity:")
            textfield(viewController.viscosity)
            label("  UI update delay (ms):")
            label("1000=Slow updates, 1=Fast")
            textfield(viewController.simulator.sliderValue)
            slider(min=1, max=1000, value = viewController.simulator.sliderValue.value) {
                bind(viewController.simulator.sliderValue)
            }
        }
        center = tabpane {
            tab("Profile Chart 1") {
                scatterchart("Burgers' Equation", NumberAxis(), NumberAxis()) {
                    xAxis.isAutoRanging = false
                    val xa = xAxis as NumberAxis
                    xa.lowerBound = 0.0
                    xa.upperBound = 1.0
                    xa.label = "x"
                    yAxis.isAutoRanging = false
                    val ya = yAxis as NumberAxis
                    ya.lowerBound = 0.0
                    ya.upperBound = 1.2
                    ya.label = "u(x,t)"
                    series("uSeries") {
                        data = viewController.uSeriesData
                    }
                    series("uAnalSeries") {
                        data = viewController.uAnalSeriesData
                    }
                }
            }
            tab("Profile Chart 2") {
                linechart("Burgers' Equation", NumberAxis(), NumberAxis()) {
                    xAxis.isAutoRanging = false
                    val xa = xAxis as NumberAxis
                    xa.lowerBound = 0.0
                    xa.upperBound = 1.0
                    xa.label = "Time, t"
                    yAxis.isAutoRanging = false
                    val ya = yAxis as NumberAxis
                    ya.lowerBound = 0.0
                    ya.upperBound = 1.2
                    ya.label = "u(t)"
                    series("midGridpoint") {
                        data = viewController.midGridpointData
                    }
                    series("lowGridpoint") {
                        data = viewController.lowGridpointData
                    }
                    series("hiGridpoint") {
                        data = viewController.hiGridpointData
                    }
                }
            }
        }
    }
}

A couple of important comments on the view code:

  • There is no explicit mention of any of the UI classes like Button, Label, TextField etc.. Instead, these are created implicitly from TornadoFX’s builder functions.
  • There is no procedural code in the view, only configuration statements.
  • Values in and out of the interface go through the viewController. The controller is injected into the view by the statement var viewController = find(ViewController::class)
  • The view component values and the viewController variables are connected through bindings. Bindings allow for automatic transfer of updated information without having to write code to detect changes and transfer new values from one object to another.

Anyone who has worked with Java/Swing or Java/JavaFX will appreciate how simple it is to build an interface with Kotlin/TornadoFX.

View – Styling

While it is possible to provide styling code directly into the view functions I find that view code becomes less clear. An alternative that I like is to list the styling attributes in a separate Stylesheet class. This is what I used for the example application:

package app

import javafx.scene.paint.Color
import javafx.scene.text.FontWeight
import tornadofx.*

class Styles : Stylesheet() {
    init {
        button {
            padding = box(10.px)
            textFill = c("green")
            and(hover) {
                backgroundColor += Color.AQUAMARINE
            }
        }

        label {
            padding = box(5.px)
            fontSize = 14.px
            fontWeight = FontWeight.BOLD
            textFill = c("blue")
        }
    }
}

I have only done the most basic of what you can achieve with these Stylesheets. For example, I specify how much padding the buttons should have and their text color. I also specify the desired color change when the mouse “hovers” over them. Similar decorations for the labels are also shown.

The Controller

The controller usually takes a very central position in most MVC designs. In my version it owns the model and it is injected into the view as I mentioned above. The controller is also an Observer of the Simulator (see below). As such it implements the IObserver‘s update(…) function. This function is called from the Simulator when changes in the model’s data have taken place and need to be displayed. Recall that the model does not know about the view and therefore has no direct way of communicating with it. All communication between the model and the view must pass through the controller.

The controller implements a few JavaFX and TornadoFX data structures that are observable to view items through bindings. For example, take the uSeriesData in the controller. It holds data for one of the series in the view’s Profile Chart 1. This is how we use it in the view:

series("uSeries") {
      data = viewController.uSeriesData
}

In the controller this variable is a special ArrayList and is declared as follows:

var uSeriesData = FXCollections.observableArrayList<XYChart.Data<Number, Number>>()

When the Simulator sends the controller an update(…) request, the uSeriesData array is updated based on information it finds in the model.

if (updateCounter > updateFrequency || updateCounter == 0) {
      for (i in 0 until model.nGridPoints) {
           val xValue = model.x[i]
           uSeriesData.add(XYChart.Data(xValue, model.u[i]))
       }
       updateCounter = 1
 }

The neat feature about these data structures is that they are “observable” in the sense that the chart updates automatically as soon as the controller makes new data available in the uSeriesData array. Similarly, when data items are removed from the array, the display changes accordingly. In other words, there is no need to explicitly “refresh” the charts with user code. Ref 3. is a good source to learn about these data structures and their use in JavaFX/TornadoFX applications.

package controller

import utilities.IObserver
import javafx.beans.property.SimpleDoubleProperty
import javafx.beans.property.SimpleIntegerProperty
import javafx.beans.property.SimpleStringProperty
import javafx.collections.FXCollections
import javafx.scene.chart.XYChart
import model.BurgersEquation
import model.Simulator
import tornadofx.*


class ViewController: Controller(), IObserver {

    var model = BurgersEquation(201)
    var endTime = 1.0
    var simulator = Simulator(model)

    var uSeriesData = FXCollections.observableArrayList<XYChart.Data<Number, Number>>()
    var uAnalSeriesData = FXCollections.observableArrayList<XYChart.Data<Number, Number>>()
    var midGridpointData = FXCollections.observableArrayList<XYChart.Data<Number, Number>>()
    var lowGridpointData = FXCollections.observableArrayList<XYChart.Data<Number, Number>>()
    var hiGridpointData = FXCollections.observableArrayList<XYChart.Data<Number, Number>>()



    val dicretizers = FXCollections.observableArrayList("Euler", "RungeKutta", "RKFehlberg")
    val stepControllers = FXCollections.observableArrayList("FixedStep", "VariableStep")
    val selectedDiscretizer = SimpleStringProperty("Euler")
    val selectedStepController = SimpleStringProperty("FixedStep")
    val initStepSize = SimpleDoubleProperty(0.001)
    val nGridPoints = SimpleIntegerProperty(model.nGridPoints)
    val viscosity = SimpleDoubleProperty(model.vis)

    var updateCounter = 0
    val updateFrequency = 4

    init {
        simulator.addIObserver(this)
    }

    fun runSimulation() {
        model = BurgersEquation(nGridPoints.value)
        model.vis = viscosity.value
        simulator.ode = model
        simulator.discretizer = selectedDiscretizer.value
        simulator.stepSizeType = selectedStepController.value
        simulator.initialStepSize = initStepSize.value
        simulator.runSimulation()
    }

    fun pauseSimulation() {
        simulator.pauseSimulation()
    }

    fun resumeSimulation() {
        simulator.resumeSimulation()
    }

    override fun update(theObserved: Any?, changeCode: Any?) {
        val code = changeCode as String
        when (code) {
            "reset" -> {
                if (uSeriesData.size > 1) {
                    uSeriesData.remove(0, uSeriesData.size)
                    uAnalSeriesData.remove(0, uAnalSeriesData.size)
                    midGridpointData.remove(0, midGridpointData.size)
                    lowGridpointData.remove(0, lowGridpointData.size)
                    hiGridpointData.remove(0, hiGridpointData.size)
                }
            }
            else -> {
                val time = model.time
                if (updateCounter > updateFrequency || updateCounter == 0) {
                    for (i in 0 until model.nGridPoints) {
                        val xValue = model.x[i]
                        uSeriesData.add(XYChart.Data(xValue, model.u[i]))
                        uAnalSeriesData.add(XYChart.Data(xValue, model.phi(x = xValue, t = time)))
                    }
                    updateCounter = 1
                }
                updateCounter += 1
                val midGridpoint = model.nGridPoints / 2
                val lowGridpoint = midGridpoint - 20 * model.nGridPoints / 201
                val hiGridpoint = midGridpoint + 20 * model.nGridPoints / 201
                midGridpointData.add(XYChart.Data(time, model.u[midGridpoint]))
                lowGridpointData.add(XYChart.Data(time, model.u[lowGridpoint]))
                hiGridpointData.add(XYChart.Data(time, model.u[hiGridpoint]))
            }
        }
    }
}

There are a few more modules we have to discuss before the app is complete. One of these is the Simulator.

The Simulator

This module is most closely characterized as a model but it is generic enough that it can be treated separately. The simulator is responsible for the numerical integration and for notifying the controller when is time to sample the model for new data to display.

The simulator is generic in the sense that it can integrate any model as long as the model implements the Integratable interface. The simulator itself implements the interface IObservable which means that it can keep track of, and notify, observing objects (e.g. the controller).

An important task for the simulator is to start the integration and run it to completion without blocking the user from working with the UI. Traditionally this is done by starting a low priority background Thread. However, threads can be tricky to work with, especially around shared data. Instead I have opted to use Kotlin’s Coroutine package. Ref. 4 is the perfect source to learn about coroutines. Here I will only highlight the features I have used.

Coroutines are launched in a CoroutineScope. For UI applications it is quite useful to derive a private scope from the general CoroutineScope() but specify that we want the coroutine to be dispatched on the JavaFx UI thread.

import kotlinx.coroutines.*
import kotlinx.coroutines.javafx.JavaFx


class Simulator(var ode: Integratable) : IObservable {

    private var job = Job()
    private val myScope: CoroutineScope =     CoroutineScope(Dispatchers.JavaFx + job)
                :
                :
                :
}

We use the private scope to launch a coroutine with code provided in the trailing lambda.

        myScope.launch {
            while (time <= endTime) {
                time = integrator.currentTime
                integrator.startTime = time
                integrator.endTime = time + dt
                integrator.continueCalculations()
                            :
                            :
                            :
            }
        }

You can easily identify these code snippets in their complete context of the Simulator code below. The coroutines are quite nice for dynamic simulations like this because we can implement start, stop, pause, resume, etc. without freezing the UI or making it sluggish.

package model

import integrators.DiscretizationModel
import integrators.Integratable
import integrators.IntegrationServer
import integrators.StepSizeControlModel
import javafx.beans.property.SimpleDoubleProperty
import utilities.IObservable
import utilities.IObserver
import utilities.ObservableComponent
import kotlinx.coroutines.*
import kotlinx.coroutines.javafx.JavaFx


class Simulator(var ode: Integratable) : IObservable {
    private val myObservableComponent: ObservableComponent = ObservableComponent()
    private var job = Job()
    private val myScope: CoroutineScope = CoroutineScope(Dispatchers.JavaFx + job)

    val sliderValue = SimpleDoubleProperty(200.0)
    var sliderValueInt: Long = 200

    lateinit var integrator: IntegrationServer
    var discretizer = "Euler"
    var stepSizeType = "FixedStep"
    lateinit var discretizationType: DiscretizationModel
    lateinit var stepSizeControlType: StepSizeControlModel
    var time = 0.0
    var endTime = 1.0
    var reportingInterval = 0.1
    var dt = reportingInterval / 10
    var dt0 = dt
    var initialStepSize = 0.001
    var simulationPaused = false
    var endSimulation = false
    private var reportTimer = 0.0
    var x: DoubleArray

    init {
        val dim = ode.dimension()
        x = DoubleArray(dim)
        System.arraycopy(ode.initialConditionsUsingArray(x), 0, x, 0, dim)
    }

    fun reset() {
        discretizationType = when(discretizer) {
            "RungeKutta" -> DiscretizationModel.ClassicalRK
            "RKFehlberg" -> DiscretizationModel.RKFehlberg
            else -> DiscretizationModel.ModifiedEuler
        }
        stepSizeControlType = when(stepSizeType) {
            "VariableStep" -> StepSizeControlModel.VariableStepController
            else -> StepSizeControlModel.FixedStepController
        }
        x = DoubleArray(ode.dimension())
        System.arraycopy(ode.initialConditionsUsingArray(x), 0, x, 0, ode.dimension())
        integrator = IntegrationServer(discretizationType, stepSizeControlType);
        integrator.ode = ode

        integrator.initialStepSize = initialStepSize
        integrator.accuracy = 1.0e-5
        reportingInterval = 0.03
        dt = reportingInterval / 2
        dt0 = dt

        time = 0.0
        reportTimer = 0.0
        integrator.startTime = 0.0
        integrator.start(x)
    }

    fun pauseSimulation() {
        simulationPaused = true
    }

    fun resumeSimulation() {
        simulationPaused = false
    }

    fun endSimulation() {
        endSimulation = true
    }


    fun runSimulation() {
        reset()
        endSimulation = false
        simulationPaused = false
        myScope.launch {
            myObservableComponent.notifyIObservers(this, "reset")
            while (time <= endTime) {
                time = integrator.currentTime
                integrator.startTime = time
                integrator.endTime = time + dt
                integrator.continueCalculations()
                time = integrator.currentTime
                x = integrator.currentValues()

                reportTimer += dt
                dt = if (simulationPaused) {
                    delay(500L)
                    0.0
                } else {
                    dt0
                }
                if (reportTimer >= reportingInterval) {
                    sliderValueInt = sliderValue.value.toLong()
                    delay(sliderValueInt)
                    myObservableComponent.notifyIObservers(this, "update")
                    reportTimer = 0.0
                }
                if (time >= endTime || endSimulation) {
                    myObservableComponent.notifyIObservers(this, "done")
                }
            }
        }
    }

    override fun addIObserver(anIObserver: IObserver?) {
        myObservableComponent.addIObserver(anIObserver)
    }

    override fun deleteIObserver(anIObserver: IObserver?) {
        myObservableComponent.deleteIObserver(anIObserver)
    }

    override fun deleteIObservers() {
        myObservableComponent.deleteIObservers()
    }
}

The App

We now have all the pieces ready for the App itself. It is trivially simple as you can see from the code below:

package app

import javafx.stage.Stage
import tornadofx.*
import view.MainView

class MyApp: App(MainView::class, Styles::class) {
    override fun start(stage: Stage) {
        with(stage) {
            width = 1000.0
            height = 600.0
        }
        super.start(stage)
    }
}

Let’s review what happens here.

  1. The Application is instantiated along with the View and Styles classes.
  2. We set the size of the stage (=application window) and start the UI Thread.
  3. The View instantiates the ViewController, which in turn instantiates a copy of the Model and the Simulator.

At that point all the actors are on stage, so to speak, and the application is ready for user input. In the short video below I show how the app is used.

Conclusions

Kotlin is a perfect tool for dynamic simulations because it multi-platform (by virtue of running on the JVM) and fast. TornadoFX, a domain specific language written in Kotlin for use with JavaFX, is also quite attractive in constructing a user interface quickly and with minimal code.

References

  1. W. E. Schiesser, Computational Mathematics in Engineering and Applied Science, CRC Press, 1994.
  2. W. E. Schiesser, Spline Collocation Methods for Partial Differential Equations, John Wiley & Sons, 2017.
  3. K. Sharan and P. Späth, Learn JavaFX 17 2nd ed., Apress Media LLC, 2022.
  4. M. Moskala, Kotlin Coroutines, Kt. Academy, 2022
Categories
Kotlin Model-View-Controller TornadoFX User Interface

Introduction to TornadoFX

Background

Dynamic simulations are most useful when they are attached to some form of a graphical user interface (GUI). A few years ago, when working on Apps for the MacOS and iOS platforms, I used Cocoa and Cocoa touch to build interfaces with Xcode. The Mac designs follow the MVC (Model-View-Controller) pattern closely. I used to construct the views in Storyboards and control these views with ViewControllers. The modern way of creating interfaces for the Mac ecosystem is with SwiftUI, which I have not yet tried.

Going further back in time (couple of decades), when writing PC apps with Java, the built-in graphics system Swing was my choice for GUI’s. Swing was, and still is, part of the Java development package (SDK).

Since then, a more contemporary graphical system was developed called JavaFX. It was aimed at replacing Swing but this never happened the way it was intended. Today Swing is still part of the Java development package and JavaFX is a separate open-source package https://openjfx.io.

JavaFX

According to the JavaFX website “JavaFX is an open source, next generation client application platform for desktop, mobile and embedded systems built on Java.”

Why am I bringing up JavaFX when I’m currently not doing much Java programming? The reason is Kotlin and TornadoFX. As I mentioned in a previous blog post, Kotlin is a separate language that targets the JVM. TornadoFX is also a separate GUI language. However, it is written in Kotlin and uses JavaFX as the base for all its graphical constructs. In other words, Java and JavaFX go together the same way as Kotlin and TornadoFX go together. But TornadoFX is actually JavaFX wrapped in some Kotlin constructs. Confusing? Let me try to explain by way of a simple example inspired by the book

Learn JavaFX 17: Building User Experience and Interfaces with Java 2nd ed. Edition by  Kishori Sharan and Peter Späth.

Here is the output of the simple JavaFX app. It has two labels, one textfield and two buttons. The user enters a name in the textfield and clicks the “Say Hello” button to get a greeting. When done you click the “Exit” button.

Output from a simple JavaFX application.

While being a very simple App it illustrates several important aspects of any GUI:

  • How to invoke the App and make a window visible.
  • How to enter text that can be consumed by the App.
  • How to create buttons that respond when clicked.
  • How to display output created by the App.

The code below shows how the App is constructed using Java and JavaFX.

import javafx.application.Application;
import javafx.application.Platform;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;

public class SimpleFXApp extends Application {
    public static void main(String[] args) {
        Application.launch(args);
    }

    @Override
    public void start(Stage stage) {
        Label nameLabel = new Label("Enter a name:");
        TextField nameField = new TextField();

        Label msg = new Label();
        msg.setStyle("-fx-text-fill: blue;");

        // Create buttons
        Button sayHelloButton = new Button("Say Hello");
        Button exitButton = new Button("Exit");

        // Add the event handler for the Say Hello button
        sayHelloButton.setOnAction(e -> {
            String name = nameField.getText();
            if (name.trim().length() > 0) {
                msg.setText("Hello " + name);
            } else {
                msg.setText("Hello to you");
            }
        });

        // Add the event handler for the Exit button
        exitButton.setOnAction(e -> Platform.exit());

        // Create the root node
        VBox root = new VBox();

        // Set the vertical spacing to 5px
        root.setSpacing(5);

        // Add children nodes to the root node
        root.getChildren().addAll(nameLabel, nameField, msg, sayHelloButton, exitButton);

        Scene scene = new Scene(root, 350, 150);
        stage.setScene(scene);
        stage.setTitle("Simple JavaFX Application");
        stage.show();
    }
}

TornadoFX

Now let’s explore how we can write the same simple App using Kotlin and TornadoFX https://tornadofx.io.

Since TornadoFX lends itself nicely to MVC, I chose this file structure for the example. Thus, the first code snippet is just the View. It practically reads like prose in that labels, a textfield and two buttons are introduced by the builder functions. More on those later.

package view

import app.Styles
import controller.ViewController
import tornadofx.*
import kotlin.system.exitProcess

class MainView : View("Simple TornadoFX Application") {

    private val viewController: ViewController by inject()

    override val root = vbox {
        spacing = 5.0

        label("Enter a name:")
        textfield(viewController.name)
        label(viewController.labelText) {
            addClass(Styles.heading)
        }
        button("Say Hello") {
            action {
                viewController.makeGreeting()
            }
        }
        button("Exit") {
            action {
                exitProcess(0)
            }
        }
    }
}

Next we introduce the view controller code. It holds the values of the output (labelText) and the input textfield (name). It also contains the function that makes the greeting when the “Hello button” is clicked.

Notice that the MainView is derived from a View and that the ViewController is derived from a Controller. Both View and Controller are Singletons meaning that only one object of each class is constructed in the application. The view knows about the controller by “inject()”. The controller is instantiated the first time it is used in the view. The view is instantiated by the App as shown below.

package controller

import javafx.beans.property.SimpleStringProperty
import tornadofx.*

class ViewController: Controller() {

    val labelText = SimpleStringProperty()
    val name = SimpleStringProperty()

    fun makeGreeting() {
        val enteredName = name.value
        if (enteredName.trim().isNotEmpty()) {
            labelText.value = "Hello $enteredName"
        } else {
            labelText.value = "Hello to you"
        }
    }
}

We need two other pieces for the TornadoFX App to function properly. The first is the stage (window) for the application by settings its size and making it display. The MainView singleton is instantiated at the start of the App.

package app

import javafx.stage.Stage
import tornadofx.*
import view.MainView

class MyApp: App(MainView::class, Styles::class) {
    override fun start(stage: Stage) {
        with(stage) {
            width = 350.0
            height = 200.0
        }
        super.start(stage)
    }
}

The second piece is a Styles class that controls the font size and color.

package app
import javafx.scene.text.FontWeight
import tornadofx.*

class Styles : Stylesheet() {
    companion object {
        val heading by cssclass()
    }

    init {
        label and heading {
            padding = box(2.px)
            fontSize = 20.px
            fontWeight = FontWeight.BOLD
            textFill = c("blue")
        }
    }
}

By running the Kotlin/TornadoFX code we get the same output as in the Java example.This should not be surprising given that JavaFX is the engine that drives TornadoFX. What should be surprising (at least it was to me) is how the TornadoFX code can look so simple and not explicitly mention objects and classes of types TextField, Label, Button, etc.? I will try to explain below.

Output from a simple TornadoFX application.

Kotlin Magic

TornadoFX manages to invoke all the same JavaFX code that Java uses but in a much more streamlined fashion. This is possible by use of Kotlin’s extension functions and trailing lambdas.

Let’s compare the implementation of the “Hello button” both in Java and Kotlin. In Java we have the following code snippet:

        Button sayHelloButton = new Button("Say Hello");
        
        sayHelloButton.setOnAction(e -> {
            String name = nameField.getText();
            if (name.trim().length() > 0) {
                msg.setText("Hello " + name);
            } else {
                msg.setText("Hello to you");
            }
        });

In TornadoFX we don’t mention the class Button. Instead we call a function with the name “button” (lowercase “b”):

        button("Say Hello") {
            action {
                viewController.makeGreeting()
            }
        }
    fun makeGreeting() {
        val enteredName = name.value
        if (enteredName.trim().isNotEmpty()) {
            labelText.value = "Hello $enteredName"
        } else {
            labelText.value = "Hello to you"
        }
    }

The question is, how does the TornadoFX code generate the same code as JavaFX? The answer lies in the use of Kotlin extension functions and lambdas to construct builder functions. For example, look at the extension function “button” below taken from the TornadoFX’s controls library https://github.com/edvin/tornadofx/blob/master/src/main/java/tornadofx/Controls.kt#L309-L314. It takes three arguments, a String, a graphic node and a lambda function. The function is set equal to a JavaFX Button which in turn attaches the lambda function to itself.

In the example above, the lambda function is the function “action”, defined in the code below. It is also an extension function and it takes only one argument, a lambda function.

fun EventTarget.button(text: String = "", graphic: Node? = null, op: Button.() -> Unit = {}) = Button(text).attachTo(this, op) {
    if (graphic != null) it.graphic = graphic
}

fun ButtonBase.action(op: () -> Unit) = setOnAction { op() }

If you combine the result of the builder function, button(…), and its member function, action(…), you get the effect of a JavaFX Button handling an action event as illustrated below.

        button("Say Hello") {
            action {
                viewController.makeGreeting()
            }
        }

        ===

        Button("Say Hello").onAction = EventHandler { e: ActionEvent? -> viewController.makeGreeting() }

Discussion

TornadoFX offers a set of Kotlin constructs operating on JavaFX that makes it possible to build GUI’s simply and cleanly. I have not done any complex interfaces with TornadoFX yet but I plan on using it going forward.