1457 lines
69 KiB
Typst
1457 lines
69 KiB
Typst
#import "@preview/cetz:0.4.0"
|
|
#import "@preview/cetz-plot:0.1.2"
|
|
#import "@preview/finite:0.4.1"
|
|
|
|
#set text(size: 11pt)
|
|
#set page(paper: "a4", numbering: "1")
|
|
|
|
#set cite(style: "alphanumeric.csl")
|
|
|
|
#set par(justify: true)
|
|
#set par.line(numbering: "1")
|
|
|
|
#set raw(syntaxes: "zelus.sublime-syntax")
|
|
#show raw: set par.line(numbering: none)
|
|
|
|
#set heading(numbering: "1.1")
|
|
#show heading: set par.line(numbering: none)
|
|
|
|
#set figure(placement: top)
|
|
#show figure: set par.line(numbering: none)
|
|
|
|
#let zelus = smallcaps[Zélus]
|
|
#let lustre = smallcaps[Lustre]
|
|
#let ocaml = smallcaps[OCaml]
|
|
#let sundials = smallcaps[Sundials CVODE]
|
|
#let simulink = smallcaps[Simulink]
|
|
#let haskell = smallcaps[Haskell]
|
|
|
|
#let zel(body) = raw(lang: "zelus", body)
|
|
|
|
#let Time = math.italic[Time]
|
|
#let stop = math.italic[stop]
|
|
#let DNode = math.italic[DNode]
|
|
#let DNodeC = math.italic[DNodeC]
|
|
#let CNode = math.italic[CNode]
|
|
#let HNode = math.italic[HNode]
|
|
#let ANode = math.italic[ANode]
|
|
#let ASNode = math.italic[ASNode]
|
|
#let Dense = math.italic[Dense]
|
|
#let IVP = math.italic[IVP]
|
|
#let ZCP = math.italic[ZCP]
|
|
#let Stream = math.italic[Stream]
|
|
#let Signal = math.italic[Signal]
|
|
#let Solver = math.italic[Solver]
|
|
#let CSolver = math.italic[CSolver]
|
|
#let ZSolver = math.italic[ZSolver]
|
|
#let Option = math.italic[Option]
|
|
#let None = math.italic[None]
|
|
#let Some = math.italic[Some]
|
|
#let Unit = math.italic[Unit]
|
|
#let List = math.italic[List]
|
|
#let Zi = $Z_i$
|
|
#let Zo = $Z_o$
|
|
#let SimState = math.italic[State]
|
|
#let DSim = math.italic[DSim]
|
|
#let CSim = math.italic[CSim]
|
|
#let HSim = math.italic[HSim]
|
|
#let HASim = math.italic[HASim]
|
|
|
|
#let show_notes = true
|
|
#let note(body, prefix: "NOTE: ", color: rgb(0, 0, 0, 50)) = if show_notes {
|
|
rect(fill: color, inset: 3pt, width: 100%)[#prefix #body]
|
|
} else []
|
|
#let todo(body) = note(color: rgb(255, 0, 0, 50), prefix: "TODO: ")[#body]
|
|
|
|
#set par.line(numbering: none)
|
|
#align(center)[
|
|
#text(16pt)[
|
|
*Internship Report --- MPRI M2 \
|
|
Hybrid System Models with Transparent Assertions*
|
|
]
|
|
|
|
Henri Saudubray, supervised by Marc Pouzet, Inria PARKAS
|
|
]\
|
|
#set par.line(numbering: "1")
|
|
|
|
// #heading(outlined: false, numbering: none)[General Context]
|
|
// What is the report about? Where does the problem come from? What is the state
|
|
// of the art in that area?
|
|
|
|
*General Context* --- Hybrid systems modelers such as #simulink #footnote[
|
|
#link("https://www.mathworks.com/products/simulink")
|
|
] are essential tools in the development of embedded systems which evolve in
|
|
physical environments. They allow for precise descriptions of hybrid systems
|
|
through both continuous-time behaviour defined with Ordinary Differential
|
|
Equations (ODEs) and discrete-time reactive behaviour similar to what is found
|
|
in the synchronous languages such as #lustre @cit:lustre. The #zelus language
|
|
@cit:zelus_sync_lng_with_ode aims to reconcile synchronous languages with hybrid
|
|
systems, by taking a synchronous language kernel and extending it with
|
|
continuous-time constructs similar to what is found in tools like #simulink.
|
|
Continuous-time behaviour is computed through the use of an off-the-shelf ODE
|
|
solver such as #sundials @cit:sundials @cit:sundialsml.
|
|
|
|
// #heading(outlined: false, numbering: none)[Research Problem]
|
|
// What specific question(s) have you studied? What motivates the question? What
|
|
// are the applications/consequences? Is it a new problem? Why did you choose
|
|
// this problem?
|
|
|
|
*Research Problem* --- The simulation of hybrid system models, as in #simulink
|
|
and #zelus, uses a single ODE solver instance to simulate the entire model. This
|
|
has various advantages: it is less computationally intensive, and simplifies the
|
|
work of the compiler. Unfortunately, it also raises a difficult problem:
|
|
sub-systems which seemingly should not interfere with each other end up
|
|
affecting each other's results. This is due to the chosen integration method. An
|
|
adaptive solver like #sundials will vary its step length throughout the
|
|
integration process, and the addition of new, unrelated ODEs in the system can
|
|
influence these step lengths, affecting the results obtained for pre-existing
|
|
ODEs. This is particularly problematic in the case of runtime assertions, which
|
|
are typically expected to be transparent: they should not affect the final
|
|
result of the computation.
|
|
|
|
We therefore aim to define a new execution model for hybrid system models, which
|
|
allows for clear separation between a program and its assertions, in such a way
|
|
that the results obtained by executing a model with and without its assertions
|
|
are the same.
|
|
|
|
// #heading(outlined: false, numbering: none)[Proposed Contributions]
|
|
// What is your answer to the research problem? Please remain at a high level;
|
|
// technical details should be given in the main body of the report. Pay special
|
|
// attention to the description of the _scientific_ approach.
|
|
|
|
*Proposed Contributions* --- To solve this, we propose a new runtime for the
|
|
#zelus language that simulates assertions with their own solvers in order to
|
|
maintain the separation between assertions and the model they operate on. This
|
|
is done by first describing the execution of a hybrid system model as a
|
|
synchronous node akin to those found in languages such as #lustre, operating on
|
|
streams of functions, and then implementing this vision in #ocaml. This
|
|
interpretation allows us to _lift_ the runtime into the language, allowing for
|
|
direct manipulation of hybrid simulations in the synchronous subset of #zelus.
|
|
The addition of a few primitives then allows us to attain our final objective of
|
|
transparent assertions.
|
|
|
|
// #heading(outlined: false, numbering: none)[Arguments Supporting Their Validity]
|
|
// What is the evidence that your solution is a good solution? (Experiments?
|
|
// Proofs?) Comment on the robustness of your solution: does it rely on
|
|
// assumptions, and if so, to what extent?
|
|
|
|
*Arguments Supporting Their Validity* ---
|
|
#todo[
|
|
Justify.
|
|
]
|
|
|
|
// #heading(outlined: false, numbering: none)[Summary And Future Work]
|
|
// What did you contribute to the area? What comes next? What is a good _next_
|
|
// step or question?
|
|
|
|
*Summary and Fugure Work* -- Future work includes the addition of assertions as
|
|
a syntactic construct in the source language, with a compilation pass to
|
|
translate them to their equivalent form in #ocaml. The direct manipulation of
|
|
streams of functions which may depend on the solver's internal state also raises
|
|
several questions, one of which is of particular interest: we cannot necessarily
|
|
use `pre` on such values, since an integration step of the solver may modify its
|
|
internal state, on which the closures depend. One could envision a static
|
|
analysis to forbid such manipulations, and a simple answer could be typeclasses
|
|
_à la_ #haskell, but the addition of typeclasses into the type system of #zelus
|
|
is a problem of its own.
|
|
|
|
#pagebreak()
|
|
#pagebreak()
|
|
#set par.line(numbering: none)
|
|
#outline()
|
|
#set par.line(numbering: "1")
|
|
|
|
= Introduction <sec:introduction>
|
|
|
|
#todo[
|
|
Write the introduction.
|
|
Justify the approach: we first present a low-level denotational semantics
|
|
of hybrid system models as a collection of functions operating on an inner
|
|
state, and pair this denotational semantics with a numerical ODE solver and a
|
|
zero-crossing detection mechanism to construct a synchronous operational
|
|
semantics of the simulation. We then use this operational semantics to
|
|
implement transparent observers, by separating the simulation of the model
|
|
from that of its observers.
|
|
]
|
|
|
|
#pagebreak()
|
|
|
|
= Hybrid System Model Simulation <sec:hyb_sys_mod_sim>
|
|
|
|
The simulation of a hybrid system model is a function from signals to signals.
|
|
Signals are functions from time to values, modelling the evolution of a value in
|
|
time. The exact meaning of time depends on the nature of the model. Three
|
|
possible situations may occur: discrete-time models, akin to those found in
|
|
synchronous languages like #lustre @cit:lustre; continuous-time models with no
|
|
discontinuities; and hybrid models, which involve both discrete and continuous
|
|
behaviours.
|
|
|
|
== Discrete-Time Models <sec:discrete_mod>
|
|
|
|
#zelus starts from a synchronous language kernel _à la_ #lustre, and extends it
|
|
with continuous-time constructs @cit:zelus_sync_lng_with_ode. This synchronous
|
|
language kernel allows for the description of variables evolving in time through
|
|
streams of values. The notion of time is logical, and is represented as a series
|
|
of discrete instants; time is then a value in $NN$, and streams are functions
|
|
from $NN$ to their respective codomains:
|
|
|
|
$ Stream(V) = NN -> V $
|
|
|
|
Given a stream $s : Stream(V)$, we denote $s_n$ the $n$-th value $s(n)$ of the
|
|
stream.
|
|
|
|
Computation occurs in successive steps performed at each instant, and may depend
|
|
on values computed at previous instants. This interpretation of time allows a
|
|
program written in the synchronous kernel to be considered independently of its
|
|
physical implementation. Nothing in this representation tells us anything about
|
|
how much physical time passes between successive instants.
|
|
|
|
The programs expressed in this kernel, called _discrete nodes_, are functions on
|
|
streams. At each time instant, given inputs $I$, they produce outputs $O$.
|
|
Producing these outputs may also depend on previously computed values through
|
|
operators like ```zelus pre(e)```, which returns the value of its sub-expression
|
|
`e` at the previous instant, and ```zelus e1 -> e2```, which returns its
|
|
left-hand side `e1` at the first instant and its right-hand side `e2`
|
|
afterwards. This requires nodes to store some previously computed values. Nodes
|
|
therefore operate on an inner state of type $S$: previously computed values must
|
|
be stored inside this state in order to refer to them afterwards. The behaviour
|
|
of a node is represented by a step function $f_"step" : S -> I -> O times S$ and
|
|
an initial state $s_0 : S$, used at the first instant. Given a set of inputs and
|
|
the current state, the $f_"step"$ function produces a set of outputs and a new,
|
|
updated state. This function is then called at each instant, taking as input the
|
|
current value of the input signal, and the state produced by the previous
|
|
instant.
|
|
|
|
#todo[
|
|
Clearly distinguish between stream semantics and Mealy machine implementation
|
|
(and mention Mealy machines explicitly).
|
|
]
|
|
|
|
Since programs may wish to reset the state of a node (for instance, when writing
|
|
automata; further motivation will be given in the following sections), nodes
|
|
also define a reset function $f_"reset" : S -> R -> S$. Since nodes may be
|
|
parameterized by a value, this reset function takes in an additional reset
|
|
parameter $R$ and the previous state, and returns an updated state. A discrete
|
|
model with input $I$ and output $O$ is then a triple of an initial state, and a
|
|
step and reset function:
|
|
|
|
$ DNode(I, O, R, S) eq.def {
|
|
s_0 : S;
|
|
f_"step" : S -> I -> O times S;
|
|
f_"reset" : S -> R -> S
|
|
} $
|
|
|
|
The simulation of such a model then defines two streams: the inner state $s$ and
|
|
the output $o$:
|
|
|
|
$ DSim(M)(i_n) = o_n "where"
|
|
(o_n, s_(n+1)) = M.f_"step" (i_n, s_n), s_0 = M.s_0 $
|
|
|
|
A possible implementation of this simulation in #ocaml, where streams are
|
|
represented by lists of values, is given in @lst:discrete_sim #footnote[
|
|
Due to space concerns, #ocaml type definitions are ommitted from the main body
|
|
of the report, and are instead given in @sec:appendix, @lst:ocaml_typedefs.
|
|
These type definitions are direct translations of the mathematical definitions
|
|
into #ocaml.
|
|
].
|
|
|
|
#figure(
|
|
```ocaml
|
|
(** Run a model on a list of inputs. *)
|
|
let dsim (DNode model) input =
|
|
let rec run s = function
|
|
| [] -> []
|
|
| i :: is -> let (o, s) = model.step s i in (o :: run s is) in
|
|
run model.s0 input
|
|
```,
|
|
caption: [Discrete simulation in #ocaml],
|
|
) <lst:discrete_sim>
|
|
|
|
#figure(
|
|
```zelus
|
|
let h = 0.01 (* Integration time step. *)
|
|
|
|
(* Forward Euler integrator. *)
|
|
let node integr(x0: float, x': float) = (x: float) where
|
|
rec x = x0 -> pre(x +. x' *. h)
|
|
|
|
let node sincos_discrete() = (sin: float, cos: float) where
|
|
rec sin = integr(0.0, cos) (* (dsin/dt)(t) = cos(t), sin(0) = 0.0 *)
|
|
and cos = integr(1.0, -. sin) (* (dcos/dt)(t) = -sin(t), cos(0) = 1.0 *)
|
|
```,
|
|
caption: [Sine and cosine approximation in discrete #zelus],
|
|
) <lst:sincos_discrete>
|
|
|
|
As an example, consider the program in @lst:sincos_discrete, written in the
|
|
discrete subset of #zelus (it could have been written in #lustre in a similar
|
|
way). This program computes approximations for the sine and cosine functions.
|
|
The `integr` node implements a forward Euler integrator. It takes as input a
|
|
stream `x0` representing the initial value of the signal to be integrated, and a
|
|
stream `x'` representing the derivative of this same signal, sampled at a
|
|
predefined integration step `h`. It then defines a new stream `x`, approximating
|
|
the integral of `x'`, as follows:
|
|
|
|
$ #zel("x")_0 = #raw("x0")_0 wide
|
|
#zel("x")_(n+1) = #zel("x")_n + #zel("x'")_n dot #zel("h") $
|
|
|
|
The `integr` node is used to compute an approximation of the solution to a
|
|
restricted form of an initial value problem: given a function $x'(t)$ computing
|
|
the derivative of a variable $x$ with respect to time (that is,
|
|
$(d x)/(d t)(t) = x'(t)$), and an initial value $x_0$ for this variable,
|
|
its solution is a function of time $x(t)$ whose derivative is $x'$, and whose
|
|
value at $t = 0$ is $x(0) = x_0$.
|
|
|
|
The `sincos_discrete` node then uses this integrator to approximate solutions
|
|
for the sine and cosine functions. Since $(d sin)/(d t)(t) = cos(t)$ and
|
|
$(d cos)/(d t)(t) = -sin(t)$, and $sin(0) = 0$ and $cos(0) = 1$, we can
|
|
formulate $sin$ and $cos$ through an initial value problem. We can then use two
|
|
integrators to approximate solutions to $sin$ and $cos$. The output of `sincos`
|
|
at instant $n$ is then the pair of $sin(n dot h)$ and $cos(n dot h)$.
|
|
|
|
#todo[
|
|
Emphasize that the inner state of the subnodes is contained in the state of
|
|
the parent node, and that separate calls to subnodes are separate instances.
|
|
]
|
|
|
|
== Continuous-Time Models <sec:continuous_mod>
|
|
|
|
#figure(
|
|
cetz.canvas({
|
|
let data = csv("data/sincos_discrete.csv")
|
|
let dsin = data.map(t => (float(t.at(0)), float(t.at(1))))
|
|
let dcos = data.map(t => (float(t.at(0)), float(t.at(2))))
|
|
cetz-plot.plot.plot(size: (12, 2), axis-style: "left",
|
|
x-tick-step: 10, y-tick-step: 1, x-grid: "both", y-grid: "both",
|
|
legend: (11, 2.2), y-label: none, x-label: "Time", {
|
|
cetz-plot.plot.add(label: "sin", dsin, style: (stroke: (dash: "solid")))
|
|
cetz-plot.plot.add(label: "cos", dcos, style: (stroke: (dash: "dashed")))
|
|
})
|
|
}),
|
|
caption: [Simulation of @lst:sincos_discrete with `h = 0.01`],
|
|
) <fig:sincos_discrete>
|
|
|
|
|
|
While the model in @lst:sincos_discrete is simple to understand, it is somewhat
|
|
rigid: the integration method is fixed, as well as the time step. The simulation
|
|
results strongly depend on these parameters. Given a time step of $0.01$, for
|
|
instance, the approximation quickly diverges from the analytical solution, as
|
|
seen in @fig:sincos_discrete. This divergence is simple to understand: rather
|
|
than using the derivative of $sin$ and $cos$ exactly, we sample it at specific
|
|
instants (multiples of $h$), and consider it to be constant between samples.
|
|
This causes the result of an integration "step" to be slightly imprecise. Over
|
|
time, these imprecisions accumulate, leading to this divergence. This problem is
|
|
not exclusive to the forward Euler integrator --- it is an inherent difficulty
|
|
of numerical approximation of ODEs.
|
|
|
|
#figure(
|
|
```zelus
|
|
let hybrid sincos() = (sin: float, cos: float) where
|
|
rec der sin = cos init 0.0 (* (dsin/dt)(t) = cos(t), sin(0) = 0.0 *)
|
|
and der cos = -. sin init 1.0 (* (dcos/dt)(t) = -sin(t), cos(0) = 1.0 *)
|
|
```,
|
|
caption: [Sine and cosine approximation in continuous #zelus]
|
|
) <lst:sincos_continuous>
|
|
|
|
Still, we can do better. Rather than remain in the discrete world, #zelus allows
|
|
us to express a signal as a function of _continuous time_. Time is no longer
|
|
logical and represented by a series of discrete instants, but rather physical
|
|
and continuous. A model is now a function of signals on physical time. Given an
|
|
input signal of type $I$, it defines a continuously evolving inner state of type
|
|
$S$, and an output signal of type $O$. This is represented through an initial
|
|
state $s_0 : S$ and two functions: the derivative function
|
|
$f_"der" : I -> S -> S'$ computes the derivative $S'$ of the inner state $S$ at
|
|
a given time using the value of the input signal and the inner state at that
|
|
time; the output function $f_"out" : I -> S -> O$ computes the output of the
|
|
model at a given time given the value of the input signal and the inner state at
|
|
that time. A continuous model is then a tuple of an initial state and of these
|
|
two functions:
|
|
|
|
#todo[
|
|
The fact that the arguments of $f_"der"$ are at the same time is not visible
|
|
in the type.
|
|
]
|
|
|
|
$ CNode(I, O, S, S') eq.def
|
|
{ s_0: S; f_"der": I -> S -> S'; f_"out": I -> S -> O } $
|
|
|
|
For instance, the model of @lst:sincos_discrete can be expressed in continuous
|
|
time as seen in @lst:sincos_continuous. Here, `sin` and `cos` are expressed
|
|
directly as initial value problems. The notation ```zelus der x = e init e0```
|
|
expresses that the derivative of `x` with respect to time is `e`, and that the
|
|
value of `x` at time `t = 0` is `e0`.
|
|
|
|
A major difference between the discrete and continuous models is that the
|
|
description of the continuous model is kept separate from the ODE solving
|
|
machinery. Nothing in @lst:sincos_continuous expresses any constraints for how
|
|
the two initial value problems of `sin` and `cos` are solved -- we leave this
|
|
detail to the language implementation. This allows for greater flexibility in
|
|
the simulation process, because independence from the solver means we can choose
|
|
our approximation method.
|
|
|
|
== Numerical ODE Solvers <sec:ode_solvers>
|
|
|
|
The simulation of a continuous model solves the initial value problem posed by
|
|
the initial state $s_0$ and the derivative function $f_"der"$, and uses this
|
|
solution in order to compute the output signal with $f_"out"$. This is done
|
|
using a numerical solver which approximates the solution, such as #sundials ---
|
|
the `integr` node from @lst:sincos_discrete is another example of a numerical
|
|
solver (albeit not a very good one). In general, numerical ODE solvers can be
|
|
considered through a simple interface: given an initial value problem for a
|
|
signal $y : Time -> Y$, in the form of a maximum time $stop$, a derivative
|
|
function $f : [0, stop] -> Y -> Y'$ such that for all $t in [0, stop]$,
|
|
$(d y)/(d t)(t) = f(t, y(t))$), and an initial value $y_0 : Y$ such that
|
|
$y(0) = y_0$, a numerical ODE solver provides a function
|
|
|
|
$ italic("csolve")(f)(y_0) : (h : Time) ->
|
|
(h' : Time) times (italic("dky") : [0, h'] -> Y) $
|
|
|
|
This function, given a requested horizon $h : Time$ (this represents the date up
|
|
to which we wish to know the approximation of the solution), returns a new
|
|
horizon $h' <= h$ and an approximation $italic("dky") : [0, h'] -> Y$ of the
|
|
solution to the initial value problem, that is, $italic("dky")(t) approx y(t)$
|
|
for all $t in [0, h']$. This function is called a _dense solution_. #footnote[
|
|
The notation $italic("dky")$ and the name _dense solution_ are taken from the
|
|
#sundials interface.
|
|
]
|
|
|
|
Of particular interest is the fact that numerical ODE solvers can be considered
|
|
to compute approximations _sequentially_. Some solvers, such as #sundials,
|
|
perform integration in successive steps. Each step produces a part of the
|
|
approximation, and successive calls to the step function produce successive
|
|
parts. More formally, a single call to the $italic("csolve")$ function provides
|
|
us with an approximation of the solution up to the returned horizon $h'$, which
|
|
may be less than the requested date $h$. To obtain an approximation of the
|
|
solution at a later date, we must perform another call, this time with initial
|
|
state $italic("dky")(h')$, which is the best approximation of the value of
|
|
$y(h')$. This new call will provide us with a new horizon $h'' >= h'$, and a new
|
|
approximation $italic("dky")' : [h', h''] -> Y$. This is then repeated as often
|
|
as needed to build a larger approximation of the solution.
|
|
|
|
This sequential process allows a synchronous interpretation of an ODE solver as
|
|
a discrete node. Rather than producing a single function of continuous time, an
|
|
ODE solver is a synchronous node that takes in a stream of requested horizons
|
|
and produces a stream of dense functions and associated horizons.
|
|
|
|
$ Dense(A) eq.def { h : Time; u : [0, h] -> A } $
|
|
|
|
The ODE solver, given a stream of requested horizons, produces a stream of dense
|
|
solutions, and operates on an internal state $S$, whose definition depends on
|
|
the solver being used. Its reset parameter is an initial value problem:
|
|
|
|
$ IVP(Y, Y') eq.def { y_0 : Y; stop : Time; f : [0, stop] -> Y -> Y' } $
|
|
|
|
An ODE solver can thus be considered as a particular kind of discrete node:
|
|
|
|
$ CSolver(Y, Y', S) eq.def DNode(Time, Dense(Y), IVP(Y, Y'), S) $
|
|
|
|
A signal of type $V$ is now represented as a stream of interval-defined
|
|
functions, that is, a function from $NN$ to $Dense(V)$. Successive values in the
|
|
stream are interpreted as successive intervals on the time domain. Given a
|
|
stream of dense functions $s : NN -> Dense(V)$, the corresponding signal
|
|
$s' : Time -> V$ is defined as
|
|
|
|
$ s'(t) = cases(
|
|
s_0.u(t) & "if" t in [0, e_0],
|
|
s_n.u(t - e_(n-1)) & "if" t in (e_(n-1), e_n] "for some" n > 0,
|
|
"undefined" & "otherwise"
|
|
) wide wide e_n = sum^n_(i=0)s_i.h $
|
|
|
|
#todo[Find better names than $s$ and $s'$.]
|
|
|
|
where $e_n$ is the stream of instants at which the solver stops. We assume dense
|
|
functions to be continuous on their domain. However, nothing prevents
|
|
discontinuities from occurring at the joining points of the stream, that is, for
|
|
the stream $s$ above, we might have that $s_n.u(s_n.h) != s_(n+1).u(0)$. The ODE
|
|
solver does not itself introduce discontinuities; the only discontinuities in
|
|
the system are those introduced by the input signal.
|
|
|
|
It is important to note that the solver approximates solutions to the _entire_
|
|
initial value problem at once. That is, if the initial value problem is composed
|
|
of two or more unrelated ODEs (in the sense that they operate on distinct sets
|
|
of variables), the solver does not consider these ODEs separately; rather, it
|
|
computes approximations to the entire system at once. This can lead to some
|
|
unexpected behaviour. Some solvers, such as #sundials, adapt their step length
|
|
according to the system being approximated. If given a particularly "steep"
|
|
curve (say, a sine wave with a high frequency), the step length is shortened to
|
|
mitigate errors; instead, if given a "gentler" curve, the step length is
|
|
lengthened to increase efficiency. Of course, the approximation obtained depends
|
|
on the length of the integration steps the solver performs; integrating the same
|
|
curve with different step lengths yields different results.
|
|
|
|
The addition of a new, unrelated ODE to a pre-existing system can then alter the
|
|
results obtained for this system. If the newly added ODE is "steep", the solver
|
|
reduces its step length to mitigate error, and computes an approximation for the
|
|
entire system using this new step length. This differs from the steps the solver
|
|
would have taken had the new ODE not been included; and so, the results obtained
|
|
for the rest of the system are different. This is particularly important: the
|
|
simultaneous integration of two unrelated systems yields different results from
|
|
their integration in isolation.
|
|
|
|
Of course, one could consider a different approximation method, where each ODE
|
|
is integrated independently with its own ODE solver, rather than all together as
|
|
a whole system. This is called distributed simulation, and while it solves the
|
|
issue of interference between unrelated systems, it raises other difficulties
|
|
and performance concerns, and is more difficult to implement. #zelus chooses
|
|
instead to live with the consequences of using a single solver for the entire
|
|
system.
|
|
|
|
The simulation of a continuous-time system with an ODE solver is now considered
|
|
as a synchronous node. Rather than continuous-time signals, it operates on
|
|
streams of interval-defined functions. At each step, it takes the value provided
|
|
by its input signal, initializes the ODE solver with an appropriate initial
|
|
state and derivative function, and performs a step of the solver to obtain an
|
|
approximation of the solution to the initial value problem. It then uses this
|
|
approximation and the model's output function to build an output value.
|
|
|
|
Since the ODE solver does not necessarily reach the requested horizon in a
|
|
single step, the simulation may need to execute several steps of the underlying
|
|
solver for each dense function provided as input. That is, for an input value
|
|
defined on the time interval $[0, h]$, the ODE solver produces a list of
|
|
approximations such that their "concatenation" represents the solution over the
|
|
full interval $[0, h]$, with each item in the list being the result of a step of
|
|
the solver. Since the ODE solver does not introduce discontinuities, it is safe
|
|
to consider this concatenation as a single continuous function.
|
|
|
|
Unfortunately, some ODE solvers (such as #sundials) work in such a way that
|
|
stepping the solver multiple times per step of the simulation is infeasible.
|
|
Solvers operate on an internal state (in #sundials, this is called a session),
|
|
and the approximation returned by a step of the solver depends on this internal
|
|
state. Some solvers rewrite this internal state in-place during a step,
|
|
invalidating previously produced approximations. We cannot then step the solver
|
|
multiple times, as all but the last approximation produced will be unusable. But
|
|
stepping the solver once per simulation step would mean that the solver would
|
|
have to store its input values until the solver is ready to work on them, which
|
|
we cannot do either: the input stream might be the output of another simulation,
|
|
in which case all but the latest input value would be unusable as well.
|
|
|
|
To solve this conundrum, we wrap the dense functions in our stream with an
|
|
option type, representing the "readiness" of the simulation to accept a new
|
|
input value.
|
|
|
|
$ Signal(V) eq.def Option(Dense(V)) $
|
|
|
|
Rather than stepping the solver multiple times per step of the simulation, we
|
|
step the solver once, and return the output up to the horizon reached by the
|
|
solver. If the solver has not reached the input's horizon, we perform another
|
|
step, giving $None$ as input to the simulation, and do so until the solver
|
|
reaches the input's horizon, after which the simulation simply returns $None$.
|
|
Once this occurs, it is safe to provide a new dense function as input and begin
|
|
integration again.
|
|
|
|
The simulation can then take as input as many $None$ values as necessary for the
|
|
solver to "have time" to reach the horizon of the input. The input stream must
|
|
then contain as many successive $None$ as needed after a dense function for the
|
|
solver to reach the horizon requested by this dense function. The simulation
|
|
assumes the input signal takes this form; if a new input value is provided to
|
|
the simulation before it is done integrating the previous one, no guarantee is
|
|
made on the correctness of the results.
|
|
|
|
The simulation of a continuous-time model with a solver is then a special case
|
|
of a discrete node, with a complex internal state $S$:
|
|
|
|
$ CSim : & CNode(I, O, S_M, S'_M) -> CSolver(S_M, S'_M, S_S) ->
|
|
\ & DNode(Signal(I), Signal(O), Unit, Signal(I) times S_M times S_S) $
|
|
|
|
A step of the simulation can take three forms, depending on its input and on
|
|
the state of the simulation. If the input is a new dense function, we assume we
|
|
are done integrating the previous input. We reset the solver to take into
|
|
account the new input value in the initial value problem (the model's derivative
|
|
function uses the input, and so the derivative function given to the solver must
|
|
change). If the input is $None$ and we are not done integrating, we call the
|
|
solver again and use the approximation returned to build a dense value for the
|
|
output stream. If the input is $None$ and we are done integrating, we do
|
|
nothing: the simulation is waiting for the next input value. A possible
|
|
implementation in #ocaml is given in @lst:continuous_sim.
|
|
|
|
#figure(
|
|
```ocaml
|
|
(** Simulation of a continuous model, as a discrete node. *)
|
|
let csim (CNode model) (DNode solver) =
|
|
let s0 = (None, model.s0, solver.s0) in
|
|
let step (current_input, mstate, sstate) new_input =
|
|
match (new_input, current_input) with
|
|
| (Some input, None) ->
|
|
let ivp_f t m = model.fder (input.u t) m in
|
|
let ivp = { y0=mstate; f=ivp_f; h=input.h } in
|
|
None, (Some i, mstate, solver.reset sstate ivp)
|
|
| (None, Some input) ->
|
|
let ({h; u=dky}, sstate) = solver.step sstate input.h in
|
|
let u t = model.fout (input.u t) (dky t) in
|
|
let current_input = if h >= input.h then None else current_input in
|
|
Some {h; u}, (current_input, dky h, sstate)
|
|
| (None, None) -> None, (None, ms, ss)
|
|
| (Some _, Some _) -> assert false in
|
|
let reset (_, ms, ss) () = (None, model.y0, solver.y0) in
|
|
DNode { s0; step; reset }
|
|
```,
|
|
caption: [Continuous simulation in #ocaml]
|
|
) <lst:continuous_sim>
|
|
|
|
== Hybrid Models <sec:hybrid_mod>
|
|
|
|
Continuous-time models allow for precise descriptions of physical systems and
|
|
continuous behaviours. However, they lack the ability to describe discrete
|
|
_events_. For instance, consider the model of a bouncing ball. We can describe
|
|
its behaviour in the air with two ODEs for the ball's position $y$ (the distance
|
|
from the ground) and speed $y'$:
|
|
|
|
$ (d y)/(d t)(t) = y'(t) wide (d y')/(d t)(t) = -g $
|
|
|
|
where $g$ is the gravitational constant ($g approx 9.81$). Coupled with an
|
|
initial position and speed, this gives us our initial value problem, which can
|
|
be approximated as seen above. However, nothing here describes the ball's
|
|
bouncing behaviour as it touches the ground: it will fall until the end of time.
|
|
We would ideally like to identify the instant at which the ball touches the
|
|
ground, stop the simulation at this instant, and perform some changes to the
|
|
model state to represent the impact of the bounce (say, negate the speed and
|
|
scale it by a constant), before resuming the simulation with the updated state.
|
|
|
|
The question of discrete events comes up whenever we wish to include discrete
|
|
behaviour in a continuous model. Since time is logical in discrete nodes,
|
|
nothing tells us when, in continuous time, we should perform discrete steps.
|
|
There are many possible choices. We could, for instance, pick a step length $p$
|
|
and say that the discrete step is performed periodically at every $p$. In
|
|
practice, hybrid system modelers like #simulink and #zelus use _zero-crossings_.
|
|
They monitor a certain value during simulation, and perform a discrete step
|
|
whenever this value changes from strictly negative to positive or null.
|
|
|
|
More formally, a zero-crossing on a function $z : [0, h] -> RR$ occurs at time
|
|
$t in [0, h]$ if any of the following conditions are met:
|
|
$ & (z(t - epsilon) < 0) and (z(t) > 0) \
|
|
or & (z(t - epsilon) < 0) and (z(t) = 0) and (z(t + epsilon) >= 0) \
|
|
or & (z(t - epsilon) = 0) and (z(t) = 0) and (z(t + epsilon) > 0) $
|
|
with $epsilon in Time$ a strictly positive, solver-dependent constant
|
|
representing the maximum precision of the zero-crossing detection mechanism
|
|
#footnote[
|
|
For instance, if the zero-crossing mechanism represented time with
|
|
floating-point numbers, a sensible choice for $epsilon$ could be
|
|
`epsilon_float`.
|
|
] (see @sec:zero_crossing_solvers).
|
|
|
|
An important point is that discrete events should take _no time_ to execute. The
|
|
physical time of the model does not change during discrete steps. This is
|
|
similar to the approach of the synchronous languages, where the execution of a
|
|
step is considered to be instantaneous. Additionally, multiple discrete steps
|
|
may occur directly after one another. The time basis should reflect this: if we
|
|
use $Time = RR_+$, successive discrete steps would occur at the same time, and
|
|
we have no way to distinguish the order of execution, or even represent as a
|
|
function whose codomain is a single value. In the superdense semantics of
|
|
@cit:op_sem_hyb_sys, the time basis is the set $RR_+ times NN$, ordered
|
|
lexicographically ($(t_1, n_1) < (t_2, n_2)$ iff $t_1 < t_2$ or
|
|
$t_1 = t_2 and n_1 < n_2$) #footnote[
|
|
This is not the only possible choice; for instance, in
|
|
@cit:nonstd_sem_hyb_sys_mod, the semantics of hybrid systems is expressed
|
|
using non-standard analysis, and the time basis is the set of hyperreals
|
|
$\ ^*RR$.
|
|
]. At each physical instant $t : RR_+$, any number $n$ of discrete steps may
|
|
occur in successive logical instants $(t, 0), (t, 1), ..., (t, n)$. In our
|
|
stream representation of signals, discrete instants are instead represented by
|
|
dense functions with horizon $h = 0$ (that is, defined on the interval $[0,0]$).
|
|
The order of execution of successive discrete steps is simply the order given by
|
|
the stream #footnote[
|
|
The interpretation of a stream of dense functions $s : NN -> Dense(V)$ as a
|
|
function of superdense time $s' : (RR_+ times NN) -> V$ is then defined
|
|
through the following recursive functions:
|
|
$ f(t, n, s, i) & = cases(
|
|
s_i.u(t) & "if" t < s_i.h,
|
|
g(s_i.u(0), n-1, s, i+1) & "if" t = s_i.h,
|
|
f(t - s_i.h, n, s, i+1) & "otherwise"
|
|
) \
|
|
g(v, n, s, i) & = cases(
|
|
v & "if" n = 0,
|
|
s_i.u(0) & "if" s_i.h != 0,
|
|
g(s_i.u(0), n - 1, s, i + 1) & "otherwise"
|
|
) \
|
|
s'(t, n) & = f(t, n, s, 0) $
|
|
The stream representation is quite similar to the hybrid sequences of
|
|
@cit:theory_timed_io_automata[sec. 3.4].
|
|
].
|
|
|
|
A hybrid model describes such systems whose behaviour goes through both
|
|
continuous and discrete phases. Its state $S$ contains both discrete and
|
|
continuous parts: the continuous part $Y$ is defined by ODEs, and evolves
|
|
during continuous phases, while the discrete part is only modified during
|
|
discrete steps, and must be constant during continuous phases #footnote[
|
|
This restriction is enforced by typing: see @cit:zelus_sync_lng_with_ode for
|
|
more details.
|
|
]. The model defines functions $c_"get" : S -> Y$ and $c_"set" : S -> Y -> S$ to
|
|
get and set the continuous state $Y$ from the whole state $S$. To handle
|
|
zero-crossings, a model with input signal $I$ defines a zero-crossing function
|
|
$f_"zero" : S -> I -> Y -> Zo$, where $Zo$ is a vector of values to be monitored
|
|
for zero-crossings. The inner state $S$ also maintains a vector of boolean flags
|
|
$Zi$, representing the events corresponding to the values in $Zo$ (a flag is set
|
|
to true if its corresponding event has occured), and a function
|
|
$z_"set" : S -> Zi -> S$ to update the state when an event has been detected.
|
|
|
|
For implementation reasons, a hybrid model also defines two additional functions
|
|
$f_"jump" : S -> BB$ and $f_"horizon" : S -> Time$. The $f_"horizon"$ function
|
|
allows a model to provide a horizon after which no further integration must
|
|
occur. This is used to indicate whether or not the simulation, after a discrete
|
|
step, must perform another discrete step (the horizon is 0 in this case); and
|
|
for the ```zelus period``` construct, which represents a recurring discrete
|
|
event.
|
|
// #footnote[
|
|
// The ```zelus period``` construct could also be implemented using a sawtooth
|
|
// ode:
|
|
// ```zelus period(p)``` is roughly equivalent to \
|
|
// ```zelus let rec der t = 1.0 init -. p reset z -> -. p and z = up(t) in z```
|
|
// ].
|
|
The $f_"jump"$ function is used to indicate whether or not a discrete step
|
|
has introduced a discontinuity in the model's state: if so, the solver must be
|
|
reset to take this change into account. These two functions exist mainly for
|
|
implementation purposes: see @sec:sim_algorithm for more details.
|
|
|
|
Finally, a hybrid model defines all functions required by discrete and
|
|
continuous models:
|
|
|
|
$ HNode(I, O, R, S, Y, Y', Zi, Zo) eq.def {
|
|
& s_0 : S; \
|
|
& c_"get" : S -> Y;
|
|
c_"set" : S -> Y -> S;
|
|
z_"set" : S -> Zi -> S; \
|
|
& f_"step" : S -> I -> Zi -> O times S;
|
|
f_"der" : S -> I -> Y -> Y'; \
|
|
& f_"out" : S -> I -> Y -> O;
|
|
f_"zero" : S -> I -> Y -> Zo; \
|
|
& f_"reset" : S -> R -> S;
|
|
f_"horizon" : S -> Time;
|
|
f_"jump" : S -> BB
|
|
} $
|
|
|
|
#zelus provides several ways to specify zero-crossing events, of which the
|
|
```zelus up(e)``` construct is the most common. It monitors its subexpression
|
|
`e` for zero-crossings, and triggers an event whenever a zero-crossing occurs on
|
|
`e`. Constructs like ```zelus present``` and ```zelus reset``` allow models to
|
|
execute discrete behaviour when an event is triggered. These constructs are
|
|
compiled down to an internal representation quite similar to $HNode$ (see
|
|
@cit:sync_based_codegen_hyb_sys_lng for more details). Continuous-time models
|
|
are a special case of hybrid ones, where the discrete step function does not
|
|
do anything, and the zero-crossing function does not monitor any signals; and
|
|
#zelus compiles them down to the same internal representation.
|
|
|
|
Going back to our bouncing ball, we monitor the expression $-y$ for
|
|
zero-crossings. Whenever this expression becomes positive, the ball touches the
|
|
ground. When this zero-crossing event is triggered, we represent the effect of
|
|
the bounce by negating the speed, so that the ball starts moving up again, and
|
|
decreasing it by a small factor, to represent the loss of inertia from the
|
|
collision. A possible implementation in #zelus is given in @lst:bouncing_ball.
|
|
Whenever the zero-crossing event `z` occurs, `y'` is negated and scaled down;
|
|
the notation ```zelus last y'``` represents the left-limit of `y'`.
|
|
|
|
#figure(
|
|
```zelus
|
|
let hybrid ball(y0, y'0) = y where
|
|
rec der y = y' init y0
|
|
and der y' = -9.81 init y'0 reset z -> -0.8 *. last y'
|
|
and z = up(-. y)
|
|
```,
|
|
caption: [The bouncing ball in #zelus]
|
|
) <lst:bouncing_ball>
|
|
|
|
== Zero-crossing Detection <sec:zero_crossing_solvers>
|
|
|
|
The monitoring of the zero-crossing expressions $Zo$ requires a mechanism to
|
|
detect zero-crossings, termed a zero-crossing solver. Multiple methods exist
|
|
(#zelus uses the Illinois method @cit:illinois), but the zero-crossing solver
|
|
can be summarized as providing a function
|
|
|
|
$ italic("zsolve") : (Time -> Y -> Zo) -> Dense(Y) -> Time times Option(Zi) $
|
|
|
|
taking as input a zero-crossing function and a dense solution $v$, and returning
|
|
a pair of a horizon $h in [0, v.h]$ and an optional vector of boolean flags $z$,
|
|
such that if the zero-crossing solver detects one or more zero-crossing events,
|
|
$h$ is the earliest instant at which a zero-crossing occurs, and $z$ is not
|
|
null; otherwise, $h = v.h$, and $z$ is null.
|
|
|
|
Similarly to the ODE solver, a zero-crossing solver can be seen as a synchronous
|
|
node. Since the zero-crossing function does not change during continuous
|
|
behaviour (it takes as argument the continuous part of the state, and the
|
|
discrete part is considered constant during integration), it may be used as a
|
|
reset parameter; the input is a stream of dense solutions, and the output is a
|
|
stream of pairs of reached horizons and optional zero-crossings.
|
|
|
|
$ ZSolver\(Y, Zi, Zo, S) eq.def
|
|
DNode\(Dense(Y), Time times Option(Zi), Time -> Y -> Zo, S) $
|
|
|
|
#todo[Finish this section.]
|
|
|
|
A zero-crossing solver may be combined with an ODE solver to obtain the full
|
|
solver mechanism used by the simulation of a hybrid system. This full solver
|
|
both performs approximation of the solution to the initial value problem of the
|
|
model, as well as zero-crossing detection using this approximation. That is,
|
|
given an ODE solver $c s : CSolver(Y, Y', S_C)$ and a zero-crossing solver
|
|
$z s : ZSolver(Y, Zi, Zo, S_Z)$, their composition takes the form of a new
|
|
synchronous node $s : Solver(Y, Y', Zi, Zo, S_C times S_Z)$, where
|
|
|
|
$ Solver(Y, Y', Zi, Zo, S) eq.def
|
|
DNode(Time, Dense(Y) times Option(Z_i), IVP(Y, Y') times ZCP(Y, Zo), S) $
|
|
|
|
A step of the full solver $s$ takes in a horizon $h : Time$. It then performs a
|
|
step of the underlying ODE solver $c s$ with input $h$, obtaining a dense
|
|
function $v$ defined up to a horizon $h' <= h$ approximating the solution of the
|
|
initial value problem, and uses this dense function as input to the
|
|
zero-crossing solver $z s$, which returns a new horizon $h'' <= h'$ and an
|
|
optional zero-crossing event $z$. The final horizon $h''$ is then used as the
|
|
horizon of $v$ (since $v$ is defined on $[0, h']$ and $h'' <= h'$, then $v$ is
|
|
defined on $[0, h'']$). Finally, it returns the dense function $v$ with horizon
|
|
$h''$ and the optional zero-crossing event $z$.
|
|
|
|
$ s.f_"step" (h) eq.def (v', z) "where"
|
|
v = c s.f_"step" (h)", "
|
|
(h', z) = z s.f_"step" (v)
|
|
"and "v' = { h=h', u=v.u } $
|
|
|
|
The full solver mechanism is then paired with a hybrid model to construct a
|
|
node representing the simulation of the model.
|
|
|
|
== The Simulation Algorithm <sec:sim_algorithm>
|
|
|
|
The simulation of a full hybrid model $m : HNode(I, O, R, S_M, Y, Y', Zi, Zo)$
|
|
with a solver $s : Solver(Y, Y', Zi, Zo, S_S)$
|
|
can also be seen as a synchronous node, operating on streams of dense functions.
|
|
Simulation steps take two forms: discrete steps perform state changes and side
|
|
effects, and continuous steps approximate the solution to the initial value
|
|
problem of the model and monitors for zero-crossing events. The simulation
|
|
alternates between these two modes as needed, switching from continuous to
|
|
discrete steps if a zero-crossing event occurs, and from discrete to continuous
|
|
steps if no additional discrete steps are necessary. A high-level overview of
|
|
the simulation's behaviour is given in @fig:sim_automaton.
|
|
|
|
#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)
|
|
),
|
|
caption: [
|
|
Overview of the simulation loop; $D$ and $C$ represent discrete
|
|
and continuous step modes
|
|
]
|
|
) <fig:sim_automaton>
|
|
|
|
The simulation's internal state stores five things: the internal states
|
|
$s_m : S_M$ and $s_s : S_S$ of the model and solver, respectively; the current
|
|
simulation $m o d e$ (either idle, discrete or continuous); a boolean flag $r$
|
|
indicating whether we should reset the solver before the next continous step
|
|
(see @sec:full_sim); the current input $i : Option(Dense(I))$; and the current
|
|
simulation time with respect to the input's domain $n o w in [0, i.h]$, used in
|
|
discrete steps to obtain the correct input. We use the same trick as with
|
|
continuous-time models to solve the problem of the ODE solver taking several
|
|
steps to integrate a single input value: we ask that the input stream contains
|
|
enough successive $None$ values for the ODE solver to finish integrating the
|
|
current input value (as explained in @sec:ode_solvers).
|
|
|
|
$ SimState(S_M, S_S, I) = {
|
|
s_m : S_M;
|
|
s_s : S_S;
|
|
m o d e : M o d e;
|
|
r : BB;
|
|
i : Option(Dense(I));
|
|
n o w: [0, i.h]
|
|
} $
|
|
|
|
The simulation of a hybrid system is then a discrete node on streams:
|
|
|
|
$ HSim : & HNode(I, O, R, S_M, Y, Y', Zi, Zo) ->
|
|
Solver(Y, Y', Zi, Zo, S_S) -> \
|
|
& DNode(Signal(I), Signal(O), R, SimState(S_M, S_S, I)) $
|
|
|
|
Its step function's behaviour varies depending on the simulation mode; the
|
|
following sections describe these behaviours in more details.
|
|
|
|
=== Discrete steps <sec:discrete_step>
|
|
|
|
A discrete step occurs whenever a zero-crossing event is triggered or a new
|
|
value is obtained by the simulation. Zero-crossing events may be triggered in
|
|
two different ways: either by detection using the zero-crossing solver during a
|
|
previous continuous step, or resulting from the action of a previous discrete
|
|
step as indicated by $f_"horizon"$. New input values require a discrete step to
|
|
be performed in order to reset the underlying solver. Discrete steps may modify
|
|
the entire model state, and perform side effects. The simulation's physical time
|
|
does not advance during a discrete step.
|
|
|
|
#note[Should we explain cascades in detail?]
|
|
|
|
A discrete step of the simulation simply calls the model's $f_"step"$ function
|
|
with the appropriate inputs, constructs a dense function defined on $[0,0]$
|
|
using the output (as described in @sec:hybrid_mod, this represents a discrete
|
|
step), and updates the simulation state as needed for the next step. Four
|
|
possible situations arise. If the model's indicated horizon (obtained with the
|
|
$f_"horizon"$ function defined by the model) requires us to perform another
|
|
discrete step, we keep the simulation in discrete mode. If no other discrete
|
|
step must be performed, but we are done integrating the current input (the
|
|
current time $n o w$ is greater than or equal to the current input's horizon
|
|
$i.h$), we cannot proceed further, and must wait for additional input; and so we
|
|
switch to idle mode. If no other discrete step must be performed and we have not
|
|
reached the input's horizon, we switch to continuous mode. In this case, we may
|
|
have to reset the solver. This occurs if the current discrete step has caused a
|
|
discontinuity (as indicated by the model's $f_"jump"$ function), or if the
|
|
state's reset flag $r$ is set, in which case we build out a new initial value
|
|
problem and zero-crossing problem using the current model state and reset the
|
|
solver.
|
|
|
|
A possible implementation of the discrete step in #ocaml is given in
|
|
@lst:sim_step_discrete.
|
|
|
|
#figure(
|
|
```ocaml
|
|
let dstep (HNode model) (DNode solver) state =
|
|
let i = Option.get state.i in
|
|
let (o, sm) = model.step state.sm (i.u state.now) in
|
|
let state =
|
|
if model.horizon sm <= 0 then { state with sm }
|
|
else if state.now >= i.h then { state with mode=Idle; i=None; sm }
|
|
else if model.jump sm || state.r then (* Reset solver. *)
|
|
let ivp = { h=i.h; y0=model.cget sm; f=fun t y -> model.fder sm (i.u t) y } in
|
|
let zcp = { y0=model.cget sm; f=fun t y -> model.fzer sm (i.u t) y } in
|
|
let ss = solver.reset (ivp, zcp) state.ss in
|
|
{ state with mode=Continuous; sm; ss; r=false }
|
|
else { state with mode=Continuous; sm } in
|
|
(Some { h=0.0; u=fun _ -> o }, state)
|
|
```,
|
|
caption: [Discrete simulation step in #ocaml]
|
|
) <lst:sim_step_discrete>
|
|
|
|
=== Continuous steps <sec:continuous_step>
|
|
|
|
Continuous steps advance time, approximate the solution to the model's initial
|
|
value problem using the ODE solver, and monitor the model's zero-crossing
|
|
function for zero-crossing events using the zero-crossing solver. They operate
|
|
on a restricted part of the model's state; only the continuous part of the state
|
|
may be modified, and the discrete part is constant. Furthermore, no side-effects
|
|
may occur during continuous steps. These restrictions are enforced by a typing
|
|
pass during the compilation process @cit:sync_based_codegen_hyb_sys_lng.
|
|
|
|
A continuous step performs a call to the solver to obtain both an approximation
|
|
of the solution to the model's initial value problem and an optional
|
|
zero-crossing event. It builds a dense function representing the output on the
|
|
approximation's domain using the model's $f_"out"$ function. Then, it updates
|
|
the simulation state as needed for the next step. Once again, four possible
|
|
situations arise. If a zero-crossing event has occured, we must perform a
|
|
discrete step, and so we switch to discrete mode and update the model's state to
|
|
take into account the zero-crossing event with the $z_"set"$ function. If no
|
|
zero-crossing event has occurred, but we have reached the end of the current
|
|
input (the horizon reached by the solver is greater than or equal to the current
|
|
input's horizon), we must perform a discrete step as well, and so we switch to
|
|
discrete mode. If no zero-crossing event has occured and we have not reached the
|
|
current input's horizon, but we have reached the model's desired stopping point
|
|
(as indicated by the model's $f_"horizon"$ function), we must again perform a
|
|
discrete step, and so we switch to discrete mode. Otherwise, we can continue
|
|
integrating; we keep the simulation mode as continuous.
|
|
|
|
A possible implementation of the continuous step in #ocaml is given in
|
|
@lst:sim_step_continuous.
|
|
|
|
#figure(
|
|
```ocaml
|
|
let cstep (HNode model) (DNode solver) state =
|
|
let i = Option.get state.i in
|
|
let stop = min (model.horizon state.sm) i.h in
|
|
let (({ h=now; u=dky }, z), ss) = solver.step state.ss stop in
|
|
let sm = model.cset state.sm (dky now) in
|
|
let out = { h=now; u=fun t -> model.fout sm (i.u t) (dky t) } in
|
|
let state = match z with
|
|
| Some z ->
|
|
let sm = model.zset sm z in
|
|
{ state with mode=Discrete; sm; ss; now }
|
|
| None ->
|
|
if model.horizon sm <= 0.0 || now >= i.h
|
|
then { state with mode=Discrete; sm; ss; now }
|
|
else { state with mode=Continuous; sm; ss; now } in
|
|
(Some out, state)
|
|
```,
|
|
caption: [Continuous simulation step in #ocaml]
|
|
) <lst:sim_step_continuous>
|
|
|
|
=== Complete definition <sec:full_sim>
|
|
|
|
The full step function then performs the correct kind of step depending on the
|
|
simulation mode; if the mode is Idle, it simply returns $None$ and the
|
|
unmodified state. When a new dense function is provided as input, it updates the
|
|
current input and time, sets the reset flag, and switches to discrete mode. Once
|
|
again, it expects the input stream to contain as many successive $None$ values
|
|
as needed for the solver to integrate the entire input, as explained in
|
|
@sec:ode_solvers.
|
|
|
|
The simulation's reset function simply resets the model using its $f_"reset"$
|
|
function, sets the simulation mode to Idle, and sets the reset flag $r$ in the
|
|
simulation state, so that the next discrete step resets the solver before
|
|
integration.
|
|
|
|
Finally, its initial state is simply the initial states of the model and solver,
|
|
the mode set to idle, an empty current input ($None$) and a current time set at
|
|
0\.
|
|
|
|
Its implementation in #ocaml is given in @lst:sim_algorithm.
|
|
|
|
#figure(
|
|
```ocaml
|
|
let hsim (HNode model) (DNode solver) =
|
|
let s0 = { mode=Idle; i=None; now=0.0; sm=model.s0; ss=solver.s0; r=true } in
|
|
let step state i = match (i, state.mode) with
|
|
| Some _, Idle ->
|
|
let state = { state with mode=Discrete; i; now=0.0; r=true } in
|
|
dstep (HNode model) (DNode solver) state
|
|
| None, Discrete -> dstep (HNode model) (DNode solver) state
|
|
| None, Continuous -> cstep (HNode model) (DNode solver) state
|
|
| None, Idle -> (None, state)
|
|
| Some _, _ -> assert false in
|
|
let reset state r =
|
|
{ state with mode=Idle; sm=model.reset r state.sm; r=true } in
|
|
DNode { s0; step; reset }
|
|
```,
|
|
caption: [Simulation of a hybrid model in #ocaml]
|
|
) <lst:sim_algorithm>
|
|
|
|
== Implementation Details <sec:impl_details>
|
|
|
|
While the above algorithm works, it suffers from a few flaws which limit its
|
|
efficiency. Indeed, resetting the solver at every new input value is
|
|
counterproductive. ODE solvers with adaptive step lengths (such as #sundials)
|
|
begin integration by performing very small steps in time, and increase their
|
|
step length later, as they obtain more information on the function they are
|
|
currently integrating. Resetting the solver slows down the progress of the
|
|
simulation, as such ODE solvers will perform shorter steps than if they had not
|
|
been reset.
|
|
|
|
If two successive input values can be considered to be continuous (that is, the
|
|
second one only extends the first, with no discontinuity at the joining point),
|
|
there is no particular reason why we should reset the solver. This occurs for
|
|
instance between successive continuous steps. The ODE solver does not itself
|
|
introduce discontinuities, and so the simulation should not reset its solver if
|
|
taking as input two successive continuous steps of an ODE solver. As a first
|
|
solution, we can equip our dense values with an additional bit of information
|
|
$c$ representing whether the next value in the stream is simply an extension of
|
|
themselves:
|
|
|
|
$ Dense(V) eq.def { h : Time; u : [0, h] -> V; c : BB } $
|
|
|
|
When building the output value, the simulation knows how this output value
|
|
behaves compared to the next one: if we just performed a continuous step and the
|
|
next step is also continuous, we know that the next output value will simply be
|
|
an extension of the current one, and we can include this information in the
|
|
current output value. In all other cases, it is safe to consider that successive
|
|
values are discontinuous. When a simulation receives an input value, it can then
|
|
reset the solver only if necessary.
|
|
|
|
Another issue comes from the impossibility of stepping the solver more than once
|
|
per step of the simulation, as seen in @sec:ode_solvers. Since adaptive solver
|
|
begin with small integration steps, output values will be defined on small
|
|
intervals. If we compose simulations together, the resulting output will be
|
|
defined on smaller and smaller intervals, even though this is not always
|
|
necessary, as the ODE solvers do not introduce discontinuities between their
|
|
steps.
|
|
|
|
We can impose another restriction on our ODE solvers to mitigate this issue. If
|
|
the solver provides a function to copy its internal state, allowing us to
|
|
preserve the validity of the previous approximations, we can safely step the
|
|
solver multiple times per step of the simulation and concatenate the results. A
|
|
discrete node with state copies operating on a state $S$ defines an additional
|
|
function $f_"copy" : S -> S$ returning a copy of the inner state, which may be
|
|
used for the rest of the simulation. Previously computed approximations then
|
|
depend on the original copy of the state, which remains untouched by later
|
|
steps.
|
|
|
|
$ DNodeC(I, O, R, S) eq.def {
|
|
s_0 : S;
|
|
f_"step" : S -> I -> O times S;
|
|
f_"reset" : S -> R -> S;
|
|
f_"copy" : S -> S
|
|
} $
|
|
|
|
Given a solver with state copies, the simulation can then perform multiple steps
|
|
of the solver, performing a state copy in between each step and concatenating
|
|
the approximations returned by the solver. This concatenation
|
|
$j o i n : Dense(V) times Dense(V)-> Dense(V)$ is defined as expected:
|
|
|
|
$ j o i n(l, r) = {
|
|
h = l.h + r.h;
|
|
u = lambda t. "if" t <= l.h "then" l.u(t) "else" r.u(t + l.h)
|
|
} $
|
|
|
|
This does not free us from the option type in our signals, however; the
|
|
simulation may still produce more than one dense function as output per dense
|
|
function as input (for instance, if a zero-crossing event occurs).
|
|
|
|
== Lifting the Runtime <sec:lifting_runtime>
|
|
|
|
An interesting consequence of interpreting simulations as discrete nodes is that
|
|
we can reasonably consider manipulating them directly inside the language. This
|
|
takes the form of a module in #zelus' standard library, providing several
|
|
primitives and utility functions to create and manipulate simulations and
|
|
signals. For instance, the function
|
|
|
|
```zelus
|
|
val solve : ('i -C-> 'o) -S-> ('i signal -D-> 'o signal)
|
|
```
|
|
|
|
takes as input a continuous-time model (this is indicated by the `-C->` arrow)
|
|
from `'i` to `'o` and producing a discrete-time node (indicated by the `-D->`
|
|
arrow) from `'i signal` to `'o signal` #footnote[
|
|
Due to some restrictions in the higher-order facilities of #zelus, the
|
|
argument to `solve` must be statically known (that is, we must be able to
|
|
allocate the necessary memory before starting the execution of the program);
|
|
this is represented by the `-S->` arrow.
|
|
]. It represents the simulation _with a dedicated instance_ of an ODE solver of
|
|
its argument; that is, the ODE solver used to simulate the argument of `solve`
|
|
is separate from the one used for the rest of the program. We can now choose
|
|
which parts of our program we wish to simulate in isolation from the rest. The
|
|
primitives `compose` and `synchr`, whose signatures are
|
|
#grid(columns: (1fr, 1fr),
|
|
```zelus
|
|
val compose :
|
|
('a signal -D-> 'b signal) -S->
|
|
('b signal -D-> 'c signal) -S->
|
|
('a signal -D-> 'c signal)
|
|
```,
|
|
```zelus
|
|
val synchr :
|
|
('a signal -D-> 'b signal) -S->
|
|
('a signal -D-> 'c signal) -S->
|
|
('a signal -D-> ('b * 'c) signal)
|
|
```
|
|
)
|
|
allow for the composition and synchronization of independent simulations.
|
|
Indeed, standard composition of simulations does not work: the output of the
|
|
first would not take into account that the second simulation requires $None$
|
|
values as input until it is done integrating its current input. We need to
|
|
ensure that this requirement is met. This is simple: to simulate
|
|
$f circle.small g$, step $g$ and then $f$ when $f$ is done integrating (i.e.
|
|
when $f$ returns $None$), otherwise only step $f$. Furthermore, two simulations
|
|
will not necessarily advance at the same speed, and a synchronization primitive
|
|
is required to simulate two systems in parallel. Its behaviour is simple: step
|
|
only the simulation that has not progressed as far as the other one, and return
|
|
the solution only on the interval on which both are defined.
|
|
|
|
A possible implementation of both of these in #ocaml is given in
|
|
@sec:additional_code, but it is interesting to note that both of these could
|
|
just as well be implemented in discrete #zelus, given the right tools to
|
|
manipulate dense functions (in particular, getting their horizon and splitting
|
|
them into two smaller, successive dense functions). One could even imagine that
|
|
simulations themselves are implemented in #zelus\; they are only discrete nodes
|
|
implementing a particular automaton, and discrete #zelus provides all of the
|
|
necessary constructs to implement automata. Given an interface allowing us to
|
|
instanciate and call a solver, this seems entirely feasible.
|
|
|
|
= Hybrid Observers and Assertions <sec:hybrid_observers>
|
|
|
|
During the design and implementation of software, programmers often use
|
|
assertions in order to check certain properties, both before and during
|
|
execution. In #ocaml, for instance, the ```ocaml assert``` instruction checks
|
|
that a certain expression evaluates to ```ocaml true``` at runtime, and raises
|
|
an exception otherwise. This is an example of a defensive use of assertions as a
|
|
way to avoid unwanted behaviour: the programmer chooses to suspend execution
|
|
rather than proceed with a state which they consider invalid.
|
|
|
|
An important property of assertions is that they are _transparent_: their
|
|
presence does not affect the result of the computation. Of course, this property
|
|
requires the expression evaluated in the assertion to not cause any visible
|
|
side-effects: in #ocaml, if the assertion modifies mutable data or performs I/O
|
|
operations, executing the assertion is not transparent. Still, if the
|
|
subexpression respects certain criteria, we can safely assume that the presence
|
|
of the assertion will not affect the final result.
|
|
|
|
In synchronous languages like #lustre, the classical way to represent such an
|
|
assertion is an _observer_: a node whose sole purpose is to monitor its input
|
|
stream to check a certain property. @lst:observer_discrete gives an example of
|
|
an assertion in discrete #zelus, and an idealized translation of this assertion
|
|
into a separate observer node. The `assertion_f` node monitors its input stream,
|
|
and warns the user if a property is not respected (here, that a certain value,
|
|
obtained by integrating another stream with the `integr` node of
|
|
@lst:sincos_discrete, is always positive); otherwise it has no effect. In
|
|
particular, if its input stream respects the property, the result of the
|
|
simulation does not change whether the call to `assertion_f` in `f` is included
|
|
or not.
|
|
|
|
#figure(
|
|
placement: top,
|
|
grid(
|
|
columns: (100fr, 100fr),
|
|
align: horizon,
|
|
```zelus
|
|
let node f (*...*) =
|
|
let v = (*...*) in
|
|
assert (
|
|
let p = integr(0.0, v) in
|
|
p >= 0.0
|
|
); (*...*)
|
|
```,
|
|
```zelus
|
|
let node assertion_f(v) =
|
|
let p = integr(0.0, v) in
|
|
p >= 0.0
|
|
|
|
let node f (*...*) =
|
|
let v = (*...*) in
|
|
(match assertion_f(v) with
|
|
| true -> print_endline "ERROR"
|
|
| false -> ());
|
|
(*...*)
|
|
```,
|
|
),
|
|
caption: [A discrete assertion and its idealized translation as an observer. ]
|
|
) <lst:observer_discrete>
|
|
|
|
In general, a discrete assertion on a model $M : DNode(I, O, R, S)$ can be seen
|
|
as another node whose input stream is the state $S$ of the parent model, and
|
|
whose output stream is a Boolean value; it defines its own internal state, with
|
|
its own local variables, subnodes, and so on and so forth. The parent model then
|
|
calls the assertion with its internal state during each step.
|
|
|
|
In continuous-time, one could imagine a similar way of encoding such behaviour.
|
|
@lst:observer_continuous presents a possible implementation of the behaviour of
|
|
@lst:observer_discrete in continuous time. Rather than using inequalities, which
|
|
are considered discontinuous and are not allowed in continuous code, we use a
|
|
zero-crossing event to monitor the signal, and perform the appropriate side
|
|
effect if the event occurs. The integration is once again handled by the
|
|
simulation, as described in @sec:sim_algorithm.
|
|
|
|
#figure(
|
|
placement: top,
|
|
```zelus
|
|
let hybrid assertion_f(v) =
|
|
let der p = v init 0.0 in
|
|
up(-. p)
|
|
|
|
let hybrid f (*...*) =
|
|
let v = (*...*) in
|
|
(present(assertion_f(v)) -> print_endline "ERROR" else ());
|
|
(*...*)
|
|
```,
|
|
caption: [A naïve implementation of a continuous observer.]
|
|
) <lst:observer_continuous>
|
|
|
|
Unfortunately, this implementation does not meet our criteria for assertions.
|
|
Indeed, adding ODEs to a model changes the approximation returned by the ODE
|
|
solver, as explained in @sec:ode_solvers, even if the new ODEs are entirely
|
|
unrelated to the existing ones. The implementation in @lst:observer_continuous
|
|
does not separate the body of the assertion from its parent model, and the
|
|
simulation runs both the model and its assertion at the same time. Therefore,
|
|
the assertion may impact the results of its parent model, and is not
|
|
transparent.
|
|
|
|
We wish instead to simulate the assertion independently from its parent model,
|
|
with its own ODE solver.
|
|
|
|
== Models With Assertions <sec:models_with_assertions>
|
|
|
|
Since an assertion can be considered as a separate model operating on the inner
|
|
state of its parent model, we can represent a model with assertions as a pair
|
|
of the parent model $m$, operating on its inner state $S_M$, and a list of
|
|
assertion models. All assertions operate on the same input datatype $S_M$ (the
|
|
state of the parent model) and return Boolean output signals $BB$.
|
|
|
|
$ ANode(I, O, R, S_M, Y, Y', Zi, Zo) eq.def {
|
|
& m : HNode(I, O, R, S_M, Y, Y', Zi, Zo); \
|
|
& a : List(ANode(S_M, BB, R_A, S_A, Y_A, Y'_A, Zi_A, Zo_A))
|
|
} $
|
|
|
|
Note <t> that the output signal is Boolean even in continuous time. There are
|
|
two possible interpretations of this: either assertions benefit from relaxed
|
|
typing rules for their output, and are allowed a limited subset of discrete
|
|
behaviours in continuous time; or assertions in continuous time are defined in
|
|
terms of zero-crossing events, and as such the output of the assertion will be
|
|
constant during continuous phases (in fact, the output will be constantly true,
|
|
as the simulation would not have entered a continuous step otherwise). Both
|
|
interpretations lead to the same updated simulation algorithm, as we will see in
|
|
@sec:updated_sim. The $ANode$ datatype is recursive: indeed, nothing prevents
|
|
assertions from containing their own assertions, and so on and so forth.
|
|
|
|
#todo[Clarify why Boolean values as output is surprising.]
|
|
|
|
While this representation allows for a lot of expressivity, in most cases, a
|
|
model with a single assertion suffices. Multiple assertions may be combined as
|
|
a single one by simply taking the conjunction of their outputs, and nested
|
|
assertions (assertions within assertions) can be checked as part of the
|
|
simulation of their parents. We can then define a simpler datatype
|
|
|
|
$ ASNode(I, O, R, S_M, Y, Y', Zi, Zo) eq.def {
|
|
& m : HNode(I, O, R, S_M, Y, Y', Zi, Zo); \
|
|
& a : DNode(Signal(S_M), BB, R, S_A)
|
|
} $
|
|
|
|
where the assertion is a single, discrete node operating on dense functions of
|
|
the model state and returning Boolean values. During compilation, assertions are
|
|
compiled down to hybrid models, and turned into discrete simulations using a
|
|
variant of the `solve` function of @sec:lifting_runtime. This is the current
|
|
target model of the #zelus compiler; however, simulations of both $ANode$ and
|
|
$ASNode$ are implemented in the runtime.
|
|
|
|
== Updated Simulation <sec:updated_sim>
|
|
|
|
The simulation of a system with assertions requires little adjustments from the
|
|
original simulation algorithm. The main difficulty resides in the fact that we
|
|
need to construct a dense function of the entire parent model's state. For
|
|
discrete steps, this is simple: we build a constant function on the model's
|
|
state defined on the interval $[0, 0]$. For continuous steps, the ODE solver
|
|
provides us with a dense function of the continuous part of the state. Since the
|
|
discrete part of the state is constant during integration, we can use the
|
|
approximation returned by the solver, combined with the model's $c_"set"$
|
|
function, to build a dense function of the entire state. We need to be careful,
|
|
however: if the $c_"set"$ function rewrites the model's state in-place (this is
|
|
the case in the code produced by the #zelus compiler), we must update the state
|
|
back to its value at the horizon reached by the ODE solver before starting the
|
|
next simulation step.
|
|
|
|
The simulation state then stores a copy of the model's continuous state at the
|
|
reached horizon after every continuous step. Before every step, we update the
|
|
model's state to ensure its correctness. Continuous steps now produce an
|
|
additional dense function of the state, which is used as input to the assertion.
|
|
A step of the simulation then performs a step of the parent model, as seen in
|
|
@sec:sim_algorithm, and uses the additional output to step the simulation of
|
|
the assertion as often as needed for it to reach the model's reached time,
|
|
checking the assertion's output at each step.
|
|
|
|
The updated #ocaml implementation is given in @lst:sim_assert.
|
|
|
|
#figure(
|
|
```ocaml
|
|
let acstep (HNode model) (DNode solver) state =
|
|
let i = (*...*) in let stop = (*...*) in
|
|
let (({ h=now; u=dky }, z), ss) = solver.step state.ss stop in
|
|
let out = { h=now; u=fun t -> model.fout state.sm (i.u t) (dky t) } in
|
|
let dst = { h=now; u=fun t -> model.cset state.sm (dky t) } in
|
|
let state = (*...*) in
|
|
(Some out, state, Some dst)
|
|
|
|
let asim (ASNode { m=HNode model; a=DNode assertion }) (DNode solver) =
|
|
let s0 = { (*...*); y=None; sa=a.state } in
|
|
let dstep state =
|
|
let state = { state with sm=model.cset state.sm state.y } in
|
|
let o, state = dstep (HNode model) (DNode solver) state in
|
|
let y = model.cget state.sm in
|
|
let b, sa = assertion.step state.sa (Some { h=0.0; u=fun _ -> state.sm }) in
|
|
assert b; (o, { state with sa; y }) in
|
|
let cstep state =
|
|
let state = { state with sm=model.cset state.sm state.y } in
|
|
let o, state, st = acstep (HNode model) (DNode solver) state in
|
|
let y = model.cget state.sm in
|
|
let b, sa = assertion.step state.sa st in
|
|
assert b; (o, { state with sa; y }) in
|
|
let step state i = match (i, state.mode) with
|
|
| (*...*)
|
|
| None, Discrete -> dstep state
|
|
| None, Continuous -> cstep state
|
|
| (*...*) in
|
|
let reset state r =
|
|
let sm = model.reset r state.sm and sa = assertion.reset r state.sa in
|
|
{ state with mode=Idle; sm; sa; r=true } in
|
|
DNode { s0; step; reset }
|
|
```,
|
|
caption: [Simulation of a model with assertions in #ocaml]
|
|
) <lst:sim_assert>
|
|
|
|
= Future Work <sec:future_work>
|
|
|
|
#todo[
|
|
What do we do next? Depends on how much the #zelus compiler is already able to
|
|
do: compiling the source language into the internal representation with an
|
|
additional model is a good next step, compiling the source language into a
|
|
complex, recursive datatype, etc...
|
|
]
|
|
|
|
= Conclusion <sec:conclusion>
|
|
|
|
#todo[
|
|
Conclude.
|
|
]
|
|
|
|
#pagebreak()
|
|
|
|
= Bibliography <sec:bibliography>
|
|
|
|
#bibliography(
|
|
"sources.bib",
|
|
// full: true,
|
|
style: "alphanumeric.csl",
|
|
title: none
|
|
)
|
|
#todo[Find a proper bibliographic style.]
|
|
|
|
= Appendix <sec:appendix>
|
|
|
|
== Additional Code <sec:additional_code>
|
|
|
|
#set figure(placement: none)
|
|
|
|
#figure(
|
|
```ocaml
|
|
(** Discrete-time model. *)
|
|
type ('i, 'o, 'r, 's) dnode =
|
|
DNode of { s0 : 's; step : 's -> 'i -> 'o * 's; reset : 's -> 'r -> 's }
|
|
(** Continuous-time model. *)
|
|
type ('i, 'o, 's, 'sd) cnode =
|
|
CNode of { s0 : 's; fder : 'i -> 's -> 'sd; fout : 'i -> 's -> 'o }
|
|
(** Dense values. *)
|
|
type 'a dense = { h : time; u : time -> 'a }
|
|
(** Initial value problem. *)
|
|
type ('y, 'yd) ivp = { y0 : 'y; stop : time; f : time -> 'y -> 'yd }
|
|
(** ODE solver. *)
|
|
type ('y, 'yd, 's) csolver = (time, 'y dense, ('y, 'yd) ivp, 's) dnode
|
|
(** Hybrid simulation mode. *)
|
|
type mode = Discrete | Continuous | Idle
|
|
(** Hybrid simulation state. *)
|
|
type ('i, 'sm, 'ss) state =
|
|
{ sm : 'sm; ss : 'ss; mode : mode; r : bool; i : 'i dense option; now : time }
|
|
(** Zero-crossing problem. *)
|
|
type ('y, 'zo) zcp = { y0 : 'y; f : time -> 'y -> 'zo }
|
|
(** Zero-crossing solver. *)
|
|
type ('y, 'zi, 'zo, 's) zsolver =
|
|
('y dense, time * 'zi option, ('y, 'zo) zcp, 's) dnode
|
|
(** Hybrid model. *)
|
|
type ('i, 'o, 'r, 's, 'y, 'yd, 'zi, 'zo) hnode =
|
|
HNode of { s0 : 's;
|
|
cget : 's -> 'y; cset : 's -> 'y -> 's; zset : 's -> 'zi -> 's;
|
|
horizon : 's -> time; jump : 's -> bool; reset : 's -> 'r -> 's;
|
|
step : 's -> 'i -> 'o * 's; fder : 's -> 'i -> 'y -> 'yd;
|
|
fzero : 's -> 'i -> 'y -> 'zo; fout : 's -> 'i -> 'y -> 'o }
|
|
(** Full solver. *)
|
|
type ('y, 'yd, 'zi, 'zo, 's) solver =
|
|
(time, 'y dense * 'zi option, ('y, 'yd) ivp * ('y, 'zo) zcp, 's) dnode
|
|
(** Hybrid model with a single assertion. *)
|
|
type ('i, 'o, 'r, 'sm, 'sa, 'y, 'yd, 'zi, 'zo) asnode =
|
|
ASNode of { m : ('i, 'o, 'r, 'sm, 'y, 'yd, 'zi, 'zo) hnode;
|
|
a : ('sm signal, bool, 'sa) dnode }
|
|
```,
|
|
caption: [#ocaml type definitions]
|
|
) <lst:ocaml_typedefs>
|
|
|
|
#figure(
|
|
```ocaml
|
|
let compose (DNode f) (DNode g) =
|
|
let s0 = (f.s0, g.s0) in
|
|
let step (sf, sg) = function
|
|
| Some i ->
|
|
let (v, sg) = g.step sg (Some i) in
|
|
let (o, sf) = f.step sf v in
|
|
(o, (sf, sg))
|
|
| None ->
|
|
let (o, sf) = f.step sf None in
|
|
match o with
|
|
| Some _ -> (o, (sf, sg))
|
|
| None ->
|
|
let (v, sg) = g.step sg None in
|
|
match v with
|
|
| None -> (None, (sf, sg))
|
|
| Some _ ->
|
|
let (o, sf) = f.step sf o in
|
|
(o, (sf, sg)) in
|
|
let reset (sf, sg) (rf, rg) = (f.reset sf rf, g.reset sg rg) in
|
|
DNode { s0; step; reset }
|
|
```,
|
|
caption: [Composition of simulations in #ocaml]
|
|
) <lst:compose>
|
|
|
|
#figure(
|
|
```ocaml
|
|
let synchr (DNode f) (DNode g) =
|
|
```,
|
|
caption: [Synchronization of simulations in #ocaml]
|
|
) <lst:synchr>
|
|
|
|
#todo[Complete @lst:synchr.]
|
|
|
|
#note[
|
|
Ideas:
|
|
- Detailed synchronous kernel of #zelus
|
|
]
|