hsim/doc/notes.typ

497 lines
23 KiB
Typst

#import "@preview/cetz:0.3.2"
#import "@preview/cetz-plot:0.1.1"
#import "@preview/finite:0.4.1"
#set page(paper: "a4")
#set par(justify: true)
#let breakl(b) = {
raw(b.text.replace(regex(" *\\\\\n *"), " "), lang: b.lang, block: b.block)
}
#show raw: set text(font: "CaskaydiaCove NF")
#show link: underline
= Notes on hybrid system simulation
== Questions
=== 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
The overarching problem is _transparent assertions_. We want to simulate
assertions with their own solvers, because simulating assertions with the same
solver as their parent system changes the final results, when assertions are
supposed to be transparent.
Assertions are themselves hybrid systems, and can contain their own assertions.
We thus need a semantics that is both *independent* of the solver, which becomes
a parameter, and *recursive*, in that the simulation of an assertion is itself
the simulation of a hybrid system with assertions.
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 :
{ 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
initialized with an initial value problem, takes as input a time horizon to
reach, and returns as output an actual time reached and a dense solution.
```ocaml
type ('y, 'yder) ivp = { init : 'y; fder : time -> 'y -> 'yder; stop : time }
type ('y, 'yder) csolver = (('y, 'yder) ivp, time, time * (time -> 'y)) dnode
```
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; size: int }
type ('y, 'zin, 'zout) zsolver =
(('y, 'zout) zc, time * (time -> 'y), time * 'zin option) dnode
```
A complete solver is then a composition of these two: it is initialized with
both an `ivp` and `zc`, takes in a horizon, and returns an actual time reached,
dense solution and optional zero-crossing.
#breakl(```ocaml
type ('y, 'yder, 'zin, 'zout) solver =
(('y, 'yder) ivp * ('y, 'zout) zc, time, time * (time -> 'y) * 'zin option) \
dnode
```)
The construction of a full solver from an ODE and a zero-crossing solver is
available in the file `src/lib/hsim/solver.ml`.
The simulation of a hybrid system with a solver can itself be seen as a discrete
node, operating on streams of functions defined on successive intervals.
```ocaml
type ('p, 'a, 'b) sim = ('p, 'a signal, 'b signal) dnode
```
== The simulation
=== Steps
A simulation step is either a discrete step or a continuous step.
Discrete steps call the `model.step` function, build a constant output function
defined on a singleton interval `[now, now]`, and end in one of four possible
cases:
- We need to cascade (perform another discrete step)
- We reached the horizon (reset the solver? idle mode?)
- We can still integrate, but there has been a state jump (reset solver)
- We can still integrate, and there has been no state jump (perform a continuous
step)
#columns(4, [#align(center, {
import cetz: *
import cetz-plot.plot: *
let p = palette.new(colors: (blue,blue, blue)).with(stroke: true, fill: true)
canvas({
plot(
size: (2,2), axis-style: "left", x-tick-step: none, x-label: none,
x-ticks: ((1,none),), x-grid: "both", y-tick-step: none, y-label: none,
y-min: 1, y-ticks: ((4, none),), y-grid: "both",
style: p, {
add(((0,2),(.3,2.1),(.7,2.9),(1,3)), line: "spline")
add(((1,4),), mark: "o", mark-size: .03)
add(((1,5),(1.3,4.7),(1.7,3.2),(2,3)), line: "spline")
})
})
colbreak(); canvas({
plot(
size: (2,2), axis-style: "left", x-tick-step: none, x-label: none,
x-ticks: ((3,none),), x-grid: "both", y-tick-step: none, y-label: none,
y-min: 1, y-ticks: ((3, none),), y-grid: "both", {
add(((0,2),(.5,3.2),(1,5.5),(2,4),(3,3)), line: "spline")
})
})
colbreak(); canvas({
plot(
size: (2,2), axis-style: "left", x-tick-step: none, x-label: none,
x-ticks: ((1, none),), x-grid: "both", y-tick-step: none, y-label: none,
y-min: 1, y-ticks: ((5, none),), y-grid: "both", {
add(((0,2),(0.5,2.3),(1,3)), line: "spline")
add(((1,5),(1.5,4.7),(2,4)), line: "spline")
})
})
colbreak(); canvas({
plot(
size: (2,2), axis-style: "left", x-tick-step: none, x-label: none,
x-ticks: ((2,none),), x-grid: "both", y-tick-step: none, y-label: none,
y-min: 1, y-ticks: ((4, none),), y-grid: "both", {
add(((0,2),(0.5,2.3),(1,3),(1.3,5),(2,4),(3,3)), line: "spline")
})
})
})])
Continuous steps call the `solver.step` function, parameterized by `model.fder`
and `model.fzer`, build an output function using `model.fout` defined on the
interval `[now, now + h]`, and end in one of three possible cases:
- We found a zero-crossing (perform a discrete step)
- We found no zero-crossing, but have reached the horizon (perform a discrete
step)
- We found no zero-crossing and have not reached the horizon (perform another
continuous step)
#columns(3, [#align(center, {
import cetz: *
import cetz-plot.plot: *
let p = palette.new(colors: (blue,blue, blue)).with(stroke: true, fill: true)
canvas({
plot(
size: (2,2), axis-style: "left", x-tick-step: none, x-label: none,
x-ticks: ((.7,none),), x-grid: "both", y-tick-step: none, y-label: none,
y-min: -2, y-ticks: ((0,$0$),), y-grid: "both", {
add(((0,-1),(.5,-.6),(.7,0),(1,2.5),(2,1),(3,.5)), line: "spline")
})
})
colbreak(); canvas({
plot(
size: (2,2), axis-style: "left", x-tick-step: none, x-label: none,
x-ticks: ((3,none),), x-grid: "both", y-tick-step: none, y-label: none,
y-min: 1, y-ticks: ((3, none),), y-grid: "both", {
add(((0,2),(.5,3.2),(1,5.5),(2,4),(3,3)), line: "spline")
})
})
colbreak(); canvas({
plot(
size: (2,2), axis-style: "left", x-tick-step: none, x-label: none,
x-ticks: ((2,none),), x-grid: "both", y-tick-step: none, y-label: none,
y-min: 1, y-ticks: ((4, none),), y-grid: "both", {
add(((0,2),(0.5,2.3),(1,3),(1.3,5),(2,4),(3,3)), line: "spline")
})
})
})])
=== 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
When do we need to reset the solver itself?
If we receive a new input that is not continuous w.r.t. the previous input, we
should reset the solver before starting the integration. This implies that an
input value is tagged with information relating it to the previous input value.
If this is not the case, we can reset whenever we receive a new input value,
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:
- ask for reset parameters for both the model and solver:
```ocaml
'p * (('y, 'yder) ivp * ('y, 'zin, 'zout) zc
```
- ask for only what is needed for the reset of the model, then build a new
initial value problem and zero-crossing problem using the new model state and
reset the solver using them. We can build `fder` and `fzer` quite easily. We
still need to build `init`, `yc` and `time`.
`init` and `yc` need to use `model.cget` in order to obtain a continuous state
element. However, the usual simulation process first starts by executing a
discrete step to initialize the model state. Does `model.cget` make sense
before this first step has been executed?
#figure(finite.automaton(
(D: (D: "cascade", C: "no cascade"),
C: (C: "no zero-crossing", D: "zero-crossing")),
initial: "D", final: (), layout: finite.layout.linear.with(spacing: 3)
)) <automaton>
Tagged values that indicate whether they are discontinuous w.r.t. the previous
input value do not help us here. Indeed, we could consider that the first
value in the stream is always considered discontinuous, which would force a
reset as we receive an input value. However, the initialization of the solver
still depends on the initialization of the model, which (might) need a
discrete step, and we are back to square one.
Additionally, what stop time do we use for the initial state?
If possible, this would have the advantage of making the reset function only
dependent on the model, since the solver is updated to match the model's
simulation requirements.
```ocaml
val sim : ('p, 'a, 'b, 'y, 'yder, 'zin, 'zout) hnode ->
('y, 'yder, 'zin, 'zout) solver ->
('p, 'a, 'b) sim
```
Unfortunately, this does not work : the fder and fzer functions are of type
`fder : 'a -> 'y -> 'yder` and `fzer : 'a -> 'y -> 'zout`, and we thus need a
way to obtain a value of type `'a`. This is usually done through
`input.u : time -> 'a`, but we have no input available during the reset, which
makes this impossible. We thus need reset parameters for both the model and
solver.
== Mathematical model
#link("https://zelus.di.ens.fr/cc2015/fullpaper.pdf")[[CC'15]] defines the
simulation loop as follows:
#block(fill: rgb(230, 230, 230), stroke: black, inset: 5pt, [
The simulation loop of a hybrid system is a _synchronous function_ defining
four streams $t(n)$, $l x(n)$, $y(n)$ and $z(n)$ with $n in bb(N)$.
$t(n) in bb(R)$ is the increasing sequence of instants at which the solver
stops. $l x(n)$ is the value at time $t(n)$ of the _continuous state
variables_, that is, of all variables defined by their derivatives in the
original model. $y(n)$ is the value at time $t(n)$ of the _discrete state_.
$z(n)$ indicates any _zero-crossings_ at instant $t(n)$ on signals monitored
by the solver, that is, any signals that become equal to or pass through zero.
The synchronous function has two modes: the discrete mode ($D$) contains all
computations that may change the discrete state or that have side effects. The
continuous mode ($C$) is where ODEs are solved. The two modes alternate
according to the execution scheme summarized in @automaton.
In the _continuous mode_, the solver computes an approximation of the solution
of the ODEs and monitors a set of expressions for zero-crossings. The solver
is abstracted by a function $s o l v e(f)(g)$ parameterized by $f$ and $g$
where:
- $x'(tau)=f(y(n),tau,x(tau))$ defines the derivatives of continuous state
variables $x$ at instant $tau in bb(R)$;
- $u p z(tau)=g(y(n),tau,x(tau))$ defines the current values of a set of
zero-crossing signals $u p z$, indexed by $i in {1, ..., k}$.
The continuous mode $C$ computes four sequences $s$, $l x$, $z$ and $t$ such
that $ (l x, z, t, s)(n+1) = s o l v e(f)(g)(s,y,l x,t,s t e p)(n) $ where
- $s(n)$ is the internal state of the solver at instant $t(n) in bb(R)$.
Calling $s o l v e(f)(g)$ updates the state to $s(n+1)$.
- $x$ is an approximation of a solution of the ODE
$ x'(tau)=f(y(n),tau,x(tau)) $
It is parameterized by the current discrete state $y(n)$ and initialized at
instant $t(n)$ with the value of $l x(n)$, that is, $x(t(n)) = l x(n)$.
- $l x(n+1)$ is bounded by the horizon $t(n) + s t e p(n)$ that the solver has
been asked to reach, that is
$ t(n) <= t(n+1) <= t(n) + s t e p(n) $
- $z(n+1)$ signals any zero-crossings detected at time $t(n+1)$. An event
occurs with a transition to the discrete mode $D$ when horizon
$t(n) + s t e p(n)$ is reached, or when at least one of the zero-crossing
signals $u p z(i)$, for $i in {1, ..., k}$ crosses zero, which is indicated
by a true value for the corresponding boolean output $z(n+1)(i)$. $
e v e n t = z(n+1)(0) or ... or z(n+1)(k) or (t(n+1) = t(n) + s t e p(n)) $
If the solver raises an error (for example, a division by zero or an inability
to find a solution), we consider that the simulation fails.
All discrete changes occur in the _discrete mode_ $D$. It is entered when an
event is raised during integration. During a discrete phase, the function
$n e x t$ defines $y$, $l x$, $s t e p$, $e n c o r e$, $z$ and $t$:
$ (y, l x, s t e p, e n c o r e)(n+1) & = n e x t(y, l x, z, t)(n) \
z(n+1) & = f a l s e \
t(n+1) & = t(n) $
where
- $y(n+1)$ is the new discrete state; outside of mode $D$, $y(n+1) = y(n)$.
- $l x(n+1)$ is the new continuous state, which may be changed directly in the
discrete mode.
- $s t e p(n+1)$ is the new step size.
- $e n c o r e(n+1)$ is true if an additional discrete step must be performed.
Function $n e x t$ can decide to trigger instantaneously another discrete
event, causing an _event cascade_.
- $t(n)$ (the simulation time) is unchanged during discrete phases.
The initial values for $y(0)$, $l x(0)$ and $s(0)$ are given by an
initialization function $i n i t$. Finally, $s o l v e(f)(g)$ may decide to
reset its internal state if the continuous state changes. If
$i n i t \_ s o l v e$ initializes the solver state, we have
$ r e i n i t & = (l x(n+1) != l x(n)) \
s(n+1) & = "if" r e i n i t "then" i n i t \_ s o l v e(l x(n+1),s(n))
"else" s(n) $
Taken together, the definitions from both modes give a synchronous
interpretation of the simulation loop as a stream function that computes the
sequences $l x$, $y$ and $t$ at instant $n+1$ according to their values at
instant $n$ and an internal state. Writing $s o l v e(f)(g)$ abstracts from
the actual choice of integration method and zero-crossing detection algorithm.
A more detailed description of $s o l v e(f)(g)$ would be possible (for
example, an automaton with two states: one that integrates, and one that
detects zero-crossings) but with no influence on the code generation problem
which must be independent of such simulation details.
Given a program written in a high-level language, we must produce the
functions $i n i t$, $f$, $g$, and $n e x t$. In practice, they are
implemented in an imperative language like C. Code generation for hybrid
models has much in common with code generation for synchronous languages.
])