Getting Started with JuliaSimModelOptimizer

Accessing JuliaSimModelOptimizer

In order to start using JuliaSimModelOptimizer, you can either sign in on JuliaHub and start the JuliaSim IDE application or use the JuliaHubRegistry to download the package locally.

Using JuliaSimModelOptimizer on JuliaHub

After you sign in on JuliaHub (or your JuliaHub enterprise instance), go to Applications and then start a JuliaSim IDE app.

getting_started

Using JuliaSimModelOptimizer locally

In order to use the JuliaSimModelOptimizer locally, you'll need to first add PkgAuthentication to your global environment.

using Pkg
original_active_project = Base.active_project()
try
    withenv("JULIA_PKG_SERVER" => nothing) do
        Pkg.activate("v$(VERSION.major).$(VERSION.minor)"; shared = true)
        Pkg.add("PkgAuthentication")
    end
finally
    Pkg.activate(original_active_project)
end

Next, configure Julia to be aware of JuliaHub's package server (or your enterprise JuliaHub instance):

using PkgAuthentication
PkgAuthentication.install("juliahub.com")

Then add all known registries on JuliaHub, including the JuliaHub registry, from the "Pkg REPL" (by pressing ] from the Julia REPL), using

Pkg.Registry.add()
Note

The command above will only work on Julia v1.8 and later. On versions prior to v1.8 use the pkg> REPL command registry add instead.

This will open up a new browser window or tab and ask you to sign in to JuliaHub or to confirm authenticating as an already logged in user.

Note

This will also change the package server to JuliaHub for all Julia sessions that run startup.jl.

Lastly, in order to use the datasets in the tutorials, you'll have to make them accessible via JuliaHubData, so you'll have to install JuliaHubData and DataSets and then run the following:

using JuliaHubData
using DataSets
using PkgAuthentication

PkgAuthentication.authenticate()
JuliaHubData.connect_juliahub_data_project()

Fitting model parameters to data with JuliaSimModelOptimizer

For many of the models that we have, we don't know all the parameters, so we need to calibrate them. To do that, we can either rely on experimental data that we have for the modelled system, or we can tune the parameters such that the system achieves a certain state. We will first look at the case where we have some experimental data and we want to fit the model such that the simulation matches the data. In the following tutorial we will solve the inverse problem of finding the model parameters starting from a capacitor bridge, also sometimes called De Sauty bridge.

circuit

This capacitor bridge consists of 2 capacitors, 2 resistors and an AC source with a frequency of 100Hz and 1V amplitude. We assume that we know the resistor values and the value of one of the capacitances, as shown above. We want to find the value of the unknown capacitance starting from some measurements obtained from the ampermeter.

First steps

For this tutorial we will need the following packages:

ModuleDescription
JuliaSimModelOptimizerThis is used to formulate our inverse problem and solve it
ModelingToolkitThe symbolic modeling environment
ModelingToolkitStandardLibraryThis provides model building blocks, such as the circuit elements
OrdinaryDiffEqThe numerical differential equation solvers
DataSetsWe will read our experimental data from datasets on JuliaHub
PlotsThe plotting and visualization library
using JuliaSimModelOptimizer
using ModelingToolkit, OrdinaryDiffEq
using ModelingToolkit: t_nounits as t
using ModelingToolkitStandardLibrary.Electrical
using ModelingToolkitStandardLibrary.Blocks: Sine
using DataSets
using Plots

Model Setup

In order to solve the inverse problem, we must first define the circuit model.

function create_model(; C₁ = 3e-5, C₂ = 1e-6)
    @named resistor1 = Resistor(R = 5.0)
    @named resistor2 = Resistor(R = 2.0)
    @named capacitor1 = Capacitor(C = C₁)
    @named capacitor2 = Capacitor(C = C₂)
    @named source = Voltage()
    @named input_signal = Sine(frequency = 100.0)
    @named ground = Ground()
    @named ampermeter = CurrentSensor()

    eqs = [connect(input_signal.output, source.V)
        connect(source.p, capacitor1.n, capacitor2.n)
        connect(source.n, resistor1.p, resistor2.p, ground.g)
        connect(resistor1.n, capacitor1.p, ampermeter.n)
        connect(resistor2.n, capacitor2.p, ampermeter.p)]

    @named circuit_model = ODESystem(eqs, t,
        systems = [
            resistor1, resistor2, capacitor1, capacitor2,
            source, input_signal, ground, ampermeter,
        ])
