defmodule Integrator do
@moduledoc """
A library for solving non-stiff ordinary differential equations (ODEs).
`Integrator` uses either the Dormand-Prince 4/5 Runge Kutta algorithm, or the Bogacki-Shampine 2/3
Runge Kutta algorithm. It is intended that the user only needs to call `Integrator.integrate/4`, and
the `Integrator.AdaptiveStepsize` and `Integrator.RungeKutta` modules are only exposed for advanced
users who want to use the underlying algorithms directly.
"""
alias Integrator.AdaptiveStepsize
alias Integrator.RungeKutta
alias Integrator.RungeKutta.{BogackiShampine23, DormandPrince45}
@integrator_options %{
ode45: DormandPrince45,
ode23: BogackiShampine23
}
@default_refine_opts %{
ode45: 4,
ode23: 1
}
options = [
initial_step: [
doc: """
The initial stepsize. If not provided, a stepsize will be chosen automatically. Can be a float
or a Nx tensor.
""",
type: {:or, [:float, :any]}
],
integrator: [
doc: """
The integrator to use. Currently only :ode45 and :ode23 are supported, which correspond to
`Integrator.RungeKutta.DormandPrince45` and `Integrator.RungeKutta.BogackiShampine23`, respectively.
""",
type: {:in, [:ode45, :ode23]},
default: :ode45
]
]
@options_schema_integrator_only NimbleOptions.new!(options)
def options_schema_integrator_only, do: @options_schema_integrator_only
@options_schema NimbleOptions.new!(AdaptiveStepsize.options_schema().schema |> Keyword.merge(options))
@doc """
Integrates an ODE function using either the Dormand-Prince45 method or the Bogacki-Shampine23 method.
## Options
#{NimbleOptions.docs(@options_schema_integrator_only)}
### Additional Options
Also see the options for these functions which are passed through:
* `Integrator.NonLinearEqnRoot.find_zero/4`
* `Integrator.AdaptiveStepsize.integrate/10`
"""
@spec integrate(
ode_fn :: RungeKutta.ode_fn_t(),
t_start_t_end :: Nx.t() | [float() | Nx.t()],
x0 :: Nx.t(),
opts :: Keyword.t()
) :: AdaptiveStepsize.t()
def integrate(ode_fn, t_start_t_end, x0, opts \\ []) do
opts =
opts
|> NimbleOptions.validate!(@options_schema)
|> Keyword.put_new_lazy(:type, fn -> Nx.type(x0) |> Nx.Type.to_string() |> String.to_atom() end)
|> AdaptiveStepsize.abs_rel_norm_opts()
opts = opts |> Keyword.put_new_lazy(:refine, fn -> Map.get(@default_refine_opts, opts[:integrator]) end)
integrator_mod =
Map.get_lazy(@integrator_options, opts[:integrator], fn ->
raise "Currently only DormandPrince45 (ode45) and BogackiShampine23 (ode23) are supported"
end)
order = integrator_mod.order()
{t_start, t_end, fixed_times} = parse_start_end(t_start_t_end)
initial_step =
Keyword.get_lazy(opts, :initial_step, fn ->
AdaptiveStepsize.starting_stepsize(order, ode_fn, t_start, x0, opts[:abs_tol], opts[:rel_tol], opts)
end)
AdaptiveStepsize.integrate(
&integrator_mod.integrate/6,
&integrator_mod.interpolate/4,
ode_fn,
t_start,
t_end,
fixed_times,
initial_step,
x0,
order,
only_adaptive_stepsize_opts(opts)
)
end
@spec only_adaptive_stepsize_opts(Keyword.t()) :: Keyword.t()
defp only_adaptive_stepsize_opts(opts) do
adaptive_stepsize_opts_keys = AdaptiveStepsize.option_keys()
opts |> Keyword.filter(fn {key, _value} -> key in adaptive_stepsize_opts_keys end)
end
@spec parse_start_end([float() | Nx.t()] | Nx.t()) :: {Nx.t(), Nx.t(), [Nx.t()] | nil}
defp parse_start_end([t_start, t_end]), do: {t_start, t_end, nil}
defp parse_start_end(t_range) do
t_start = t_range[0]
{length} = Nx.shape(t_range)
t_end = t_range[length - 1]
# Figure out the correct way to do this; there's got to be a better way!
fixed_times =
0..(length - 1)
|> Enum.reduce([], fn i, acc ->
[t_range[i] | acc]
end)
|> Enum.reverse()
{t_start, t_end, fixed_times}
end
end