nicompiler_backend 0.3.0

A backend interface for National Instrument (NI) integration, offering streamlined experimental control systems with Rust's performance and safety guarantees.
Documentation
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
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
//! Implements struct and methods corresponding to NI devices. See [`BaseDevice`] for
//! implementation details.
//!
//! A NI control system consists of one or both of the components:
//! 1. Devices (cards) directly attached the computer via PCIe/USB.
//! 2. A PCIe link card connected to a PXIe chassis, which hosts multiple PXIe cards.
//!
//! ## Device
//! In this library, every [`Device`] object corresponds to a particular task for
//! a physical device (e.g. analogue output for `PXI1Slot1`). A `Device` trivially implements the
//! [`BaseDevice`] trait by supplying field methods.
//!
//! [`Device`] fields keep tracks of of the physical channels associated with the device
//! as well as device-wide data such as device name, trigger line, and synchronization behavior.
//!
//! The [`Device`] struct is the primary structure used to interact with NI hardware. It groups multiple
//! channels, each of which corresponds to a physical channel on an NI device. This struct provides
//! easy access to various properties of the device, such as its physical name, task type, and
//! several clock and trigger configurations.
//! For editing and compiling behavior of devices, see the [`BaseDevice`] trait.
//!
//!
//! ### Editable and streamable channels in devices
//! Library users create and edit editable channels. During compilation, based on the device's task type,
//! the library may internally add streamable channels.
//! For more details on editable and streamable channels, see the editable v.s. streamable section in
//! [`channel` module].
//!
//! ### Synchronization methods for devices
//! Each device's synchronization behavior is specified by its constructor arguments.
//! Refer to the [`Device`] struct for a more detailed explanation.
//!
//! [`channel` module]: crate::channel

use ndarray::{s, Array1, Array2};
use regex::Regex;
use std::collections::{BTreeSet, HashMap};

use crate::channel::*;
use crate::instruction::*;
use crate::utils::*;

/// The `BaseDevice` trait defines the fundamental operations and attributes of a National Instruments (NI) device.
///
/// This trait abstracts the common functionalities that an NI device should possess, regardless of its specific hardware details or task type. Implementers of this trait will have access to core functionalities like channel management, device status checks, signal compilation, and more.
///
/// ## Typical Use
///
/// A type implementing `BaseDevice` is primarily used to interact with the associated NI hardware, manage its channels, and perform operations like signal generation, editing, and compilation.
///
/// # Trait Methods and Their Functionality:
///
/// - **Field methods**: These provide direct access to the properties of a device, such as its channels, physical name,
/// sampling rate, and various configuration parameters.
///
/// - **Synchronization configuration**: Customize the synchronization behavior of devices via [`BaseDevice::cfg_trig`],
/// [`BaseDevice::cfg_ref_clk`], [`BaseDevice::cfg_samp_clk_src`]. See [`Device`] for more details.
///
/// - **Channel management**: Methods like [`BaseDevice::editable_channels`], [`BaseDevice::editable_channels_`], and
/// [`BaseDevice::add_channel`] allow for the retrieval and manipulation of channels associated with the device.
///
/// - **Device status checks**: Methods like [`BaseDevice::is_compiled`], [`BaseDevice::is_edited`], and
/// [`BaseDevice::is_fresh_compiled`] enable checking the compilation and editing status of the device's channels.
///
/// - **Cache operations**: The methods [`BaseDevice::clear_edit_cache`] and [`BaseDevice::clear_compile_cache`] are
/// used to clear the edit and compile caches of the device's channels, respectively.
///
/// - **Compilation**: The [`BaseDevice::compile`] method takes care of the signal compilation process for the device's
/// channels. For Digital Output (DO) channels, it provides additional functionality to merge line channels into port channels.
///
/// - **Signal generation**: The [`BaseDevice::fill_signal_nsamps`] and [`BaseDevice::calc_signal_nsamps`] methods are
/// central to signal generation, allowing for the sampling of float-point values from compiled instructions based on
/// various criteria.
///
/// - **Utility functions**: Methods like [`BaseDevice::unique_port_numbers`] offer utility functionalities specific to certain
/// task types, aiding in operations like identifying unique ports in Digital Output (DO) devices.
///
///
/// # Implementing [`BaseDevice`]:
///
/// When creating a new type that represents an NI device, implementing this trait ensures that the type has all the necessary methods and behaviors typical of NI devices. Implementers can then extend or override these methods as necessary to provide device-specific behavior or optimizations.
pub trait BaseDevice {
    // Immutable accessors (getters)
    fn channels(&self) -> &HashMap<String, Channel>;
    fn name(&self) -> &str;
    fn task_type(&self) -> TaskType;
    fn samp_rate(&self) -> f64;
    fn samp_clk_src(&self) -> Option<&str>;
    fn trig_line(&self) -> Option<&str>;
    fn export_trig(&self) -> Option<bool>;
    fn ref_clk_line(&self) -> Option<&str>;
    fn export_ref_clk(&self) -> Option<bool>;
    fn ref_clk_rate(&self) -> Option<f64>;

