-
-
Notifications
You must be signed in to change notification settings - Fork 40
Time stepping
bdsim advances simulation time with a hybrid solver: a numerical integrator for continuous
dynamics, interleaved with a scheduled-event queue that also drives clock ticks, animation/movie
frames, and discontinuity markers. This page covers how those three things — integration, animation,
and events — fit together, since animation frames turn out to be just another kind of scheduled event
sharing the same queue and the same outer loop that clock ticks and discontinuities use. For the
integrator's own state-vector/derivative mechanics, see Evaluation.
bdsim in simulation mode supports two different types of events:
-
Schedule events (SE) which are generated by clocks in sampled-data systems, and by source blocks with discontinuous output (as at the transition of a step signal, or the transitions in a square or triangle wave signal).
-
Zero-crossing events (XE) which are generated by EVENT blocks when their input passes through zero.
The heart of bdsim is the numerical integrator, specifically solve_ivp which integrates a first-order ODE over the simulation interval. Events cause the integration to performed over subintervals. SEs occur at predetermined times, whereas XEs are a function of system state and their time is not known apriori.
A key data structure is the scheduled event queue TimeQ (in components.py) is a min-heap of (time: float, seq: int, payload: Clock | Block) tuples.
time is simulation time in seconds, that is time since the beginning of the simulatin.
seq is a monotonic integer used as a tie-breaker so that same-time events are
dispatched in insertion order.
simstate.eventq # a TimeQ instance, one per run
simstate.declare_event(source, t) # push (t, source) onto the queuesource can be:
| Type | Meaning |
|---|---|
Clock instance |
A discrete clock tick. Clock.__call__ handles it. |
| callable (lambda/function) | Animation frame, system-tick, or any ad-hoc event. |
Block instance (EventSource) |
A timing marker only — tells the integrator to stop at this time so the ODE solver sees the discontinuity cleanly. The block is not called; bd.evaluate handles it via normal output logic. |
None |
Terminal boundary marker for tf. |
TimeQ.pop(dt=0.0) pops the earliest entry and returns all entries within
dt seconds of it as a group, so simultaneous events are dispatched together.
TimeQ.pop(dt) → (t, [source1, source2, ...])
TimeQ.pop_until(t) removes everything up to and including time t without
returning it — used at run start to flush any stale entries from before t0.
Blocks that subclass EventSource (e.g., STEP, WAVEFORM, PIECEWISE, RAMP)
declare their discontinuity times during start():
# STEP block
def start(self, simstate):
simstate.declare_event(self, self.T) # self = the block instance
# WAVEFORM block (square wave at 1 Hz)
def start(self, simstate):
t1, t2 = phase/freq, (duty+phase)/freq
while t1 < simstate.tf:
simstate.declare_event(self, t1)
simstate.declare_event(self, t2)
t1 += T; t2 += TThese block instances are not callable, so the outer loop's dispatch does nothing with them:
for source in sources:
if callable(source): # Block instances fail this — skipped
source(interval_end, simstate)Their purpose is purely to force the event queue (and thus solve_ivp) to stop at
the discontinuity time, so the integrator does not stride across a step change. After
stopping, bd.evaluate is called at that exact time and block.output(t) returns the
correct post-discontinuity value.
For each detector that fired, _dispatch_crossing_event(block, t_crossing, y_crossing, simstate) is called. It:
- Re-evaluates the block diagram at
(t_crossing, y_crossing)to rebind block outputs to the crossing state. - Calls
block.event_handler(t_crossing, y_crossing, state_map, simstate)(oron_event/handle_eventfor backward compatibility) — falling back to a 3-argument call, withoutstate_map, if that raisesTypeError.
A crossing causes solve_ivp to stop at that time, so treached < interval_end.
The outer loop then reschedules the original boundary and continues from treached.
These are two separate mechanisms:
| Scheduled events | Crossing events | |
|---|---|---|
| Registered via | simstate.declare_event(source, t) |
simstate.declare_crossing_event(detector, block) |
| Known time? | Yes — exact time is in the queue | No — time is found by root-finding |
| Mechanism |
TimeQ min-heap |
solve_ivp events= parameter |
| Dispatch | Outer loop pops queue, calls source(t, simstate)
|
_dispatch_crossing_event after solve_ivp returns |
| Example use | Clock ticks, step discontinuities, animation frames |
EVENT block (threshold crossing), STOP block |
A crossing event causes solve_ivp to terminate the interval early (treached < interval_end). The outer loop detects this, reschedules the original boundary, and
re-enters the loop from treached.
When animation=True (or a movie is being recorded, or the interactive debugger is active, see
Animation and movies), bdsim computes interactive_dt = 1 / animation_rate
and pushes a self-rescheduling _anim_frame callable onto the event queue at that cadence — the same
queue, and the same simstate.declare_event(source, t) mechanism, used for clock ticks and
EventSource discontinuities above. Recording a movie without live animation=True still schedules
these ticks; the frame-grab happens from the same refresh hook, just without presenting anything to a
screen.
Because animation ticks sit in the shared queue, they become interval boundaries for the outer loop
exactly like a clock tick would: each solve_ivp call only integrates up to the next animation tick
(or clock tick, or terminal boundary), stops, dispatches the tick (which refreshes the display and/or
grabs a movie frame), and is called again fresh for the next interval. Within an interval the
integrator still chooses its own adaptive step size as normal — this isn't about micro-stepping, it's
about how often the integrator gets interrupted and restarted.
That restart has a real cost: each solve_ivp call is made with no first_step hint carried over
from the previous interval, so SciPy re-picks a conservative initial step from scratch every time. A
high animation_rate relative to your system's actual timescale means frequent restarts and more
total integrator function evaluations than running the same model with animation and movies off. If
you don't need to watch a run live, prefer animation=False with a movie recorded at a modest
animation_rate over a high one chosen purely for a smoother-looking video.
The interactive debugger (-d i) goes one step further: it also hard-clamps max_step — a cap on the
integrator's own internal adaptive step size, not just the interval-boundary cadence — to
interactive_dt, so a single internal step can never overshoot the debugger's refresh rate either.
tprev = 0.0
while tprev < tf - event_tol:
tnext, sources = simstate.eventq.pop(dt=1e-6)
interval_end = min(tnext, tf)
if interval_end <= tprev + event_tol:
# zero-length interval: just dispatch the sources
for source in sources:
if callable(source):
source(interval_end, simstate)
tprev = interval_end
continue
# attempt to integrate over the interval [tprev, interval_end]
x, treached = interval_fn(bd, tprev, interval_end, x, simstate)
if simstate.stop is not None:
break
if treached >= interval_end - event_tol:
# reached the scheduled boundary — dispatch sources
for source in sources:
if callable(source):
source(interval_end, simstate)
tprev = interval_end
else:
# integration stopped early (crossing event)
if simstate.stop is not None:
break
# reschedule the boundary for later and advance past the crossing
for source in sources:
simstate.declare_event(source, tnext)
tprev = treachedThe loop always terminates because either:
-
tprevadvances tointerval_endon a normal step, or -
tprevadvances totreached(>tprev) on an early stop, or -
simstate.stopis set and breaks out.
Copyright (c) Peter Corke 2020-
- Home
- API reference (Sphinx)
- Block catalog
- Control Systems Magazine article
- Adding blocks to your model
- Block path
- Connecting blocks
- Compiling
- Running
- Watching a simulation variable
- Simulation results
- Runtime options
- Environment variables
- Discrete-time blocks
- Subsystems
- Figures
- Notebook animation
- Animation and movies
- PID control
- Coding patterns
- Block methods and attributes
- Time stepping: integration, animation & events
- Blocks, wires and plugs
- Graphics blocks
- Evaluation
- Runtimes and simulator state
- Creating a new block
- Related packages
Under development on feat/realtime branch, planned for release before end of 2026.