Skip to content

Time stepping

Peter Corke edited this page Aug 19, 2026 · 1 revision

Time stepping: integration, animation & events

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.

Scheduled events

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 queue

source 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.

EventSource blocks (timing markers)

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 += T

These 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.

Crossing events

For each detector that fired, _dispatch_crossing_event(block, t_crossing, y_crossing, simstate) is called. It:

  1. Re-evaluates the block diagram at (t_crossing, y_crossing) to rebind block outputs to the crossing state.
  2. Calls block.event_handler(t_crossing, y_crossing, state_map, simstate) (or on_event / handle_event for backward compatibility) — falling back to a 3-argument call, without state_map, if that raises TypeError.

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.

Crossing events vs scheduled events

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.

Animation and movie frames are scheduled events

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.

The outer loop

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 = treached

The loop always terminates because either:

  • tprev advances to interval_end on a normal step, or
  • tprev advances to treached (> tprev) on an early stop, or
  • simstate.stop is set and breaks out.

Clone this wiki locally