end

model = create_model()
sys = structural_simplify(model)

\[ \begin{align} \frac{\mathrm{d} \mathtt{capacitor2.v}\left( t \right)}{\mathrm{d}t} &= \mathtt{capacitor1.vˍt}\left( t \right) \\ 0 &= - \mathtt{resistor2.i}\left( t \right) + \mathtt{capacitor2.i}\left( t \right) - \mathtt{resistor1.i}\left( t \right) + \mathtt{capacitor1.i}\left( t \right) \end{align} \]

In the above, we first define the circuit components using ModelingToolkitStandardLibrary and then we connect them as in the diagram above. The units for the model are all in SI, as indicated in its documentation. The default value that we have provided for $C_2$ is our initial guess for the capacitance value.

Data Setup

We can read tabular experimental data where the model names are matching column names in the table. The time dependence in the column names is optional. For example let's consider the following:

data = dataset("juliasimtutorials/circuit_data")
name = "juliasimtutorials/circuit_data"
uuid = "56ac293a-9ebf-401c-b8a9-57d986fb2747"
tags = []
description = "Data for the Model Optimizer getting started tutorial."
type = "Blob"

[storage]
driver = "JuliaHubDataRepo"
bucket_region = "us-east-1"
bucket = "internaljuilahubdatasets"
credentials_url = "https://internal.juliahub.com/datasets/56ac293a-9ebf-401c-b8a9-57d986fb2747/credentials"
prefix = "datasets"
vendor = "aws"
type = "Blob"

    [storage.juliahub_credentials]
    auth_toml_path = "/home/github_actions/actions-runner-1/home/.julia/servers/internal.juliahub.com/auth.toml"

    [[storage.versions]]
    blobstore_path = "u1"
    date = "2023-06-10T23:53:27.845+00:00"
    size = 2768
    version = 1

We can now create an Experiment that would correspond to the experimental data above. The inverse problem is defined by the experiment, the model of the system and the search space. The search space is a vector of pairs, where the first element is the model variable (parameter or initial condition) that we want to find and the second represents the bounds in which we believe the value can be.

@unpack capacitor1, capacitor2 = model
experiment = Experiment(data, sys; initial_conditions = [capacitor2.v => 0.0, capacitor1.i => 0.0], abstol = 1e-6, reltol = 1e-5)
prob = InverseProblem(experiment, [capacitor2.C => (1.e-7, 1e-3)])
InverseProblem with one experiment with 1 elements in the search space.

Calibration

We can now calibrate the model parameters to the data using a calibration method of our choice. The simplest method that we can start with is SingleShooting. We use IpoptOptimizer for defining our optimizer.

alg = SingleShooting(maxiters = 100, optimizer = IpoptOptimizer(; tol = 1e-6))
r = calibrate(prob, alg)
Calibration result computed in 31 seconds. Final objective value: 7.02633e-10.
Optimization ended with Success Return Code and returned.

┌──────────────┐
│ capacitor2₊C │
├──────────────┤
│   1.00065e-5 │
└──────────────┘

To check that the result is correct, we can plot the solution of the simulated ampermeter readings to the experimental ones. To include the data in the plot, we use show_data=true. As systems can have a very large amount of states, the legend is not shown by default, so we turn that on with legend=true.

plot(experiment, prob, r, show_data = true, legend = true)
Example block output

We can also try another calibration algorithm, such as MultipleShooting, which would split the data into multiple groups and fit them individually and impose a continuity condition via a penalty in the loss function, for which the magnitude is set with continuitylossweight. We can also specify maxtime as stopping criteria instead of maxiters.

