Categories
Distillation control Dynamic Modeling Kotlin Model-View-Controller Process Control

A Column Simulation Model

Introduction

In my previous post I introduced the use of the build system Gradle and showed how it can be used to build Kotlin applications. As an example, I created a new project, “ColumnSimulation”, aimed at simulating a distillation column with a realistic control system. The column with its controls are shown in the picture below.

Binary distillation column separating methanol and water.

This is a conventional distillation column separating a 50/50 mixture of methanol and water. It has a total of 40 trays with 75% tray efficiency. Feed enters on tray 6 and the temperature on tray 3 is measured and controlled.

The system has 7 controllers where two are operated in a cascade arrangement (the Tray TC and the Vapor FC).

Object-Oriented Modeling

I use an object-oriented approach to modeling in order to retain maximum flexibility and code reusability. Thus, all units you see in the picture are represented by software classes stored in my SyMods library. In other words, the tray section, the reboiler, the condenser, the feed system, and the controllers are all units that can be configured for the application at hand. I’ve even combined the tray section, the reboiler and the condenser into a composite class called a “ColumnWtotalCondenser”. This saves me a bit of configuration effort every time I need a column of that type.

Given that I have all the pieces to the simulation pre-made, what remains to be done? I need to instantiate the classes into objects, configure them and make the connections corresponding to the process diagram. During this effort it is useful to do further grouping such as collecting all the controllers into another composite class called DCS (short for distributed control system). Notice that I use the Builder pattern to configure my controllers. There are other ways of doing this in Kotlin but the builder pattern is generic and works well in situations with many parameters. Also please note that the code type says “Swift” and not Kotlin. At this point Kotlin was not an option and Swift is sufficiently close in its syntax not to confuse the keywords of Kotlin too much.

class DCS: DCSUnit() {
    val feedFlowController = PIDController.Builder().
        gain(0.2).
        resetTime(0.002).
        directActing(false).
        pvMax(3000.0).
        sp(1634.0).
        output(0.25).
        build()
    val trayTemperatureController = PIDController.Builder().
        gain(0.75).
        resetTime(0.3).
        analyzerTime(0.025).
        pvMax(150.0).
        sp(92.0).
        directActing(false).
        output(0.5).
        build()
    val boilupFlowController = PIDController.Builder().
        gain(0.2).
        resetTime(0.01).
        analyzerTime(0.0).
        pvMax(2000.0).
        sp(1319.0).
        directActing(false).
        output(0.33).
        build()
    val reboilerLevelController = PIDController.Builder().
        resetTime(0.5).
        output(0.5).
        build()
    val condenserLevelController = PIDController.Builder().
        resetTime(0.5).
        pvMax(110.0).
        output(0.25).
        build()
    val condenserPressureController = PIDController.Builder().
        resetTime(0.1).
        output(0.5).
        pvMax(2.0).
        sp(1.0).
        build()
    val refluxFlowController = PIDController.Builder().
        gain(0.2).
        resetTime(0.002).
        analyzerTime(0.0).
        directActing(false).
        pvMax(3200.0).
        sp(831.0).
        output(0.25).
        build()
    init {
        with(controllers) {
            add(feedFlowController)
            add(trayTemperatureController)
            add(boilupFlowController)
            add(reboilerLevelController)
            add(condenserLevelController)
            add(condenserPressureController)
            add(refluxFlowController)
        }
    }
}

This took care of the control system setup. Next I configure the rest of the process by creating two reference fluid objects and instantiating the two process objects (Feeder and Column). The so-called reference fluids (refVapor and refLiq) are made in two steps. The first step is to instantiate a local dictionary object, factory, from a component file I’ve created using publicly available databases. In the second step I call a member function on the factory object with the names of the components I wish to include. The function searches a hard coded dictionary of Wilson activity parameters and creates an ideal vapor phase and a non-ideal liquid phase. These fluids are used as templates in all objects that require them. Finally, I connect the controllers to the process. All of that is part of the model class below.