    // Mutable accessors
    fn channels_(&mut self) -> &mut HashMap<String, Channel>;
    fn samp_clk_src_(&mut self) -> &mut Option<String>;
    fn trig_line_(&mut self) -> &mut Option<String>;
    fn export_trig_(&mut self) -> &mut Option<bool>;
    fn ref_clk_line_(&mut self) -> &mut Option<String>;
    fn export_ref_clk_(&mut self) -> &mut Option<bool>;
    fn ref_clk_rate_(&mut self) -> &mut Option<f64>;

    /// Configures the sample clock source for the device.
    ///
    /// This method sets the `samp_clk_src` field of the device to the provided source string.
    ///
    /// # Arguments
    ///
    /// * `src` - The name of the sample clock source.
    fn cfg_samp_clk_src(&mut self, src: &str) {
        *(self.samp_clk_src_()) = Some(src.to_string());
    }

    /// Configures the trigger settings for the device.
    ///
    /// Depending on the value of `export_trig`, this method either:
    ///
    /// * Exports the device task's start trigger to `trig_line` (if `export_trig` is `true`), or
    /// * Imports the device task's start trigger from `trig_line` (if `export_trig` is `false`).
    ///
    /// # Arguments
    ///
    /// * `trig_line` - The trigger line identifier.
    /// * `export_trig` - A boolean that determines whether to export or import the trigger.
    fn cfg_trig(&mut self, trig_line: &str, export_trig: bool) {
        *(self.trig_line_()) = Some(trig_line.to_string());
        *(self.export_trig_()) = Some(export_trig);
    }

    /// Configures the reference clock settings for the device.
    ///
    /// If `export_ref_clk` is set to `true`, this method:
    ///
    /// * Exports the device's 10MHz on-board reference clock to `ref_clk_line`,
    /// * Asserts that `ref_clk_rate` is set to 1e7 (10MHz).
    ///
    /// If `export_ref_clk` is set to `false`, this method:
    ///
    /// * Sets the device's reference clock to the designated line and rate provided by the arguments.
    ///
    /// # Arguments
    ///
    /// * `ref_clk_line` - The line or channel to import or export the device's reference clock.
    /// * `ref_clk_rate` - The rate of the reference clock in Hz.
    /// * `export_ref_clk` - A boolean that determines whether to export (if `true`) or import (if `false`) the reference clock.
    fn cfg_ref_clk(&mut self, ref_clk_line: &str, ref_clk_rate: f64, export_ref_clk: bool) {
        if export_ref_clk {
            assert_eq!(ref_clk_rate, 1e7,
                "Device {} needs to explicitly acknowledge exporting 10Mhz clk by setting ref_clk_rate=1e7",
                self.name());
        }
        *(self.ref_clk_line_()) = Some(ref_clk_line.to_string());
        *(self.ref_clk_rate_()) = Some(ref_clk_rate);
        *(self.export_ref_clk_()) = Some(export_ref_clk);
    }

    /// Returns a vector of references to editable channels
    fn editable_channels(&self) -> Vec<&Channel> {
        self.channels()
            .values()
            .filter(|&chan| chan.editable())
            .collect()
    }
    /// Returns a vector of mutable references to editable channels
    fn editable_channels_(&mut self) -> Vec<&mut Channel> {
        self.channels_()
            .values_mut()
            .filter(|chan| (*chan).editable())
            .collect()
    }

