├── .gitignore ├── LICENSE ├── README.md ├── graphics ├── cpu_usage.PNG ├── input_signal.gif └── loopback_example.PNG └── nidaqxm_2ch_reader_writer.py /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | __pycache__/ 3 | graphics/Thumbs.db -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Michal Jablonski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## About 2 | This script shows a minimum working example for how to use nidaqmx package to simultaneously and continuously write to, and read from an NI DAQ 3 | measurement card analog I/O's. 4 | ## Who is this for? 5 | This is for people who, in their Python apps or scripts, want to use (an NIDAQMX based) National Instruments Data Acquisition Card to continuously generate 6 | and acquire signals over the device analog I/Os. 7 | ## How does it work? 8 | The script registers reading and writing "callback" functions that are hardware triggered whenever a specific amount of 9 | data had been generated on the output or logged into the input buffer. Pyqtgraph application is used to preview the input signals. 10 | ## How to use 11 | In order to verify if the script is working correctly on your hardware its best to set up a loopback connection 12 | between the analog outputs and inputs. NOTE: The script assumes you are using an RSE (single ended) connection. 13 | Below diagram shows an example loopback for NI6211. 14 | ![loopback_example](graphics/loopback_example.PNG) 15 | ## Assesment 16 | The script works as intended if you can see two continuous, sinusoid signals appear on the input (the sinusoid are purposefully set each to a different amplitude). 17 | ![input_signal](graphics/input_signal.gif) 18 | ## Aim 19 | The aim of this script is to show a minimum working example for triggering simultaneous acquisition and generation of two signals while using minimum CPU resources. 20 | On my system (I5-4200U) the script consumes ~2% CPU time (about half of which is just pyqtgraph drawing at 10FPS) 21 | ![cpu_time](graphics/cpu_usage.PNG) 22 | ## Background and motivation 23 | I set up a script similar to this prior to starting my work on [Twingo](https://github.com/mjablons1/twingo). I did this to prove to myself that its doable 24 | and that Python implementation had enough performance to sustain a continuous, high data rate stream. 25 | Getting a stable data streaming operation was pretty time consuming due to large number of DAQ parameters (with some of the defaults not really working in your favour) and limited documentation online. I publish this script in hope that it saves you time. 26 | ## Something didn't work on your device? 27 | With NI6211 I'm able to run this script all the way up to the maximum per-channel sampling rate of 125kHz. If something does not work for you please raise an issue or let me know in a discussion! Thank you. -------------------------------------------------------------------------------- /graphics/cpu_usage.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mjablons1/nidaqmx-continuous-analog-io/79fb88f7265a4c9e840d2f0c36f09c70468d3ecc/graphics/cpu_usage.PNG -------------------------------------------------------------------------------- /graphics/input_signal.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mjablons1/nidaqmx-continuous-analog-io/79fb88f7265a4c9e840d2f0c36f09c70468d3ecc/graphics/input_signal.gif -------------------------------------------------------------------------------- /graphics/loopback_example.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mjablons1/nidaqmx-continuous-analog-io/79fb88f7265a4c9e840d2f0c36f09c70468d3ecc/graphics/loopback_example.PNG -------------------------------------------------------------------------------- /nidaqxm_2ch_reader_writer.py: -------------------------------------------------------------------------------- 1 | import pyqtgraph as pg 2 | import numpy as np 3 | 4 | import nidaqmx as ni 5 | from nidaqmx import stream_readers 6 | from nidaqmx import stream_writers 7 | 8 | # ---------------------------------------------------------------------------------------------------------------------- 9 | # | | 10 | # | NIDAQ DEVICE RSE LOOPBACK | 11 | # |_______________________________________________________________________________________| 12 | # ao0 ao1 ao_gnd ai0 ai1 ... ai_gnd 13 | # |_________|_________|_______________| | | 14 | # |_________|________________________| | 15 | # |___________________________________________________| 16 | # ---------------------------------------------------------------------------------------------------------------------- 17 | 18 | dev_name = 'Dev1' # < remember to change to your device name, and channel input names below. 19 | ao0 = '/ao0' 20 | ao1 = '/ao1' 21 | ai0 = '/ai0' 22 | ai1 = '/ai1' 23 | 24 | fs = 20000 # sample rate for input and output. 25 | #NOTE: Depending on your hardware sample clock frequency and available dividers some sample rates may not be supported. 26 | out_freq = 101 27 | 28 | NR_OF_CHANNELS = 2 # this scrip supports only 2 I/O channels (do not change) 29 | frames_per_buffer = 10 # nr of frames fitting into the buffer of each measurement channel. 30 | # NOTE With my NI6211 it was necessary to override the default buffer size to prevent under/over run at high sample 31 | # rates. 32 | refresh_rate_hz = 10 33 | samples_per_frame = int(fs // refresh_rate_hz) 34 | 35 | read_buffer = np.zeros((NR_OF_CHANNELS, samples_per_frame), dtype=np.float64) 36 | timebase = np.arange(samples_per_frame) / fs 37 | 38 | plotWidget = pg.plot(title="input_data") 39 | plot_data_item_ch0 = plotWidget.plot(pen=1) 40 | plot_data_item_ch1 = plotWidget.plot(pen=2) 41 | plotItem = plotWidget.getPlotItem() 42 | plotItem.setYRange(-1.2, 1.2) 43 | 44 | 45 | def sine_generator(): 46 | 47 | amplitudes = [1, 0.5] 48 | 49 | frame_nr = 0 50 | data_frame = np.zeros((NR_OF_CHANNELS, samples_per_frame), dtype=np.float64) 51 | 52 | samples_per_period = fs / out_freq 53 | reminder_phase_rad = 2 * np.pi * (samples_per_frame % samples_per_period) / samples_per_period 54 | omega_t = 2 * np.pi * out_freq * timebase 55 | 56 | while True: 57 | phi = reminder_phase_rad * frame_nr 58 | for channel in range(NR_OF_CHANNELS): 59 | data_frame[channel] = amplitudes[channel]*np.sin(omega_t + phi) 60 | yield data_frame 61 | frame_nr += 1 62 | 63 | 64 | def writing_task_callback(task_idx, event_type, num_samples, callback_data): 65 | 66 | """This callback is called every time a defined amount of samples have been transferred from the device output 67 | buffer. This function is registered by register_every_n_samples_transferred_from_buffer_event and it must follow 68 | prototype defined in nidaqxm documentation. 69 | 70 | Args: 71 | task_idx (int): Task handle index value 72 | event_type (nidaqmx.constants.EveryNSamplesEventType): TRANSFERRED_FROM_BUFFER 73 | num_samples (int): Number of samples that was writen into the write buffer. 74 | callback_data (object): User data - I use this arg to pass signal generator object. 75 | """ 76 | 77 | writer.write_many_sample(next(callback_data), timeout=10.0) 78 | 79 | # The callback function must return 0 to prevent raising TypeError exception. 80 | return 0 81 | 82 | 83 | def reading_task_callback(task_idx, event_type, num_samples, callback_data=None): 84 | """This callback is called every time a defined amount of samples have been acquired into the input buffer. This 85 | function is registered by register_every_n_samples_acquired_into_buffer_event and must follow prototype defined 86 | in nidaqxm documentation. 87 | 88 | Args: 89 | task_idx (int): Task handle index value 90 | event_type (nidaqmx.constants.EveryNSamplesEventType): ACQUIRED_INTO_BUFFER 91 | num_samples (int): Number of samples that were read into the read buffer. 92 | callback_data (object)[None]: User data can be additionally passed here, if needed. 93 | """ 94 | 95 | reader.read_many_sample(read_buffer, num_samples, timeout=ni.constants.WAIT_INFINITELY) 96 | 97 | # We draw from inside read callback for code brevity 98 | # but its a much better idea to draw from a separate thread at a reasonable frame rate instead. 99 | plot_data_item_ch0.setData(timebase, read_buffer[0]) 100 | plot_data_item_ch1.setData(timebase, read_buffer[1]) 101 | 102 | # The callback function must return 0 to prevent raising TypeError exception. 103 | return 0 104 | 105 | 106 | with ni.Task() as ao_task, ni.Task() as ai_task: 107 | 108 | ai_args = {'min_val': -10, 109 | 'max_val': 10, 110 | 'terminal_config': ni.constants.TerminalConfiguration.RSE} 111 | 112 | ai_task.ai_channels.add_ai_voltage_chan(dev_name+ai0, **ai_args) 113 | ai_task.ai_channels.add_ai_voltage_chan(dev_name+ai1, **ai_args) 114 | ai_task.timing.cfg_samp_clk_timing(rate=fs, sample_mode=ni.constants.AcquisitionType.CONTINUOUS) 115 | # Configure ai to start only once ao is triggered for simultaneous generation and acquisition: 116 | ai_task.triggers.start_trigger.cfg_dig_edge_start_trig("ao/StartTrigger", trigger_edge=ni.constants.Edge.RISING) 117 | 118 | ai_task.input_buf_size = samples_per_frame * frames_per_buffer * NR_OF_CHANNELS 119 | 120 | ao_args = {'min_val': -10, 121 | 'max_val': 10} 122 | 123 | ao_task.ao_channels.add_ao_voltage_chan(dev_name+ao0, **ao_args) 124 | ao_task.ao_channels.add_ao_voltage_chan(dev_name+ao1, **ao_args) 125 | ao_task.timing.cfg_samp_clk_timing(rate=fs, sample_mode=ni.constants.AcquisitionType.CONTINUOUS) 126 | 127 | # For some reason read_buffer size is not calculating correctly based on the amount of data we preload the output 128 | # with (perhaps because below we fill the read_buffer using a for loop and not in one go?) so we must call out 129 | # explicitly: 130 | ao_task.out_stream.output_buf_size = samples_per_frame * frames_per_buffer * NR_OF_CHANNELS 131 | 132 | # ao_task.out_stream.regen_mode = ni.constants.RegenerationMode.DONT_ALLOW_REGENERATION 133 | # ao_task.timing.implicit_underflow_behavior = ni.constants.UnderflowBehavior.AUSE_UNTIL_DATA_AVAILABLE # SIC! 134 | # Typo in the package. 135 | # 136 | # NOTE: DONT_ALLOW_REGENERATION prevents repeating of previous frame on output read_buffer underrun so instead of 137 | # a warning the script will crash. Its good to block the regeneration during development to ensure we don't get 138 | # fooled by this behaviour (the warning on regeneration occurrence alone is confusing if you don't know that 139 | # that's the default behaviour). Additionally some NI devices will allow you to pAUSE generation till output 140 | # read_buffer data is available again instead of crashing (not supported by my NI6211 though). 141 | 142 | reader = stream_readers.AnalogMultiChannelReader(ai_task.in_stream) 143 | writer = stream_writers.AnalogMultiChannelWriter(ao_task.out_stream) 144 | 145 | # fill output read_buffer with data, this should also enable buffered mode 146 | output_frame_generator = sine_generator() 147 | for _ in range(frames_per_buffer): 148 | writer.write_many_sample(next(output_frame_generator), timeout=1) 149 | 150 | ai_task.register_every_n_samples_acquired_into_buffer_event(samples_per_frame, reading_task_callback) 151 | ao_task.register_every_n_samples_transferred_from_buffer_event( 152 | samples_per_frame, lambda *args: writing_task_callback(*args[:-1], output_frame_generator)) 153 | # NOTE: The lambda function is used is to smuggle output_frame_generator instance into the writing_task_callback( 154 | # ) scope, under the callback_data argument. The reason to pass the generator in is to avoid the necessity to 155 | # define globals in the writing_task_callback() to keep track of subsequent data frames generation. 156 | 157 | ai_task.start() # arms ai but does not trigger 158 | ao_task.start() # triggers both ao and ai simultaneously 159 | 160 | pg.QtGui.QApplication.exec_() 161 | 162 | ai_task.stop() 163 | ao_task.stop() 164 | --------------------------------------------------------------------------------