class ProcessModel(var dcsUnit: DCS) {
    val ode = ODEManager()
    val factory = FluidFactoryFrom("/Users/bjorntyreus/component_file2.csv")
    val vl = factory.makeFluidsFromComponentList(listOf("Methanol", "Water"))
    val refVapor = vl?.vapor ?: throw IllegalStateException("Did not get a vapor")
    val refLiq = vl?.liquid ?: throw IllegalStateException("Did not get a liquid")

    val feed = Feeder(identifier = "Feed",
        composition = listOf(0.5, 0.5),
        initialFlow = 1600.0,
        minFlow = 0.0,
        maxFlow = 2000.0,
        operatingTemperature = 100.0,
        maxTemperature = 100.0,
        operatingPressure = 1.2,
        maxPressure = 3.0,
        refVapor = refVapor,
        refLiquid = refLiq)
    val column = ColumnWtotalCondenser(identifier = "column",
        numberOfTrays = numberOfTrays,
        feedRate = 1600.0,
        distillateRate = 800.0,
        refluxRatio = 1.0,
        topPressure = 1.0,
        trayDP = 0.005,
        trayEfficiency = 0.75,
        coolantInletTemperature = 25.0,
        trayDiameter = 3.0,
        lightKey = "Methanol",
        heavyKey = "Water",
        refVapor = refVapor,
        refLiq = refLiq)
    init {
        with(ode.units) {
            add(feed)
            add(column)
            add(dcsUnit)
        }
        column.trays[feedTrayLocation].liquidFeed = feed.liquidOutlet

        with(dcsUnit) {
            trayTemperatureController.pvSignal = column.trays[temperatureTrayLocation].temperatureTT
            trayTemperatureController.outSignal = boilupFlowController.exSpSignal
            trayTemperatureController.efSignal = boilupFlowController.normPvSignal
            boilupFlowController.slave = true

            boilupFlowController.pvSignal = column.reboiler.vaporBoilupFT
            boilupFlowController.outSignal = column.reboiler.heatInputAC
            boilupFlowController.efSignal = column.reboiler.heatInputAC
            column.reboiler.heatInputAC.useProcessInput = false

            reboilerLevelController.pvSignal = column.reboiler.levelLT
            reboilerLevelController.outSignal = column.reboiler.outletValveAC
            reboilerLevelController.efSignal = column.reboiler.outletValveAC
            column.reboiler.outletValveAC.useProcessInput = false

            feedFlowController.pvSignal = feed.feedRateFT
            feedFlowController.outSignal = feed.feedValveAC
            feedFlowController.efSignal = feed.feedValveAC
            feed.feedValveAC.useProcessInput = false

            condenserLevelController.pvSignal = column.condenser.levelLT
            condenserLevelController.outSignal = column.condenser.outletValveBAC
            condenserLevelController.efSignal = column.condenser.outletValveBAC
            column.condenser.outletValveBAC.useProcessInput = false

            condenserPressureController.pvSignal = column.condenser.pressurePT
            condenserPressureController.outSignal = column.condenser.coolantValveAC
            condenserPressureController.efSignal = column.condenser.coolantValveAC
            column.condenser.coolantValveAC.useProcessInput = false

            refluxFlowController.pvSignal = column.condenser.liquidOutletAFT
            refluxFlowController.outSignal = column.condenser.outletValveAAC
            refluxFlowController.efSignal = column.condenser.outletValveAAC
            column.condenser.outletValveAAC.useProcessInput = false
        }

    }
}

Notice how each controller connection requires four actions: 1) The controlled signal needs to be connected (pvSignal). 2) The output from the controller needs to be connected (outSignal). 3) The external feedback signal is then connected (efSignal). Often this signal is the same as the final control element except in cascade arrangements. 4) We have to make sure that the final control element responds to the attached control signal as opposed to retaining whatever process value that is given to it (e.g. during initialization).

We now have a process with its control system attached. Time to subject these to the integration system, prepare for data collection and design a test suite. This is done in the columnSimulation function below. Notice in particular how transparent it is to specify the timing for various tests by using Kotlin’s when expression in conjunction with ranges. It should be pretty clear from the code that during the span of 16 hours we are subjecting the process to the following changes:

  • Boilup controller switching from Auto to Cascade
  • Tray TC switching from Auto to Manual
  • ATV test on Tray TC
  • Tray TC back to Auto
  • Tray TC setpoint change 92 -> 85 oC
  • Applied results from ATV test and changed setpoint back to 92 oC
  • Feed composition change from 50/50 to 40/60 methanol/water
  • Feed flow increase by roughly 10%
