feat (notes): reorganise and update (and some formatting)

This commit is contained in:
Henri Saudubray 2025-05-01 12:05:09 +02:00
parent 3b5e01b163
commit dd6152833f
Signed by: hms
GPG key ID: 7065F57ED8856128
6 changed files with 211 additions and 209 deletions

View file

@ -13,17 +13,48 @@
= Notes on hybrid system simulation
== TODO
== Questions
Multiple different axes of research:
- abstract over the notion of internal state and of our ability to copy some or
part of it
- make free variables contained in solver closures explicit, and require the
ability to copy them for greedy simulation (without the solver itself knowing
about them?)
- think about the notion of input values with overlapping domains
- think about successive input values carrying information about possible
discontinuities
=== Internal state abstraction
In `LazySim(S).accelerate`, we need access to the type of the simulation's
internal state in order to use the functions provided by `S`. This requires
another definition of the discrete and continuous node types where the inner
state is visible. Is the existential type really needed in the original
definitions? What guarantees does it provide? Could we do with explicit type
parameters for the state? On the other hand, could we somehow implement
`LazySim(S).accelerate` without neeeding this access?
Should the simulation state abstraction be parameterized by a solver and model
state abstractions? What does it mean to have a functional simulation state when
the solver and/or model states are modified in place?
We provide a state copy function which is assumed to ensure that the closures
returned by the solver remain valid after another call to the solver (i.e., that
no underlying data structure has been modified). In particular, the state copy
function has no obligation to copy the entire state, only what is needed to
ensure the preservation of this property. Its signature `sstate -> sstate` does
not accurately reflect this. Should the state abstraction be decomposed into two
parts, one relevant for this problem, and which should be copied by the `copy`
function, and another, which does not matter?
=== Simulation semantics
What does providing an input whose starting date is not equal to the end date of
the previous input mean (and in particular, in the case where the domains
overlap: $"end"_((n-1)) > "start"_n$)?
We immediately reset the solvers when obtaining a new input. This is (most
likely) very inneficient, as adaptive solvers usually start with a very small
step size, and increase this step size as needed as they gain more knowledge of
the underlying function. Testing this, and thinking about how we might avoid
this automatic reset, would be beneficial.
All tests have, up to now, been made only with "stateless"
#smallcaps([Runge-Kutta]) solvers that do not really suffer loss of precision
from resets. Attempting to run some examples with solvers such as
#smallcaps([Sundials CVODE]) would be beneficial to measure the impact that
resets have on the accuracy of the results.
== The problem
@ -41,7 +72,7 @@ The main idea is as follows: a solver can be seen as a synchronous function (an
inner state, a step function, and a reset function).
```ocaml
type ('p, 'a, 'b) dnode = DNode :
{ s: 's; step: 's -> 'a -> 'b * 's; reset : 'p -> 's -> 's } ->
{ state: 's; step: 's -> 'a -> 'b * 's; reset : 'p -> 's -> 's } ->
('p, 'a, 'b) dnode
```
An ODE solver is, in fact, a special case of a discrete node, which is
@ -55,7 +86,7 @@ Similarly, a zero-crossing solver is initialized with a zero-crossing problem,
takes as input a horizon and dense function, and returns an actual time reached
and optional zero-crossing.
```ocaml
type ('y, 'zout) zc = { fzer : time -> 'y -> 'zout; yc : 'y }
type ('y, 'zout) zc = { fzer : time -> 'y -> 'zout; yc : 'y; size: int }
type ('y, 'zin, 'zout) zsolver =
(('y, 'zout) zc, time * (time -> 'y), time * 'zin option) dnode
```
@ -81,70 +112,6 @@ type ('p, 'a, 'b) sim = ('p, 'a signal, 'b signal) dnode
== The simulation
=== Modes and output format
There are two simulation modes, depending on whether the solver provides a way
to copy its internal state as part of its interface, or more precisely, whether
the dense solution returned by the solver's step function remains valid after we
modify the solver's state (through another integration step or a full reset).
In *_lazy_* mode, the output has format ```ocaml 'b signal = 'b value option```.
The input stream first provides a value for the simulation to run, and must then
provide as many `None` values as needed for the solver to finish integrating as
much as possible (that is, until it reaches a zero-crossing or the horizon
provided by the current input value). This mode is the one used when the solver
modifies its solution in place (for example, #smallcaps([Sundials])). Each step
of the simulation runs _one_ step of the solver, and then runs all subsystems as
much as they need to in order for them to finish using the output of the solver.
The solver output is then not needed anymore, and we can call the solver again
to progress with the simulation.
In *_greedy_* mode, the output has format ```ocaml 'b value list```. The input
stream provides a value for the simulation to run, and the simulation then calls
the solver as much as it needs to until it reaches the end of the possible
integration period (zero-crossing or horizon). We concatenate the list of
results (all functions returned from the solver steps are continuous at their
edges and defined on more than a single instant, and we can thus create a large,
piecewise-defined function from all the results up to this point). The solver
then performs as many discrete steps as needed (i.e. we cascade). These are kept
in the output list _as is_. We cannot concatenate a discrete step (i.e. a
function defined on a singleton interval $[t,t]$) with anything else, as it may
be hidden by the (inclusive) border of the other function. For instance,
#align(center, ```ocaml
f = concat { start = 0.0; length = 1.0; u = fun t -> t }
{ start = 1.0; length = 0.0; u = fun t -> 2.0 }
```)
#columns(2, [#{
align(center, cetz.canvas({
import cetz-plot.plot: *
plot(size: (1, 0.5), axis-style: "left", x-tick-step: 1, x-label: "time",
y-tick-step: 1, y-label: none, {
add(((0,0),(1,1)))
add(((1,2),), mark: "o", mark-size: .03)
})
}))
colbreak()
$ f(t) = cases(
t & "if" 0.0 <= t <= 1.0,
2.0 & "if" t = 1.0 + partial,
bot & "otherwise",
) $
}])
Here, what is `f.u 1.0`? We cannot differentiate between the multiple
infinitesimal steps at a given real time `t` using only floating-point numbers
as input. Therefore, we keep the list of inputs separated, apart from the
adjacent continuous functions, which we can concatenate together. Until we reach
the horizon given by the input function, we keep alternating continuous and
discrete steps. The output is a list of functions, which we can interpret as a
stream, and the subsystems (the assertions, for instance) can then be called
repeatedly until they reach the same horizon as the parent system. This has the
advantage of not needing/having optional values in the input and output streams,
and the calling system does not need to guess how many steps the simulation will
need to reach the requested horizon.
=== Steps
A simulation step is either a discrete step or a continuous step.
@ -239,6 +206,118 @@ interval `[now, now + h]`, and end in one of three possible cases:
})
})])
=== Modes and output format
There are two simulation modes, depending on whether the solver provides a way
to copy its internal state as part of its interface, or more precisely, whether
the dense solution returned by the solver's step function remains valid after we
modify the solver's state (through another integration step or a full reset).
Both modes output values representing functions defined on real intervals
```ocaml
type continuity = Continuous | Discontinuous
type 'a value = { start: time; length: time; u: time -> 'a; cont: continuity }
type 'a signal = 'a value option
```
where `u` is defined on the interval `[start, start + length]` and `cont`
represents the relationship of a value with the next one in the stream: if
`cont = Continuous`, the next value in the stream simply extends the current
one; if `cont = Discontinuous`, the next value does not extend the current one,
either due to a jump, a discrete step or the horizon being reached.
In *_lazy_* mode, the output has format ```ocaml 'b signal = 'b value option```.
The input stream first provides a value for the simulation to run, and must then
provide as many `None` values as needed for the solver to finish integrating as
much as possible (that is, until it reaches a zero-crossing or the horizon
provided by the current input value). This mode is the one used when the solver
modifies its solution in place (for example, #smallcaps([Sundials CVODE])). Each
step of the simulation runs _one_ step of the solver, and then runs all
subsystems as much as they need to in order for them to finish using the output
of the solver. The solver output is then not needed anymore, and we can call the
solver again to progress with the simulation.
We can also optionally "accelerate" the simulation if given a solver supporting
state copies. That is, we can keep calling the solver as long as we keep making
continuous steps. When we reach a zero-crossing or the horizon, we stop calling
the solver and return the concatenation of all the intermediate functions. The
caller can then keep making simulation steps, which will run through all the
discrete steps, until we reach another continuous step sequence, where the next
simulation step will again run through as many continuous steps as it can, and
so on and so forth.
#columns(3, {
import cetz: *
import cetz-plot.plot: *
align(right, canvas({
plot(
size: (2, 1), axis-style: "left", x-tick-step: none, x-label: none,
y-tick-step: none, y-label: none, x-ticks: ((1,none),(2, none)),
x-grid: "both", {
add(((0,2),(1,3)))
add(((1,3),(2,4)))
add(((2,3.5),), mark: "o", mark-size: .03)
add(((2,3),(3,3)))
})
}))
colbreak(); linebreak()
align(center, $ ="accelerate"=> $)
colbreak(); align(left, canvas({
plot(
size: (2, 1), axis-style: "left", x-tick-step: none, x-label: none,
y-tick-step: none, y-label: none, x-ticks: ((2, none),),
x-grid: "both", {
add(((0,2),(2,4))); add(((0,2),))
add(((2,3.5),), mark: "o", mark-size: .03)
add(((2,3),(3,3)))
})
}))
})
In *_greedy_* mode, the output has format ```ocaml 'b value list```. The input
stream provides a value for the simulation to run, and the simulation then calls
the solver as much as it needs to until it reaches the end of the possible
integration period (zero-crossing or horizon). We concatenate the list of
results (all functions returned from the solver steps are continuous at their
edges and defined on more than a single instant, and we can thus create a large,
piecewise-defined function from all the results up to this point). The solver
then performs as many discrete steps as needed (i.e. we cascade). These are kept
in the output list _as is_. We cannot concatenate a discrete step (i.e. a
function defined on a singleton interval $[t,t]$) with anything else, as it may
be hidden by the (inclusive) border of the other function. For instance,
#align(center, ```ocaml
f = concat { start=0.0; length=1.0; u=fun t -> t }
{ start=1.0; length=0.0; u=fun t -> 2.0 }
```)
#columns(2, [#{
align(center, cetz.canvas({
import cetz-plot.plot: *
plot(size: (1, 0.5), axis-style: "left", x-tick-step: 1, x-label: "time",
y-tick-step: 1, y-label: none, {
add(((0,0),(1,1)))
add(((1,2),), mark: "o", mark-size: .03)
})
}))
colbreak()
$ f(t) = cases(
t & "if" t in [0, 1],
2 & "if" t = 1 + partial,
bot & "otherwise",
) $
}])
Here, what is `f.u 1.0`? We cannot differentiate between the multiple
infinitesimal steps at a given real time `t` using only floating-point numbers
as input. Therefore, we keep the list of inputs separated, apart from the
adjacent continuous functions, which we can concatenate together. Until we reach
the horizon given by the input function, we keep alternating continuous and
discrete steps. The output is a list of functions, which we can interpret as a
stream, and the subsystems (the assertions, for instance) can then be called
repeatedly until they reach the same horizon as the parent system. This has the
advantage of not needing/having optional values in the input and output streams,
and the calling system does not need to guess how many steps the simulation will
need to reach the requested horizon.
=== Resets
==== Solver reset
@ -254,6 +333,37 @@ although this is less satisfactory.
If a discrete step results in a state jump, we should reset the solver before
continuing the integration.
===== Continuity
When defined in terms of limits, the function $f$ is _continuous at some point_
$c$ of its domain if $ lim_(x -> c) f(x) = f(c) $
The Weierstrass and Jordan definition of continuous function is as follows:
given a function $f : D -> bb(R)$ and an element $x_0 in D$, $f$ is said to be
continuous at the point $x_0$ when the following holds:
$ forall epsilon in bb(R)^star_+. exists delta in bb(R)^star_+. forall x in D.
x_0 - delta < x < x_0 + delta ==> f(x_0) - epsilon < f(x) < f(x_0) + epsilon $
Every differentiable function $f : (a, b) -> bb(R)$ is continuous. The
derivative $f'(x)$ of a differentiable function $f(x)$ need not be continuous.
If $f'(x)$ is continuous, $f(x)$ is said to be _continuously differentiable_.
The set of such functions is denoted $C^1((a,b))$. More generally, the set of
functions $f : Omega -> bb(R)$ (from an open interval $Omega$ to the reals) such
that $f$ is $n$ times differentiable and such that the $n$-th derivative of $f$
is continuous is denoted $C^n (Omega)$.
Every continuous function $f : [a,b] -> bb(R)$ is integrable.
===== Differentiability classes
Consider an open set $U$ on the real line and a function $f$ defined on $U$ with
real values. Let $k$ be a non-negative integer. The function $f$ is said to be
of differentiability class $C^k$ if the derivatives $f', f'', ..., f^((k))$
exist and are continuous on $U$. If $f$ is $k$-differentiable on $U$, then it is
at least in the class $C^(k-1)$ since $f', f'', ..., f^((k-1))$ are continuous
on $U$. The function $f$ is said to be *infinitely differentiable*, *smooth*, or
of *class* $C^infinity$, if it has derivatives of all orders on $U$.
==== Simulation reset
Two possible options for the simulation reset:
@ -300,113 +410,6 @@ Two possible options for the simulation reset:
makes this impossible. We thus need reset parameters for both the model and
solver.
=== Steps
The _lazy simulation_'s step function is as follows:
```ocaml
let step s i =
let ms, ss = S.get_mstate s, S.get_sstate s in
match i, S.is_running s with
| Some i, _ ->
let mode, now, stop = Discrete, 0.0, i.length in
None, S.set_running ~mode ~input:i ~now ~stop s
| None, false -> None, s
| None, true ->
let i, now, stop = S.get_input s, S.get_now s, S.get_stop s in
match S.get_mode s with
| Discrete ->
let o, ms = model.step ms (i.u now) in
let s =
let h = model.horizon ms in
if h <= 0.0 then S.set_mstate ms s
else if now >= stop then S.set_idle s
else if model.jump ms then
let init = model.cget ms in
let fder t = model.fder ms (Utils.offset i now t) in
let fzer t = model.fzer ms (Utils.offset i now t) in
let ivp = { fder; stop = stop -. now; init } in
let zc = { init ; fzer; size = model.zsize } in
let ss = solver.reset (ivp, zc) ss in
let i = { start=i.start +. now; length=i.length -. now;
u=Utils.offset i now } in
let mode, stop, now = Continuous, i.length, 0.0 in
S.update ms ss (S.set_running ~mode ~input:i ~stop ~now s)
else S.set_running ~mode:Continuous s in
Some { start = i.start+. now; length = 0.0; u = fun _ -> o }, s
| Continuous ->
let (h, f, z), ss = solver.step ss stop in
let ms = model.cset ms (f h) in
let start = i.start +. now in
let fout t = model.fout ms (i.u (now +. t)) (f (now +. t)) in
let out = { start; length=h -. now; u=fout } in
let s = match z with
| None ->
let s = if h >= stop
then S.set_running ~mode:Discrete ~now:h s
else S.set_running ~now:h s in
S.update ms ss s
| Some z ->
let s = S.set_running ~mode:Discrete ~now:h s in
S.update (model.zset ms z) ss s in
Some out, s in
```
The _greedy simulation_'s step function is as follows:
```ocaml
let rec step s i =
let ms, ss = S.get_mstate s, S.get_sstate s in
if not (S.is_running s) then
let mode, now, stop = Discrete, 0.0, i.length in
step (S.set_running ~mode ~input:i ~now ~stop s) i
else let now, stop = S.get_now s, S.get_stop s in
match S.get_mode s with
| Discrete ->
let o, ms = model.step ms (i.u now) in
let h = model.horizon ms in
let rest, s =
if h <= 0.0 then step (S.set_mstate ms s) i
else if now >= stop then [], s
else if model.jump ms then
let init = model.cget ms in
let fder t = model.fder ms (Utils.offset i now t) in
let fzer t = model.fzer ms (Utils.offset i now t) in
let ivp = { fder; stop = stop -. now; init } in
let zc = { init; fzer; size = model.zsize } in
let ss = solver.reset (ivp, zc) ss in
let i = { start=i.start +. now; length=i.length -. now;
u=Utils.offset i now } in
let mode, stop, now = Continuous, i.length, 0.0 in
let s = S.set_running ~mode ~input:i ~stop ~now s in
step (S.update ms ss s) i
else step (S.set_running ~mode:Continuous s) i in
{ start = i.start +. now; length = 0.0; u = fun _ -> o }::rest, s
| Continuous ->
let (h, f, z), ss = solver.step ss stop in
let ss = solver.copy ss in
let ms = model.cset ms (f h) in
let fout t = model.fout ms (i.u (now +. t)) (f (now +. t)) in
let out = { start = i.start +. now; length = h -. now; u = fout } in
match z with
| None ->
if h >= stop then
let s = S.set_running ~mode:Discrete ~now:h s in
let rest, s = step (S.update ms ss s) i in
out::rest, s
else
let s = S.set_running ~now:h s in
let rest, s = step (S.update ms ss s) i in
(match rest with
| [] -> [out], s
| f::rest -> Utils.compose [out;f] :: rest, s)
| Some z ->
let s = S.set_running ~mode:Discrete ~now:h s in
let ms = model.zset ms z in
let rest, s = step (S.update ms ss s) i in
out::rest, s in
```
== Mathematical model
#link("https://zelus.di.ens.fr/cc2015/fullpaper.pdf")[[CC'15]] defines the

View file

@ -49,8 +49,7 @@ let sqrt () =
| Bad ->
let o = 42.0 in
let state = { state with s_encore=false;
s_pos =
of_array [| s_pos.{0}; 0.0 |] } in
s_pos=of_array [| s_pos.{0}; 0.0 |] } in
of_array [| o; state.s_pos.{0}; state.s_pos.{1} |], state in
let cget { s_pos; _ } = s_pos in
let cset s l_x = { s with s_pos=l_x } in