    /// Adds a new channel to the device.
    ///
    /// This base method validates the provided `name` based on the device's `task_type`
    /// to ensure it adheres to the expected naming convention for the respective task type.
    ///
    /// # Naming Conventions:
    /// - For `TaskType::AO`: Channels should be named following the pattern "ao(number)"
    ///   (e.g., "ao0", "ao1").
    /// - For `TaskType::DO`: Channels should be named following the pattern "port(number)/line(number)"
    ///   (e.g., "port0/line1").
    ///
    /// # Panics
    /// - If the provided `name` does not adhere to the expected naming convention for the
    ///   associated task type.
    /// - If a channel with the same `name` already exists within the device.
    ///
    /// # Arguments
    /// - `name`: Name of the channel as seen by the NI driver, which must adhere to the
    ///   naming conventions detailed above.
    fn add_channel(&mut self, name: &str) {
        // Check the name format
        let (name_match_string, name_format_description) = match self.task_type() {
            TaskType::AO => (String::from(r"^ao\d+$"), String::from("ao(number)")),
            TaskType::DO => (
                String::from(r"^port\d+/line\d+$"),
                String::from("port(number)/line(number)"),
            ),
        };

        let re = Regex::new(&name_match_string).unwrap();
        if !re.is_match(name) {
            panic!(
                "Expecting channels to be of format '{}' yet received channel name {}",
                name_format_description, name
            );
        }
        for channel in self.channels().values() {
            if channel.name() == name {
                panic!(
                    "Physical name of channel {} already registered. Registered channels are {:?}",
                    name,
                    self.channels()
                        .values()
                        .map(|c| c.name())
                        .collect::<Vec<_>>()
                );
            }
        }
        let new_channel = Channel::new(self.task_type(), name, self.samp_rate());
        self.channels_().insert(name.to_string(), new_channel);
    }

    /// A device is compiled if any of its editable channels are compiled.
    /// Also see [`BaseChannel::is_compiled`]
    fn is_compiled(&self) -> bool {
        self.editable_channels()
            .iter()
            .any(|channel| channel.is_compiled())
    }
    /// A device is marked edited if any of its editable channels are edited.
    /// Also see [`BaseChannel::is_edited`]
    fn is_edited(&self) -> bool {
        self.editable_channels()
            .iter()
            .any(|channel| channel.is_edited())
    }
    /// A device is marked fresh-compiled if all if its editable channels are freshly compiled.
    /// Also see [`BaseChannel::is_fresh_compiled`]
    fn is_fresh_compiled(&self) -> bool {
        self.editable_channels()
            .iter()
            .all(|channel| channel.is_fresh_compiled())
    }
    /// Clears the edit-cache fields for all editable channels.
    /// Also see [`BaseChannel::clear_edit_cache`]
    fn clear_edit_cache(&mut self) {
        self.editable_channels_()
            .iter_mut()
            .for_each(|chan| chan.clear_edit_cache());
    }
    /// Clears the compile-cache fields for all editable channels.
    /// Also see [`BaseChannel::clear_compile_cache`]
    fn clear_compile_cache(&mut self) {
        self.editable_channels_()
            .iter_mut()
            .for_each(|chan| chan.clear_compile_cache());
    }