fun columnSimulation(discr: DiscretizationModel, control: StepSizeControlModel): List<Plot> {
    // Prepare process for integration
    val dcsUnit = DCS()
    val model = ProcessModel(dcsUnit)
    val ode = model.ode
    
    // Instantiate, configure and start integrator
    val ig = IntegrationServer(discr, control)
    val dim = ode.dimension()
    val x = DoubleArray(dim)
    val endTime = 16.0
    ig.ode = ode
    ig.initialStepSize = 1.0e-3
    val reportingInterval = 0.05
    val dt = reportingInterval / 2.0
    var localTime = 0.0
    var reportTimer = 0.0
    ig.startTime = 0.0
    ig.start(ode.initialConditionsUsingArray(x))
    
    // Create lists to hold the dynamic data from a run
    val timeList = mutableListOf<Double>()
    val tempList = mutableListOf<Double>()
    val pressureList = mutableListOf<Double>()
    val tcOutList = mutableListOf<Double>()
    val boilupList = mutableListOf<Double>()
    val reboilLevelList = mutableListOf<Double>()
    val condenserLevelList = mutableListOf<Double>()
    val h20OHList = mutableListOf<Double>()
    val meohBtmsList = mutableListOf<Double>()
    val btmsFlowList = mutableListOf<Double>()
    val distList = mutableListOf<Double>()
    val feedRateList = mutableListOf<Double>()
    val feedCmpList = mutableListOf<Double>()
    val plotList = mutableListOf<Plot>()
    var atvGain = dcsUnit.trayTemperatureController.gainATV
    var atvReset = dcsUnit.trayTemperatureController.resetTimeATV
    var reductFactor = dcsUnit.trayTemperatureController.resetReductionFactor

    // Simulate and collect data
    while (localTime <= endTime) {
        localTime = ig.currentTime
        ig.startTime = localTime
        ig.endTime = localTime + dt
        ig.continueCalculations()
        localTime = ig.currentTime
        reportTimer += dt
        if (reportTimer > reportingInterval) {
            reportTimer = 0.0
            //println("time= $localTime, Tank temp = ${model.tank.tankTemperatureTT.processValue}")
            timeList.add(localTime)
            tempList.add(model.column.trays[temperatureTrayLocation].temperatureTT.processValue)
            pressureList.add(model.column.condenser.pressurePT.processValue)
            val tcOut = model.dcsUnit.trayTemperatureController.outSignal?.signalValue ?: 0.0
            tcOutList.add(tcOut * 100.0)
            boilupList.add(model.column.reboiler.vaporBoilupFT.processValue)
            reboilLevelList.add(model.column.reboiler.levelLT.processValue)
            condenserLevelList.add(model.column.condenser.levelLT.processValue)
            h20OHList.add(model.column.condenser.liquidHoldup.weightFractions[1] * 1.0e6)
            meohBtmsList.add(model.column.reboiler.reboilerHoldup.weightFractions[0] * 1.0e6)
            btmsFlowList.add(model.column.reboiler.outletFlowFT.processValue)
            distList.add(model.column.condenser.liquidOutletBFT.processValue)
            feedRateList.add(model.feed.feedRateFT.processValue)
            feedCmpList.add(model.feed.feedComposition[0] * 100.0)

            // Perform test on the system at specified time points
            val wholeHours = localTime.toInt()
            with (dcsUnit) {
                when (wholeHours) {
                    in 1..3 -> boilupFlowController.controllerMode = ControlMode.cascade
                    in 3..4 -> {
                        trayTemperatureController.controllerMode = ControlMode.manual
                        trayTemperatureController.output = 0.92
                    }
                    in 4..6 -> {
                        trayTemperatureController.h = 0.10
                        trayTemperatureController.controllerMode = ControlMode.autoTune
                        atvGain = trayTemperatureController.gainATV
                        atvReset = trayTemperatureController.resetTimeATV
                        reductFactor = trayTemperatureController.resetReductionFactor
                    }
                    in 6..7 -> {
                        trayTemperatureController.controllerMode = ControlMode.automatic
                        trayTemperatureController.sp = 92.0
                    }
                    in 7..8 -> {
                        trayTemperatureController.controllerMode = ControlMode.automatic
                        trayTemperatureController.sp = 85.0
                    }
                    in 8..9 -> {
                        trayTemperatureController.gain = atvGain / 2.0
                        trayTemperatureController.resetTime = atvReset
                        trayTemperatureController.sp = 92.0
                    }
                    in 10..12 -> {
                        model.feed.currentComposition = listOf(0.4, 0.6)
                    }
                    in 12..14 -> feedFlowController.sp = 1800.0
                }
            }
        }
    }

The actual start of the program is trivially simple. In the main function I call the columnSimulation function and get a list of plots back. Six of these I display in one figure and the other six go to the second figure. The whole operation of simulating the 40 tray column for 16 hours takes 1.6 seconds on a 2014 vintage MacBook Pro. Kotlin is fast!

fun main(args: Array<String>) {
    val timeInMillis = measureTimeMillis {

        val plotGroup = columnSimulation(DiscretizationModel.ModifiedEuler, StepSizeControlModel.FixedStepController)

        val group1 = plotGroup.take(6)
        val group2 = plotGroup.drop(6)
        gggrid(group1, ncol = 2, cellWidth = 470, cellHeight = 300).show()
        gggrid(group2, ncol = 2, cellWidth = 470, cellHeight = 300).show()

    }
    println("(The operation took $timeInMillis ms)")
}

Below I show the results of the simulation run described in the code.

Performance of control system for the 40 tray column. Pay particular attention to the Tray Temperature Controller behavior (upper left). It is activated (cascade with Boilup) after 1 hour of operation but responds slowly due to poor tuning. After the ATV test the old tuning parameters remain for one setpoint change (at hour 7). The new tuning parameters are set at hour 8 just before a final setpoint change to 92 oC. After that both feed composition and feed rate change in steps of 10%. Temperature is held close to setpoint in the face of these disturbances.
This figure shows how the important composition variables behave in the face of temperature setpoint changes and external disturbances. Notice that the ATV test itself causes only minor deviations in the compositions. The control system is also quite robust against feed rate and composition changes.

MVC

An important concept in software engineering is the separation of a Model from its View and the software Controller used to manipulate both the view and the model. The Model-View-Controller (MVC) concept is important because it enables software reuse, provides flexibility in the choice of views and controllers and it facilitates trouble shooting and debugging.

While this post is primarily aimed at demonstrating modeling with Kotlin and Let’s plot, it also provides me with an opportunity to dwell a bit on MVC.

In the example above it should be clear that the model in my MVC is the class “ProcessModel”. It consists of the column, the feeder and the feedback control system. But the model does nothing by itself, it needs to be driven by an integrator and be told about changes to its environment. That’s the job of the controller.

Furthermore, the model has no built-in display capabilities or views. The reason is that you should be able to choose the view independently from the model. Only the controller will know about the view and will be feeding it with information from the model.

In my example the function columnSimulation(…) is the controller. It owns the model and the integrator and knows how to collect information to feed the plotting program Let’s plot. But we could have chosen another method to display the result. For example, the controller could have exported data to a text file that could have been used to display graphs in Excel. I have used that method many times.

To further drive home the flexibility of a well designed MVC I share an example simulation of the same distillation column on an iPad. Here the model is the same as above but implemented in Swift instead of Kotlin. However, the views and controllers are quite different. Instead of collecting data for static plots I update strip charts live as the simulation progresses. I also provide views and dedicated controllers for the PID controllers so the user can interact with them and provide tuning parameters while the simulation is running. This mode of operation is called interactive dynamic simulation and mimics running a real plant.

I’m currently exploring a user interface system (controllers and views) called TornadoFX. It has a set of Kotlin API’s for user interfaces and is built upon a well established UI system called JavaFX. I’m hoping to report progress on my findings in a future post.