298 lines
11 KiB
Plaintext
298 lines
11 KiB
Plaintext
# The problem-algorithm-solve interface
|
||
|
||
|
||
{{< include ../_common_code.qmd >}}
|
||
|
||
This section uses these add-on packages:
|
||
|
||
|
||
```{julia}
|
||
using Plots
|
||
using MonteCarloMeasurements
|
||
```
|
||
|
||
|
||
---
|
||
|
||
|
||
The [DifferentialEquations.jl](https://github.com/SciML) package is an entry point to a suite of `Julia` packages for numerically solving differential equations in `Julia` and other languages. A common interface is implemented that flexibly adjusts to the many different problems and algorithms covered by this suite of packages.
|
||
|
||
In this section, we review a very informative [post](https://discourse.julialang.org/t/function-depending-on-the-global-variable-inside-module/64322/10) by discourse user `@genkuroki` which very nicely demonstrates the usefulness of the problem-algorithm-solve approach used with `DifferentialEquations.jl`. We slightly modify the presentation below for our needs, but suggest a perusal of the original post.
|
||
|
||
|
||
##### Example: Free fall
|
||
|
||
|
||
The motion of an object under a uniform gravitational field is of interest.
|
||
|
||
|
||
The parameters that govern the equation of motions are the gravitational constant, `g`; the initial height, `y0`; and the initial velocity, `v0`. The time span for which a solution is sought is `tspan`.
|
||
|
||
|
||
A problem consists of these parameters. Typical `Julia` usage would be to create a structure to hold the parameters, which may be done as follows:
|
||
|
||
|
||
```{julia}
|
||
struct Problem{G, Y0, V0, TS}
|
||
g::G
|
||
y0::Y0
|
||
v0::V0
|
||
tspan::TS
|
||
end
|
||
|
||
Problem(;g=9.80665, y0=0.0, v0=30.0, tspan=(0.0,8.0)) = Problem(g, y0, v0, tspan)
|
||
```
|
||
|
||
The above creates a type, `Problem`, *and* a default constructor with default values. (The original uses a more sophisticated setup that allows the two things above to be combined.)
|
||
|
||
Types, as used above, serve two purposes: they bundle together the parameters for later reference and they can be used to dispatch varying methods to solve problems. The `solve` generic in the `Julia` ecosystem dispatches on the type of problem it is given.
|
||
|
||
In the above code, just calling `Problem()` will create a problem suitable for the earth, passing different values for `g` would be possible for other planets.
|
||
|
||
|
||
To solve differential equations there are many different possible algorithms. Here is the construction of two types to indicate two algorithms:
|
||
|
||
|
||
```{julia}
|
||
struct EulerMethod{T}
|
||
dt::T
|
||
end
|
||
EulerMethod(; dt=0.1) = EulerMethod(dt)
|
||
|
||
struct ExactFormula{T}
|
||
dt::T
|
||
end
|
||
ExactFormula(; dt=0.1) = ExactFormula(dt)
|
||
```
|
||
|
||
The above just specifies a type for dispatch-–-the directions indicating what code to use to solve the problem and default constructors. As seen, each constructor specifies a default size for a time step of `0.1`.
|
||
|
||
|
||
A type for solutions is useful for different `show` methods or other methods. One can be created through:
|
||
|
||
|
||
```{julia}
|
||
struct Solution{Y, V, T, P<:Problem, A}
|
||
y::Y
|
||
v::V
|
||
t::T
|
||
prob::P
|
||
alg::A
|
||
end
|
||
```
|
||
|
||
The different algorithms then can be implemented as part of a generic `solve` function. Following the post we have:
|
||
|
||
|
||
```{julia}
|
||
solve(prob::Problem) = solve(prob, default_algorithm(prob))
|
||
default_algorithm(prob::Problem) = EulerMethod()
|
||
|
||
function solve(prob::Problem, alg::ExactFormula)
|
||
|
||
(; g, y0, v0, tspan) = prob # property destructuring
|
||
dt = alg.dt # direct property access
|
||
t0, t1 = tspan
|
||
|
||
ts = range(t0, t1 + dt/2; step = dt)
|
||
|
||
y(t) = y0 + v0*(t - t0) - g*(t - t0)^2/2
|
||
v(t) = v0 - g*(t - t0)
|
||
|
||
Solution(y.(ts), v.(ts), ts, prob, alg)
|
||
end
|
||
```
|
||
|
||
The exact formulas:
|
||
|
||
$$
|
||
\begin{align*}
|
||
y(t) &= y_0 + v_0\cdot(t - t_0) - g\cdot(t - t_0)^2/2\\
|
||
v(t) &= v_0 - g\cdot(t - t_0),
|
||
\end{align*}
|
||
$$
|
||
|
||
are well-known physics formulas, discussed previously, for motion under a constant acceleration. The `ExactFormula` code broadcasts these functions over a range of values in `ts` and then wraps the output up in a `Solution` object so that the answers found can be easily extracted in a uniform manner.
|
||
|
||
For the Euler method, a `for` loop is utilized to step through the algorithm, in preparation, the new command `fill(y0, n)` is technical. It sets up a storage vector of length `n` which is initially filled with `y0` but for which the second through last are overwritten. There are many other means to do a similar task, including creating an uninitialized vector with `Vector{typeof(y0)}(undef, n)` for which all entries would be subsequently filled in.
|
||
|
||
```{julia}
|
||
function solve(prob::Problem, alg::EulerMethod)
|
||
|
||
(; g, y0, v0, tspan) = prob
|
||
dt = alg.dt
|
||
t0, t1 = tspan
|
||
|
||
ts = range(t0, t1 + dt/2; step = dt)
|
||
n = length(ts)
|
||
|
||
ys = fill(y0, n)
|
||
vs = fill(v0, n)
|
||
|
||
for i in 1:n-1
|
||
vs[i+1] = vs[i] - g*dt # F*h step of Euler
|
||
ys[i+1] = ys[i] + vs[i]*dt # F*h step of Euler
|
||
end
|
||
|
||
Solution(ys, vs, ts, prob, alg)
|
||
end
|
||
```
|
||
|
||
Plots of solutions generated by the default values for each method are produced in @fig-projectile-motion-on-the-earth-dt-default.
|
||
|
||
::: {#fig-projectile-motion-on-the-earth-dt-default}
|
||
```{julia}
|
||
earth = Problem()
|
||
sol_euler = solve(earth)
|
||
sol_exact = solve(earth, ExactFormula())
|
||
|
||
plot(sol_euler.t, sol_euler.y;
|
||
label="Euler's method (dt = $(sol_euler.alg.dt))", linestyle=:auto)
|
||
plot!(sol_exact.t, sol_exact.y;
|
||
label="exact solution", linestyle=:auto)
|
||
|
||
title!("On the Earth"; xlabel="t", legend=:bottomleft)
|
||
```
|
||
|
||
Plot of exact and approximate solutions, the latter using the default time step size
|
||
:::
|
||
|
||
Following the post, since the time step `dt = 0.1` is not small enough, the error of the Euler method is readily identified in @fig-projectile-motion-on-the-earth-dt-default.
|
||
|
||
Next we change the algorithm's default parameter for `dt` to be smaller. @fig-projectile-motion-on-the-earth-dt-modified shows a much improved agreement between the exact answer and the approximate one found with `EulerMethod`.
|
||
|
||
::: {#fig-projectile-motion-on-the-earth-dt-modified}
|
||
```{julia}
|
||
earth₂ = Problem()
|
||
sol_euler₂ = solve(earth₂, EulerMethod(dt = 0.01))
|
||
sol_exact₂ = solve(earth₂, ExactFormula())
|
||
|
||
plot(sol_euler₂.t, sol_euler₂.y;
|
||
label="Euler's method (dt = $(sol_euler₂.alg.dt))", linestyle=:auto)
|
||
plot!(sol_exact₂.t, sol_exact₂.y;
|
||
label="exact solution", linestyle=:auto)
|
||
|
||
title!("On the Earth"; xlabel="t", legend=:bottomleft)
|
||
```
|
||
|
||
Model of projectile motion on the earth along with exact solution. This approximation used a modification from the default for the step size.
|
||
:::
|
||
|
||
The code is mostly a template. It is worth noting that only one line of code was modified, and in that line only the method required a modification.
|
||
|
||
|
||
Were the moon to be considered, the gravitational constant would need adjustment. This parameter is a property of the problem, not the solution algorithm, as `dt` is.
|
||
|
||
|
||
Such adjustments are made by passing different values to the `Problem` constructor. Again, just the one line needs modification.
|
||
|
||
::: {#fig-projectile-motion-on-the-moon}
|
||
```{julia}
|
||
moon = Problem(g = 1.62, tspan = (0.0, 40.0))
|
||
|
||
sol_eulerₘ = solve(moon)
|
||
sol_exactₘ = solve(moon, ExactFormula(dt = sol_euler.alg.dt))
|
||
|
||
plot(sol_eulerₘ.t, sol_eulerₘ.y;
|
||
label="Euler's method (dt = $(sol_eulerₘ.alg.dt))", linestyle=:auto)
|
||
plot!(sol_exactₘ.t, sol_exactₘ.y;
|
||
label="exact solution", linestyle=:auto)
|
||
|
||
title!("On the Moon"; xlabel="t", legend=:bottomleft)
|
||
```
|
||
|
||
Model for projectile motion on the moon. Only modest changes needed to be introduced from the model for the motion on oearth illustrated in @fig-projectile-motion-on-the-earth-dt-default.
|
||
:::
|
||
|
||
The code above also adjusts the time span in addition to the graviational constant. The algorithm for the exact formula is set to use the `dt` value used in the `euler` formula, for easier comparison. Otherwise, outside of the labels, the patterns are the same. Only those things that need changing are changed, the rest comes from defaults.
|
||
|
||
|
||
The above shows the benefits of using a common interface---new problems can be approached through only minor adjustments to the parameters, yet the calling pattern remains the same.
|
||
|
||
Next, the post illustrates how *other* authors *could* extend this code. The `solve` method dispatches on the problem type and the method type. Adding a new method to `solve` requires defining new method type and the algorithm for that type in the extension of `solve`.
|
||
|
||
For example, the following adds a sympletic method which conserves a quantity, allowing the approximate solutions to track long-term evolution without drift.
|
||
|
||
|
||
```{julia}
|
||
struct Symplectic2ndOrder{T}
|
||
dt::T
|
||
end
|
||
Symplectic2ndOrder(; dt=0.1) = Symplectic2ndOrder(dt)
|
||
|
||
function solve(prob::Problem, alg::Symplectic2ndOrder)
|
||
|
||
g, y0, v0, tspan = prob.g, prob.y0, prob.v0, prob.tspan
|
||
dt = alg.dt
|
||
t0, t1 = tspan
|
||
|
||
ts = range(t0, t1 + dt/2; step = dt)
|
||
n = length(ts)
|
||
|
||
ys = fill(y0, n)
|
||
vs = fill(v0, n)
|
||
|
||
for i in 1:n-1
|
||
vs[i+1] = vs[i] - g*dt
|
||
ys[i+1] = ys[i] + (vs[i] + vs[i+1])/2 * dt
|
||
end
|
||
|
||
Solution(ys, vs, ts, prob, alg)
|
||
end
|
||
```
|
||
|
||
Had the two prior methods been in a package, the other user could still extend the interface, as above, with just a slight standard modification.
|
||
|
||
The exact same approach to solving a problem works for this new type:
|
||
|
||
::: {#fig-projectile-motion-symplectic-2-order}
|
||
```{julia}
|
||
earth₃ = Problem()
|
||
sol_sympl₃ = solve(earth₃, Symplectic2ndOrder(dt = 2.0))
|
||
sol_exact₃ = solve(earth₃, ExactFormula())
|
||
|
||
plot(sol_sympl₃.t, sol_sympl₃.y;
|
||
label="2nd order symplectic (dt = $(sol_sympl₃.alg.dt))", linestyle=:auto)
|
||
plot!(sol_exact₃.t, sol_exact₃.y;
|
||
label="exact solution", linestyle=:auto)
|
||
|
||
title!("On the Earth"; xlabel="t", legend=:bottomleft)
|
||
```
|
||
|
||
Plot of exact solution and approximate solution using a $2$nd-order symplectic method
|
||
:::
|
||
|
||
Finally, the author of the post shows how the interface can compose with other packages in the `Julia` package ecosystem. This example uses the external package `MonteCarloMeasurements` which plots the behavior of the system for perturbations of the initial value, as seen in @fig-illustration-with-monte-carlo-measurements.
|
||
|
||
::: {#fig-illustration-with-monte-carlo-measurements}
|
||
```{julia}
|
||
using MonteCarloMeasurements # introduces ± operation
|
||
|
||
earth₄ = Problem(y0 = 0.0 ± 0.0, v0 = 30.0 ± 1.0)
|
||
sol_euler₄ = solve(earth₄)
|
||
sol_sympl₄ = solve(earth₄, Symplectic2ndOrder(dt = 2.0))
|
||
sol_exact₄ = solve(earth₄, ExactFormula())
|
||
|
||
|
||
P = plot(sol_euler₄.t, sol_euler₄.y;
|
||
label="Euler's method (dt = $(sol_euler₄.alg.dt))", linestyle=:auto)
|
||
|
||
Q = plot(sol_sympl₄.t, sol_sympl₄.y;
|
||
label="2nd order symplectic (dt = $(sol_sympl₄.alg.dt))", linestyle=:auto)
|
||
|
||
R = plot(sol_exact₄.t, sol_exact₄.y;
|
||
label="exact solution", linestyle=:auto)
|
||
|
||
title!.((P,Q,R), "On the Earth"; xlabel="t", legend=:bottomleft, ylims=(-100, 60))
|
||
|
||
plot(P, Q, R)
|
||
```
|
||
|
||
Figures showing solutions to a differential equation where the initial values have some specified uncertainty
|
||
:::
|
||
|
||
The only change was in the problem, `Problem(y0 = 0.0 ± 0.0, v0 = 30.0 ± 1.0)`, where a different number type is used which accounts for uncertainty. The rest follows the same pattern.
|
||
|
||
|
||
This example, shows the flexibility of the problem-algorithm-solve pattern while maintaining a consistent pattern for execution.
|