    /// Compiles all editable channels to produce a continuous instruction stream.
    ///
    /// The method starts by compiling each individual editable channel to obtain a continuous
    /// stream of instructions (also see[`BaseChannel::compile`]).
    /// If the device type is `TaskType::DO` (Digital Output), an additional
    /// processing step is performed. All the line channels belonging to the same port are merged
    /// into a single, streamable port channel that is non-editable. This aggregated port channel
    /// contains constant instructions whose integer values are determined by the combined state
    /// of all the lines of the corresponding port. Specifically, the `n`th bit of the integer
    /// value of the instruction corresponds to the boolean state of the `n`th line.
    ///
    /// # Port Channel Aggregation
    /// Each instruction inside the aggregated port channel is a constant instruction. The value of
    /// this instruction is an integer, where its `n`th bit represents the boolean state of the
    /// `n`th line. This way, the combined state of all lines in a port is efficiently represented
    /// by a single integer value, allowing for streamlined execution and efficient data transfer.
    ///
    /// # Arguments
    /// - `stop_pos`: The stop position used to compile the channels.
    fn compile(&mut self, stop_pos: usize) {
        self.editable_channels_()
            .iter_mut()
            .for_each(|chan| chan.compile(stop_pos));
        if self.task_type() != TaskType::DO {
            return;
        }
        // For DO channels: merge line channels into port channels
        for match_port in self.unique_port_numbers() {
            // Collect a sorted list of possible intervals
            let mut instr_end_set = BTreeSet::new();
            instr_end_set.extend(
                self.editable_channels()
                    .iter()
                    .filter(|chan| extract_port_line_numbers(chan.name()).0 == match_port)
                    .flat_map(|chan| chan.instr_end().iter()),
            );
            let instr_end: Vec<usize> = instr_end_set.into_iter().collect();

            let mut instr_val = vec![0.; instr_end.len()];
            for chan in self.editable_channels() {
                let (port, line) = extract_port_line_numbers(chan.name());
                if port == match_port {
                    let mut chan_instr_idx = 0;
                    for i in 0..instr_val.len() {
                        assert!(chan_instr_idx < chan.instr_end().len());
                        let chan_value =
                            chan.instr_val()[chan_instr_idx].args.get("value").unwrap();
                        instr_val[i] += *chan_value as f64 * 2.0f64.powf(line as f64);
                        if instr_end[i] == chan.instr_end()[chan_instr_idx] {
                            chan_instr_idx += 1;
                        }
                    }
                }
            }
            let port_instr_val: Vec<Instruction> = instr_val
                .iter()
                .map(|&val| Instruction::new_const(val))
                .collect();
            let mut port_channel = Channel::new(
                TaskType::DO,
                &format!("port{}", match_port),
                self.samp_rate(),
            );
            *port_channel.instr_val_() = port_instr_val;
            *port_channel.instr_end_() = instr_end;
            self.channels_()
                .insert(port_channel.name().to_string(), port_channel);
        }
    }

    /// Returns a vector of compiled channels based on the given criteria.
    ///
    /// Filters the device's channels based on their compiled state and optional properties such as
    /// streamability and editability.
    ///
    /// # Arguments
    /// - `require_streamable`: If `true`, only compiled channels marked as streamable will be included in the result.
    /// - `require_editable`: If `true`, only compiled channels marked as editable will be included in the result.
    ///
    /// # Returns
    /// A `Vec` containing references to the channels that match the provided criteria.
    fn compiled_channels(&self, require_streamable: bool, require_editable: bool) -> Vec<&Channel> {
        self.channels()
            .values()
            .filter(|chan| {
                chan.is_compiled()
                    && (!require_streamable || chan.streamable())
                    && (!require_editable || chan.editable())
            })
            .collect()
    }

    /// Calculates the maximum stop time among all compiled channels.
    ///
    /// Iterates over all the compiled channels in the device, regardless of their streamability or
    /// editability, and determines the maximum stop time.
    /// See [`BaseChannel::compiled_stop_time`] for more information.
    ///
    /// # Returns
    /// A `f64` representing the maximum stop time (in seconds) across all compiled channels.
    fn compiled_stop_time(&self) -> f64 {
        self.compiled_channels(false, false)
            .iter()
            .map(|chan| chan.compiled_stop_time())
            .fold(0.0, f64::max)
    }

    /// Calculates the maximum stop time among all editable channels.
    ///
    /// Iterates over all the editable channels in the device and determines the maximum stop time.
    /// See [`BaseChannel::edit_stop_time`] for more information.
    ///
    /// # Returns
    /// A `f64` representing the maximum stop time (in seconds) across all editable channels.
    fn edit_stop_time(&self) -> f64 {
        self.editable_channels()
            .iter()
            .map(|chan| chan.edit_stop_time())
            .fold(0.0, f64::max)
    }

