nicompiler_backend/
lib.rs

1//! # National Instrument (NI) Integration with `nicompiler_backend`
2//!
3//! National Instrument (NI) has long been a preferred choice for building experimental control systems, owing to the
4//! versatility, cost-effectiveness, extensibility, and robust documentation of its hardware. Their substantial
5//! documentation spans from system design ([NI-DAQmx Documentation](https://www.ni.com/docs/en-US/bundle/ni-daqmx/page/daqhelp/daqhelp.html))
6//! to APIs for both [ANSI C](https://www.ni.com/docs/en-US/bundle/ni-daqmx-c-api-ref/page/cdaqmx/help_file_title.html) and
7//! [Python](https://nidaqmx-python.readthedocs.io).
8//!
9//! While NI provides fine-grained control over its hardware, existing drivers present the following challenges:
10//!
11//! ## Challenges with Existing Implementations
12//!
13//! ### 1. Streaming Deficiency
14//! The NI driver, while versatile, demands that output signals be pre-sampled and relayed to the device's output-buffer.
15//! Consider an experiment that runs for an extended duration (e.g., 10 minutes) and requires high time-resolution (e.g.,
16//! 1MHz for 10 analogue f64 channels). Pre-sampling the entire waveform becomes both computationally demanding and
17//! memory-intensive (requiring around ~44.7Gb for storage). A more practical approach would be streaming the signal,
18//! where a fraction of the signal is sampled and relayed while the preceding chunk is executed. This approach reduces
19//! memory and computational overhead while maintaining signal integrity.
20//!
21//! ### 2. Device-Centric Abstraction
22//! NI drivers typically interface at the device level, with software "task" entities corresponding to specific device channels.
23//! Modern experiments, however, often require capabilities that exceed a single NI card. Using a NI experimental control system
24//! consisting of multiple devices necessitates managing multiple device tasks concurrently, a problem fraught with complexity.
25//! Ideally, researchers should interface with the entire system holistically rather than grappling with individual devices
26//! and their concurrent tasks. See [`Device`] for more details on synchronization.
27//!
28//! ### 3. Trade-offs between High vs. Low-Level Implementation
29//! Low-level system implementations promise versatility and performance but at the expense of development ease. Conversely,
30//! a Python-based solution encourages rapid development but may be marred by performance bottlenecks, especially when dealing
31//! with concurrent streaming across multiple devices.
32//!
33//! ## Introducing `nicompiler_backend`
34//!
35//! `nicompiler_backend` is designed to bridge these challenges. At its core, it leverages the performance and safety
36//! guarantees of Rust as well as its convenient interface with C and python. By interfacing seamlessly with the NI-DAQmx C
37//! driver library and providing a Python API via `PyO3`, `nicompiler_backend` offers the best of both worlds.
38//! Coupled with an optional high-level Python wrapper, researchers can design experiments
39//! in an expressive language, leaving the Rust backend to handle streaming and concurrency.
40//!
41//! Currently, this crate supports analogue and digital output tasks, along with synchronization between NI devices through
42//! shared start-triggers, sampling clocks, or phase-locked reference clocks.
43//!
44//! ## Example usage
45//! ### Rust
46//! ```
47//! use nicompiler_backend::*;
48//! let mut exp = Experiment::new();
49//! // Define devices and associated channels
50//! exp.add_ao_device("PXI1Slot3", 1e6);
51//! exp.add_ao_channel("PXI1Slot3", 0);
52//!
53//! exp.add_ao_device("PXI1Slot4", 1e6);
54//! exp.add_ao_channel("PXI1Slot4", 0);
55//!
56//! exp.add_do_device("PXI1Slot6", 1e7);
57//! exp.add_do_channel("PXI1Slot6", 0, 0);
58//! exp.add_do_channel("PXI1Slot6", 0, 4);
59//!
60//! // Define synchronization behavior:
61//! exp.device_cfg_trig("PXI1Slot3", "PXI1_Trig0", true);
62//! exp.device_cfg_ref_clk("PXI1Slot3", "PXI1_Trig7", 1e7, true);
63//!
64//! exp.device_cfg_trig("PXI1Slot4", "PXI1_Trig0", false);
65//! exp.device_cfg_ref_clk("PXI1Slot4", "PXI1_Trig7", 1e7, false);
66//!
67//! exp.device_cfg_samp_clk_src("PXI1Slot6", "PXI1_Trig7");
68//! exp.device_cfg_trig("PXI1Slot6", "PXI1_Trig0", false);
69//!
70//! // PXI1Slot3/ao0 starts with a 1s-long 7Hz sine wave with offset 1
71//! // and unit amplitude, zero phase. Does not keep its value.
72//! exp.sine("PXI1Slot3", "ao0", 0., 1., false, 7., None, None, Some(1.));
73//! // Ends with a half-second long 1V constant signal which returns to zero
74//! exp.constant("PXI1Slot3", "ao0", 9., 0.5, 1., false);
75//!
76//! // We can also leave a defined channel empty: the device / channel will simply not be compiled
77//!
78//! // Both lines of PXI1Slot6 start with a one-second "high" at t=0 and a half-second high at t=9
79//! exp.high("PXI1Slot6", "port0/line0", 0., 1.);
80//! exp.high("PXI1Slot6", "port0/line0", 9., 0.5);
81//! // Alternatively, we can also define the same behavior via go_high/go_low
82//! exp.go_high("PXI1Slot6", "port0/line4", 0.);
83//! exp.go_low("PXI1Slot6", "port0/line4", 1.);
84//!
85//! exp.go_high("PXI1Slot6", "port0/line4", 9.);
86//! exp.go_low("PXI1Slot6", "port0/line4", 9.5);
87//!
88//! // Compile the experiment: this will stop the experiment at the last edit-time plus one tick
89//! exp.compile();
90//!
91//! // We can compile again with a specific stop_time (and add instructions in between)
92//! exp.compile_with_stoptime(10.); // Experiment signal will stop at t=10 now
93//! assert_eq!(exp.compiled_stop_time(), 10.);
94//! ```
95//!
96//! ### Python
97//! Functionally the same code, additionally samples and plots the signal for `PXI1Slot6/port0/line4`.
98//! The primary goal of the `Experiment` object is to expose a complete set of fast rust-implemented methods
99//! for interfacing with a NI experiment. One may easily customize syntactic sugar and higher-level abstractions
100//! by wrapping `nicompiler_backend` module in another layer of python code,
101//! see our [project page](https://github.com/nlyu1/NI-experiment-control) for one such example.
102//! ```Python
103//! # Instantiate experiment, define devices and channels
104//! from nicompiler_backend import Experiment
105//! import matplotlib.pyplot as plt
106//!
107//! exp = Experiment()
108//! exp.add_ao_device(name="PXI1Slot3", samp_rate=1e6)
109//! exp.add_ao_channel(name="PXI1Slot3", channel_id=0)
110//!
111//! exp.add_ao_device(name="PXI1Slot4", samp_rate=1e6)
112//! exp.add_ao_channel(name="PXI1Slot4", channel_id=0)
113//!
114//! exp.add_do_device(name="PXI1Slot6", samp_rate=1e7)
115//! exp.add_do_channel(name="PXI1Slot6", port_id=0, line_id=0)
116//! exp.add_do_channel("PXI1Slot6", port_id=0, line_id=4)
117//!
118//! # Define synchronization behavior
119//! exp.device_cfg_trig(name="PXI1Slot3", trig_line="PXI1_Trig0", export_trig=True)
120//! exp.device_cfg_ref_clk(name="PXI1Slot3", ref_clk_line="PXI1_Trig7",
121//!                        ref_clk_rate=1e7, export_ref_clk=True)
122//! exp.device_cfg_trig(name="PXI1Slot4", trig_line="PXI1_Trig0", export_trig=False)
123//! exp.device_cfg_ref_clk(name="PXI1Slot4", ref_clk_line="PXI1_Trig7",
124//!                        ref_clk_rate=1e7, export_ref_clk=False)
125//! exp.device_cfg_samp_clk_src(name="PXI1Slot6", src="PXI1_Trig7")
126//! exp.device_cfg_trig(name="PXI1Slot6", trig_line="PXI1_Trig0", export_trig=False)
127//!
128//! # Define signal
129//! # Arguments of "option" type in rust is converted to optional arguments in python
130//! exp.sine(dev_name="PXI1Slot3", chan_name="ao0", t=0., duration=1., keep_val=False,
131//!          freq=7., dc_offset=1.)
132//! exp.constant(dev_name="PXI1Slot3", chan_name="ao0", t=9., duration=0.5, value=1., keep_val=False)
133//!
134//! exp.high("PXI1Slot6", "port0/line0", t=0., duration=1.)
135//! exp.high("PXI1Slot6", "port0/line0", t=9., duration=.5)
136//!
137//! exp.go_high("PXI1Slot6", "port0/line4", t=0.)
138//! exp.go_low("PXI1Slot6", "port0/line4", t=1.)
139//! exp.go_high("PXI1Slot6", "port0/line4", t=9.)
140//! exp.go_low("PXI1Slot6", "port0/line4", t=9.5)
141//!
142//! exp.compile_with_stoptime(10.)
143//! # Returns a 100-element vector of float
144//! sig = exp.channel_calc_signal_nsamps("PXI1Slot6", "port0/line4", start_time=0., end_time=10., num_samps=100)
145//! plt.plot(sig)
146//! ```
147//!
148//! # Navigating the Crate
149//!
150//! The `nicompiler_backend` crate is organized into primary modules - [`experiment`], [`device`], [`channel`], and [`instruction`].
151//! Each serves specific functionality within the crate. Here's a quick guide to help you navigate:
152//!
153//! ### [`experiment`] Module: Your Starting Point
154//!
155//! If you're a typical user, you'll likely spend most of your time here.
156//!
157//! - **Overview**: An [`Experiment`] is viewed as a collection of devices, each identified by its name as recognized by the NI driver.
158//! - **Usage**: The `Experiment` object is the primary entity exposed to Python. It provides methods for experiment-wide, device-wide, and channel-wide operations.
159//! - **Key Traits & Implementations**: Refer to the [`BaseExperiment`] trait for Rust methods and usage examples. For Python method signatures, check the direct implementations in [`Experiment`], which simply wrap `BaseExperiment` implementations.
160//!
161//! ### [`device`] Module: Delving into Devices
162//!
163//! If you're keen on understanding or customizing device-specific details, this module is for you.
164//!
165//! - **Overview**: Each [`Device`] relates to a unique piece of NI hardware in the control system. It contains essential metadata such as physical names, sampling rates, and trigger behaviors.
166//! - **Key Traits & Implementations**: See the [`BaseDevice`] trait and the entire [`device`] module for more insights. Devices also hold a set of channels, each referred to by its physical name.
167//!
168//! ### [`channel`] Module: Channel Instructions & Behaviors
169//!
170//! Ideal for those wanting to understand how instructions are managed or need to design a new [`TaskType`] as well as `TaskType`-specific customized channel behavior.
171//!
172//! - **Overview**: A [`Channel`] signifies a specific physical channel on an NI device. It administers a series of non-overlapping [`InstrBook`] which, after compilation, can be sampled to render floating-point signals.
173//!
174//! ### [`instruction`] Module: Deep Dive into Instructions
175//!
176//! For those interested in the intricacies of how instructions are defined and executed.
177//!
178//! - **Overview**: Each [`InstrBook`] holds an [`Instruction`] coupled with edit-time metadata, like `start_pos`, `end_pos`, and `keep_val`. An [`Instruction`] is crafted from an instruction type ([`InstrType`]) and a set of parameters in key-value pairs.
179//!
180//! We encourage users to explore each module to fully grasp the capabilities and structure of the crate. Whether you're here for a quick setup or to contribute, the `nicompiler_backend` crate is designed to cater to both needs.
181
182use pyo3::prelude::*;
183// use pyo3::wrap_pyfunction;
184
185pub mod channel;
186pub mod device;
187pub mod experiment;
188pub mod instruction;
189pub mod utils;
190
191pub use channel::*;
192pub use device::*;
193pub use experiment::*;
194pub use instruction::*;
195pub use utils::*;
196
197#[pymodule]
198fn nicompiler_backend(_py: Python, m: &PyModule) -> PyResult<()> {
199    m.add_class::<Experiment>()?;
200    Ok(())
201}