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

use pyo3::prelude::*;
// use pyo3::wrap_pyfunction;

pub mod channel;
pub mod device;
pub mod experiment;
pub mod instruction;
pub mod utils;

pub use channel::*;
pub use device::*;
pub use experiment::*;
pub use instruction::*;
pub use utils::*;

#[pymodule]
fn nicompiler_backend(_py: Python, m: &PyModule) -> PyResult<()> {
    m.add_class::<Experiment>()?;
    Ok(())
}