    /// Generates a signal by sampling float-point values from compiled instructions.
    ///
    /// This method fills a given buffer with signal values based on the compiled instructions of the device's
    /// channels. Depending on the requirements, it can either generate signals intended for actual driver
    /// writing or for debugging editing intentions.
    ///
    /// # Arguments
    /// - `start_pos`: The starting position in the sequence of compiled instructions.
    /// - `end_pos`: The ending position in the sequence of compiled instructions.
    /// - `nsamps`: The number of samples to generate.
    /// - `buffer`: A mutable reference to a 2D array. The first axis corresponds to the channel index and
    ///    the second axis corresponds to the sample index.
    /// - `require_streamable`: If `true`, only signals from channels marked as streamable will be generated.
    /// - `require_editable`: If `true`, signals will be generated according to editing intentions for debugging purposes.
    ///
    /// # Panics
    /// This method will panic if:
    /// - The first dimension of the buffer does not match the number of channels that fulfill the provided requirements.
    /// - The second dimension of the buffer does not match the provided `nsamps` value.
    ///
    /// # TODO Notes
    /// The generation of signals from channels can be parallelized for performance improvements.
    fn fill_signal_nsamps(
        &self,
        start_pos: usize,
        end_pos: usize,
        nsamps: usize,
        buffer: &mut ndarray::Array2<f64>,
        require_streamable: bool,
        require_editable: bool,
    ) {
        // Assumes buffer of shape [num_compiled_and_streamable_channels][nsamps]
        assert!(
            buffer.dim().0
                == self
                    .compiled_channels(require_streamable, require_editable)
                    .len(),
            "Device {} has {} channels but passed buffer has shape {:?}",
            self.name(),
            self.compiled_channels(require_streamable, require_editable)
                .len(),
            buffer.dim()
        );
        assert!(
            buffer.dim().1 == nsamps,
            "Simulating position {}-{} with {} elements, but buffer has shape {:?}",
            start_pos,
            end_pos,
            nsamps,
            buffer.dim()
        );
        // This can be parallelized (note)
        for (i, chan) in self
            .compiled_channels(require_streamable, require_editable)
            .iter()
            .enumerate()
        {
            let mut channel_slice = buffer.slice_mut(s![i, ..]);
            chan.fill_signal_nsamps(start_pos, end_pos, nsamps, &mut channel_slice);
        }
    }

    /// Computes and returns the signal values for specified channels in a device.
    ///
    /// This method calculates the signal values by sampling float-point values from compiled instructions
    /// of the device's channels. Depending on the requirements, the signal can be either intended for actual
    /// driver writing or for debugging editing intentions. For AO (Analog Output) devices, the returned buffer
    /// will contain time data.
    ///
    /// # Arguments
    /// - `start_pos`: The starting position in the sequence of compiled instructions.
    /// - `end_pos`: The ending position in the sequence of compiled instructions.
    /// - `nsamps`: The number of samples to generate.
    /// - `require_streamable`: If `true`, only signals from channels marked as streamable will be generated.
    /// - `require_editable`: If `true`, signals will be generated according to editing intentions for debugging purposes.
    ///
    /// # Returns
    /// A 2D array with the computed signal values. The first axis corresponds to the channel index and the
    /// second axis corresponds to the sample index.
    ///
    /// # Panics
    /// This method will panic if:
    /// - There are no channels that fulfill the provided requirements.
    /// - The device's task type is not AO (Analog Output) when initializing the buffer with time data.
    fn calc_signal_nsamps(
        &self,
        start_pos: usize,
        end_pos: usize,
        nsamps: usize,
        require_streamable: bool,
        require_editable: bool,
    ) -> Array2<f64> {
        let num_chans = self
            .compiled_channels(require_streamable, require_editable)
            .len();
        assert!(
            num_chans > 0,
            "There is no channel with streamable={}, editable={}",
            require_streamable,
            require_editable
        );
        let mut buffer = Array2::from_elem((num_chans, nsamps), 0.);
        // Only AOChannel needs to initialize buffer with time data
        if self.task_type() == TaskType::AO {
            let t_values = Array1::linspace(
                start_pos as f64 / self.samp_rate(),
                end_pos as f64 / self.samp_rate(),
                nsamps,
            );
            buffer
                .outer_iter_mut()
                .for_each(|mut row| row.assign(&t_values));
        }
        self.fill_signal_nsamps(
            start_pos,
            end_pos,
            nsamps,
            &mut buffer,
            require_streamable,
            require_editable,
        );
        buffer
    }