alg = MultipleShooting(maxiters = 200, trajectories = 7, continuitylossweight = 1.0, optimizer = IpoptOptimizer(; tol = 1e-6), initialization = DataInitialization())
r = calibrate(prob, alg)
Calibration result computed in 9 minutes and 32 seconds. Final objective value: 7.02531e-10.
Optimization ended with Success Return Code and returned.

┌──────────────┐
│ capacitor2₊C │
├──────────────┤
│   1.00064e-5 │
└──────────────┘

Again, to check that the solution is correct, we can plot the result of the simulated experiment with the calibration result and compare it against the data.

plot(experiment, prob, r, show_data = true, legend = true)
Example block output

If we want to inspect other states of the model, we can use the states keyword argument.

@unpack capacitor2, resistor1 = model
plot(experiment, prob, r, states = [capacitor2.v, resistor1.v], legend = true, ylabel = "V")
Example block output

Design Optimization with JuliaSimModelOptimizer

Similarly to how we can use a Wheatstone bridge to determine an unknown resistance by varying a variable resistance, we can build a capacitance bridge in which we find and unknown capacitance. In the following, instead of assuming that we can vary one of the resistances, we directly calibrate the capacitance value of the unknown capacitor, such that the current through the ampermeter is 0.

Design problems usually require tuning model parameters such that certain goals are attained. In some cases our objective function might not be depend on measurement data, such as in fitting problems, but instead we might want the system to achieve a certain state. JuliaSimModelOptimizer can be used for both of these usecases and in the following we will present a design optimization problem starting from the above defined capacitor bridge.

t0 = 0.0
tend = 0.1

@unpack ampermeter, capacitor1, capacitor2 = model

config = DesignConfiguration(sys;
    initial_conditions = [capacitor2.v => 0.0, capacitor1.i => 0.0],
    tspan = (t0, tend),
    abstol = 1e-6, reltol = 1e-5,
    running_cost = abs2(ampermeter.i),
    reduction = sum)

prob = InverseProblem(config, [capacitor2.C => (1.e-7, 1e-3)])
InverseProblem with one experiment with 1 elements in the search space.

As we don't have data in this case, we need to provide a timespan for our experiment and an error function to be minimized. The inverse problem definition looks the same as above, as we have the same system and the same search space.

Calibration

We define the experiment without any data, but we supply a custom error function, such that we enforce that no current passes through the ampermenter. This is equivalent with balancing the bridge by modyfing the resistances. This means that we can verify our calibration result with the known analytic results for this particular configuration,

\[C_2 = C_1 * \frac{R_1}{R_2}.\]

The simplest calibration method that we can try is SingleShooting.

alg = SingleShooting(maxiters = 100, optimizer = IpoptOptimizer(; tol = 1e-6))
r = calibrate(prob, alg)
Calibration result computed in 50 seconds. Final objective value: 1.46038e-10.
Optimization ended with Success Return Code and returned.

┌──────────────┐
│ capacitor2₊C │
├──────────────┤
│       7.5e-5 │
└──────────────┘

As we can see, we obtain the correct result $C_2 = 75μF$. We can check that the system behaves how we expect, namely that we don't have current through the ampermeter. To do this, we can plot result of the experiment using the calibration result that we obtained above.

plot(config, prob, r, states = ampermeter.i, legend = true)
Example block output

We can also try MultipleShooting, so that we split the above oscillations in multiple groups and instead of trying to cancel all of them at the same time, we can just find a solution that minimizes parts of a single oscillation and enforce the continuity of the solution.

alg = MultipleShooting(maxtime = 240, trajectories = 50, continuitylossweight = 1e-4, initialization = RandomInitialization())
r = calibrate(prob, alg)
Calibration result computed in 1 minute and 17 seconds. Final objective value: 0.00247751.
Optimization ended with Success Return Code and returned.

┌──────────────┐
│ capacitor2₊C │
├──────────────┤
│   7.50003e-5 │
└──────────────┘

We can again check that the system behaves as expected.

plot(config, prob, r, states = ampermeter.i, legend = true)
Example block output