Context Manager#

Note

You need one AO card and one DO card to run this tutorial.

Streamer setup#

from nistreamer import NIStreamer
from nistreamer.utils import iplot
# ⚠️ Adjust to match your setup ⚠️

ni_strmr = NIStreamer()

ao_card = ni_strmr.add_ao_card('Dev2', samp_rate=400e3)
do_card = ni_strmr.add_do_card('Dev3', samp_rate=10e6)

ao_0 = ao_card.add_chan(chan_idx=0)
do_0 = do_card.add_chan(port_idx=0, line_idx=0)

START_TRIG = 'RTSI0'
ao_card.start_trig_out = START_TRIG
do_card.start_trig_in = START_TRIG
ni_strmr.starts_last = ao_card.max_name
# Minimal demo sequence
ni_strmr.clear_edit_cache()
ao_0.sine(t=0, dur=100e-3, amp=1.0, freq=12.34)
do_0.high(t=50e-3, dur=150e-3)
ni_strmr.compile();
iplot(chan_list=[do_0, ao_0])

A schematic of the demo pulse sequence. It is very simple - there is only a sine wave pulse on the analog channel and a single high pulse on the digital channel.

Context manager interface#

There are 2 types of stream control interfaces:

  • basic run method;

  • context manager API.

All previous tutorials used the basic approach:

ni_strmr.run()

A simplest example of context manager usage, equivalent to the above with run, looks like this:

with ni_strmr.init_stream() as handle:
    handle.launch()
    handle.wait_until_finished()

Breakdown:

  • with ni_strmr.init_stream() as handle initializes the stream and assigns StreamHandle instance to handle target.

  • launch() starts the run. Note: this call is non-blocking and returns immediately.

  • wait_until_finished() blocks script execution until the full waveform generation is finished.

  • Stream is automatically closed and all resources are released when leaving the context block due to any reason.

See the full documentation in StreamHandle reference section.

Note

You should always call wait_until_finished every time you called launch.

Use cases#

Context manager interface is needed in several cases:

  1. Custom logic between launches without re-init overhead;

  2. Using the in-stream looping feature.

(1) Customizable repeat#

The basic run(nreps) method already allows to replay sequence efficiently. But in some cases, you may need to do custom operations before/after each repetition - send commands to other instruments, save data, and so on.

This is how you would do it using the basic run:

reps = 10
for _ in range(reps):
    # ... custom logic before ...
    ni_strmr.run(nreps=1)  # <-- not using repeat here
    # ... custom logic after ...

Although functional, it is inefficient - the whole stream is initialized from scratch for every repetition, which is very costly.

Context manager interface was exposed to address this issue. Let’s re-write the above example using it:

reps = 10
with ni_strmr.init_stream() as handle:
    for _ in range(reps):
        # ... custom logic before ...
        handle.launch()
        # ... custom logic during ...
        handle.wait_until_finished()
        # ... custom logic after ...

In this example, the stream is only initialized once - when entering the context - and all repetitions reuse it, avoiding the re-init overhead. This is how run(nreps) is actually implemented under the hood for efficient replay, but it does not allow adding custom code there.

Notice that you can even execute some logic during waveform playing - launch starts generation and returns immediately, so you can do something in parallel to streamer playing. Just don’t forget to call wait_until_finished() once your custom logic is done, even if you think the run has already finished.

(2) In-stream looping#

In all examples above we replayed the sequence by re-launching the stream multiple times. Re-launching incurs overhead (not as large as re-initializing from scratch, but noticeable) resulting in a fluctuating time gap between repetitions:

An oscilloscope screenshot showing several consecutive repetitions. There is about 17 ms gap visible between repetitions due to re-launch overhead.

This is why a more advanced way of replaying is available - the in-stream looping feature. It is only exposed through launch(). Here is a minimal example:

with ni_strmr.init_stream() as handle:
    handle.launch(instream_reps=10)  # <-- notice this argument
    handle.wait_until_finished()

In-stream looping is very different from basic re-launching - all instream_reps iterations happen as a single continuous stream. As a result, the gap between subsequent repetitions is minimal and will not fluctuate:

The same oscilloscope screenshot, but now using the in-stream looping instead. There are no visible gaps between repetitions.

However, there are restrictions as compared to re-launching:

  • In-stream looping only works for sufficiently long sequences - a single repetition must exceed chunksize_ms (see launch() docs). In contrast, repetitive re-launching can be used with any sequence duration.

  • Start sync. You can use an external trigger to launch the in-stream loop, but once started, loop iterations will proceed without waiting. In contrast, each re-launch iteration can wait for a trigger to start.

Once launched, in-stream loop is running in the background, but there are ways to monitor and control it (refer to StreamHandle for full details).

Just like run(nreps), it can be stopped with KeyboardInterrupt and behaves similarly - generation stops after completing the current repetition in progress.

The following example shows how to print progress of a long-running loop with a large instream_reps:

instream_reps = 100

with ni_strmr.init_stream() as handle:
    handle.launch(instream_reps)
    
    while True:
        finished = handle.wait_until_finished(timeout=1)
        print(f'{handle.reps_written_count()} reps written out of {instream_reps}', end='\r')
        if finished:
            break
100 reps written out of 100

Here we are using wait_until_finished() with a finite timeout to periodically poll reps_written_count() until the run is finished.