    /// Retrieves a list of unique port numbers from the device's channels.
    ///
    /// This utility function is primarily used with DO (Digital Output) devices to identify and operate
    /// on unique ports. It scans through the compiled channels of the device, filtering for those that are
    /// editable, and extracts the unique port numbers associated with them.
    ///
    /// # Returns
    /// A vector of unique port numbers identified in the device's channels.
    ///
    /// # Panics
    /// The method will panic if it's invoked on a device that is not of task type DO.
    fn unique_port_numbers(&self) -> Vec<usize> {
        assert!(
            self.task_type() == TaskType::DO,
            "unique ports should only be invoked for DOs, but {} is not",
            self.name()
        );

        let mut port_numbers = BTreeSet::new();

        self.compiled_channels(false, true).iter().for_each(|chan| {
            // Capture the port
            let name = &chan.name();
            port_numbers.insert(extract_port_line_numbers(name).0);
        });
        port_numbers.into_iter().collect()
    }
}

/// Represents a National Instruments (NI) device.
///
/// A `Device` is the primary structure used to interact with NI hardware. It groups multiple
/// channels, each of which corresponds to a physical channel on an NI device. This struct provides
/// easy access to various properties of the device, such as its physical name, task type, and
/// several clock and trigger configurations.
/// For editing and compiling behavior of devices, see the [`BaseDevice`] trait.
///
/// # Fields
/// - `channels`: A collection of channels associated with this device.
/// - `name`: Name of the device as seen by the NI driver.
/// - `task_type`: Specifies the task type associated with the device.
/// - `samp_rate`: The sampling rate of the device in Hz.
/// - `samp_clk_src`: Optional source of the sampling clock; supply `None` for on-board clock source.
/// - `trig_line`: Optional identifier for the port through which to import/export the task start trigger.
///     Supply `None` for trivial triggering behavior.
/// - `export_trig`: Optional Boolean indicating if the device exports its start trigger. If `true`, the device
///     exports the start trigger of the NI-task associated with this device through `trig_line`. If `false` or `None`,
///     the device is set to import the start trigger. In case that any device in an experiment has nontrivial triggering behavior,
///     one and only one of the devices must have `export_trig` set to `true`.
/// - `ref_clk_line`: Optional source of the reference clock to phase-lock the device clock to.
/// - `export_ref_clk`: Optional indicator of whether to export the reference clock. If `true`, the device exports its
///     reference clock. If `false` or `None`, it imports the reference clock. Use `None` for trivial behavior.
/// - `ref_clk_rate`: Optional rate of the reference clock in Hz.
///
/// # Synchronization Methods
///
/// For experiments that do not require synchronization between devices, set all optional fields of `Device` to `None`.
/// However, for more accurate and cohesive experiments, we recommend at least implementing start-trigger synchronization.
///
/// ## Start-trigger Synchronization
///
/// Relevant fields: `trig_line`, `export_trig`.
///
/// Refer to the official [NI documentation on start-trigger synchronization](https://www.ni.com/docs/en-US/bundle/ni-daqmx/page/mxcncpts/syncstarttrigger.html).
///
/// This method designates one device to export its start trigger and others to import. When the experiment begins, tasks on
/// devices with `export_trig` set to `false` are set to wait for a digital edge trigger from the `trig_line` channel. The device with `export_trig` set to `true` exports its start trigger to `trig_line`.
///
/// **Note**: It's essential to physically connect the device that exports its trigger (where `export_trig` is `true`) to the corresponding lines on devices that import the trigger.
///
/// For PCIe devices, use a `PFI` label. For PXIe devices, use the label `PXI_Trig` followed by a number in the range 0-7.
/// This backend crate ensures task synchronization such that threads handling tasks set to import the trigger always start listening for triggers before the exporting task begins.
///
/// For PXIe devices linked to a chassis, ensure that you configure trigger bus routing using NI-MAX (on Windows) or the
/// NI Hardware Configuration Utilities (on Linux) when specifying backplane trigger lines. Detailed information can be
/// found [here](https://www.ni.com/docs/en-US/bundle/pxi-platform-services-help/page/trigger_routing_and_reservation.html).
///
/// It's important to note that after starting, each device's task utilizes its internal clock, which may result in incremental
/// drifts between devices over time. For longer signals, it's advisable to use additional synchronization methods to ensure
/// clock alignment.
///
/// ### Example:
/// Here, the device `PXI1Slot6` exports its start trigger to `PXI1_Trig0`, while `PXI1Slot7` imports its start
/// trigger from the same line.
/// ```
/// # use nicompiler_backend::*;
/// let mut exp = Experiment::new();
/// exp.add_do_device("PXI1Slot6", 1e6);
/// exp.add_do_device("PXI1Slot7", 1e6);
/// exp.device_cfg_trig("PXI1Slot6", "PXI1_Trig0", true);
/// exp.device_cfg_trig("PXI1Slot7", "PXI1_Trig0", false);
/// ```
///
/// The compiler will panic if more than one device exports trigger
/// ```should_panic
/// # use nicompiler_backend::*;
/// let mut exp = Experiment::new();
/// exp.add_do_device("PXI1Slot6", 1e6);
/// exp.add_do_device("PXI1Slot7", 1e6);
/// exp.device_cfg_trig("PXI1Slot6", "PXI1_Trig0", true);
/// exp.device_cfg_trig("PXI1Slot7", "PXI1_Trig0", true);
/// ```
///
/// The compiler **will** panic if some device is expecting a start trigger yet no device exports one.
/// ```should_panic
/// # use nicompiler_backend::*;
/// let mut exp = Experiment::new();
/// exp.add_do_device("PXI1Slot6", 1e6);
/// exp.add_do_channel("PXI1Slot6", 0, 4);
/// exp.device_cfg_trig("PXI1Slot6", "PXI1_Trig0", false);
/// exp.go_high("PXI1Slot6", "port0/line4", 0.5);
/// exp.compile_with_stoptime(1.); // Panics here
/// ```
///
/// ## Phase-lock to Reference Clock
///
/// Relevant fields: `ref_clk_line`, `ref_clk_rate`, `export_ref_clk`.
///
/// Refer to the [NI documentation on phase-lock synchronization](https://www.ni.com/docs/en-US/bundle/ni-daqmx/page/mxcncpts/syncrefclock.html).
///
/// A subset of NI devices support this flexible synchronization method, which allows devices synchronized in this manner
/// to operate at different sampling rates. Devices phase-lock their on-board oscillators to an external reference at `ref_clk_line`
/// and indicate its frequency via `ref_clk_rate`. A device can optionally export its 10MHz onboard reference clock to `ref_clk_line` by setting `export_ref_clk` to `true`.
///
/// **Note**: Devices phase-locked in this manner still require start-trigger synchronization to ensure synchronized start times.
///
/// ### Example:
/// The device `PXI1Slot6` exports its start trigger signal to `PXI1_Trig0` and its 10MHz reference clock to `PXI1_Trig7`.
/// The device `PXI1Slot4` acts accordingly.
/// ```rust
/// use nicompiler_backend::*;
/// let mut exp = Experiment::new();
/// exp.add_ao_device("PXI1Slot3", 1e6);
/// exp.device_cfg_trig("PXI1Slot3", "PXI1_Trig0", true);
/// exp.device_cfg_ref_clk("PXI1Slot3", "PXI1_Trig7", 1e7, true);
///
/// exp.add_ao_device("PXI1Slot4", 1e6);
/// exp.device_cfg_trig("PXI1Slot4", "PXI1_Trig0", false);
/// exp.device_cfg_ref_clk("PXI1Slot4", "PXI1_Trig7", 1e7, false);
/// ```
///
/// ## Importing Sample Clock
///
/// Relevant fields: `samp_clk_src`.
///
/// Check out the [NI documentation on sample clock synchronization](https://www.ni.com/docs/en-US/bundle/ni-daqmx/page/mxcncpts/syncsampleclock.html).
///
/// Some NI devices do not support reference clock synchronization. As an alternative, they can directly use external
/// clock signals for their sampling clock. However, this constrains them to operate at the same rate as the imported sample clock.
///
/// ### Example:
/// Building on the previous example, an additional `PXI1Slot6` sources its sample clock from the 10MHz signal exported by `PXI1Slot3`.
/// ```rust
/// use nicompiler_backend::*;
/// let mut exp = Experiment::new();
/// exp.add_ao_device("PXI1Slot3", 1e6);
/// exp.device_cfg_trig("PXI1Slot3", "PXI1_Trig0", true);
/// exp.device_cfg_ref_clk("PXI1Slot3", "PXI1_Trig7", 1e7, true);
///
/// exp.add_ao_device("PXI1Slot4", 1e6);
/// exp.device_cfg_trig("PXI1Slot4", "PXI1_Trig0", false);
/// exp.device_cfg_ref_clk("PXI1Slot4", "PXI1_Trig7", 1e7, false);
///
/// exp.add_do_device("PXI1Slot6", 1e7);
/// exp.device_cfg_samp_clk_src("PXI1Slot6", "PXI1_Trig7");
/// exp.device_cfg_trig("PXI1Slot6", "PXI1_Trig0", false);
/// ```
pub struct Device {
    channels: HashMap<String, Channel>,

    name: String,
    task_type: TaskType,
    samp_rate: f64,

    samp_clk_src: Option<String>,
    trig_line: Option<String>,
    export_trig: Option<bool>,
    ref_clk_line: Option<String>,
    export_ref_clk: Option<bool>,
    ref_clk_rate: Option<f64>,
}

impl Device {
    /// Constructs a new `Device` instance.
    ///
    /// This constructor initializes a device with the provided parameters. The `channels` field
    /// is initialized as an empty collection. All synchronization fields are initialized to `None`
    /// by default. For nontrivial synchronization behavior, use the methods [`BaseDevice::cfg_samp_clk_src`],
    /// [`BaseDevice::cfg_trig`], and [`BaseDevice::cfg_ref_clk`].
    ///
    /// # Arguments
    /// - `name`: Name of the device as seen by the NI driver.
    /// - `task_type`: The type of task associated with the device.
    /// - `samp_rate`: Desired sampling rate in Hz.
    ///
    /// # Returns
    /// A new instance of `Device` with the specified configurations and all synchronization-related fields set to `None`.
    pub fn new(name: &str, task_type: TaskType, samp_rate: f64) -> Self {
        Self {
            channels: HashMap::new(),

            name: name.to_string(),
            task_type,
            samp_rate,

            samp_clk_src: None,
            trig_line: None,
            export_trig: None,
            ref_clk_line: None,
            export_ref_clk: None,
            ref_clk_rate: None,
        }
    }
}

impl BaseDevice for Device {
    // Immutable accessors (getters)
    fn channels(&self) -> &HashMap<String, Channel> {
        &self.channels
    }

    fn name(&self) -> &str {
        &self.name
    }

    fn task_type(&self) -> TaskType {
        self.task_type
    }

    fn samp_rate(&self) -> f64 {
        self.samp_rate
    }

    fn samp_clk_src(&self) -> Option<&str> {
        self.samp_clk_src.as_deref()
    }

    fn trig_line(&self) -> Option<&str> {
        self.trig_line.as_deref()
    }

    fn export_trig(&self) -> Option<bool> {
        self.export_trig
    }

    fn ref_clk_line(&self) -> Option<&str> {
        self.ref_clk_line.as_deref()
    }

    fn export_ref_clk(&self) -> Option<bool> {
        self.export_ref_clk
    }

    fn ref_clk_rate(&self) -> Option<f64> {
        self.ref_clk_rate
    }

    // Mutable accessors
    fn channels_(&mut self) -> &mut HashMap<String, Channel> {
        &mut self.channels
    }

    fn samp_clk_src_(&mut self) -> &mut Option<String> {
        &mut self.samp_clk_src
    }

    fn trig_line_(&mut self) -> &mut Option<String> {
        &mut self.trig_line
    }

    fn export_trig_(&mut self) -> &mut Option<bool> {
        &mut self.export_trig
    }

    fn ref_clk_line_(&mut self) -> &mut Option<String> {
        &mut self.ref_clk_line
    }

    fn export_ref_clk_(&mut self) -> &mut Option<bool> {
        &mut self.export_ref_clk
    }

    fn ref_clk_rate_(&mut self) -> &mut Option<f64> {
        &mut self.ref_clk_rate
    }
}