Skip to content

Commit 82338f8

Browse files
authored
SY-3096: Automate Driver QA - NI (#2071)
SY-3096: Automate Driver QA - NI (#2071)
1 parent 97cc306 commit 82338f8

24 files changed

Lines changed: 2813 additions & 171 deletions

File tree

.github/PULL_REQUEST_TEMPLATE/rc.md

Lines changed: 1 addition & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -319,65 +319,12 @@ I can successfully:
319319
I can successfully:
320320

321321
- [ ] Enable and disable NI integration when starting the server.
322-
- [x] Recognize and connect to an NI device locally. (driver_ni_digital_write.py)
323-
- [ ] Recognize and connect to NI devices over the network.
324-
- [x] Recognize and connect to physical and simulated devices. (driver_ni_digital_write.py)
325-
- [ ] Disconnect a physical device while a task is running without causing faults.
326-
- [ ] Ignore chassis and view devices connected to it.
327-
- [ ] Run the Driver without NI-DAQmx and System Configuration libraries installed.
328-
- [ ] Receive feedback when trying to create an NI task on a machine lacking the necessary libraries.
329-
- **Handle invalid device configurations and receive meaningful feedback:**
330-
- [ ] Invalid ports.
331-
- [ ] Incorrect task type for devices.
332-
- [ ] Out-of-range values.
333-
- [ ] Multiple tasks using the same channel.
322+
- [ ] View a chassis and it's child devices.
334323
- [ ] Shut down the driver without errors during embedded operation.
335-
- [ ] Run various tasks on a single device.
336-
- [ ] Run multiple tasks across multiple devices concurrently.
337-
- **Reliable data streaming at the following sample rates:**
338-
- [ ] 1 Hz
339-
- [ ] 10 Hz
340-
- [ ] 100 Hz
341-
- [ ] 1 kHz
342-
- [ ] 5 kHz
343-
- **Configure the following stream rates:**
344-
- [ ] 1 Hz
345-
- [ ] 10 Hz
346-
- [ ] 30 Hz
347-
- **Analog Read Task**
348-
- [ ] Plot live data.
349-
- [ ] Tare data.
350-
- [ ] Handle device disconnection during active tasks with appropriate feedback.
351-
- [ ] Start multiple tasks at different times and view live data.
352-
- [ ] Enable and disable data saving.
353-
- [ ] Enabled auto-start, and ensure that the task automatically starts after configuration.
354-
- [ ] Ensure no lag between sensor input and Core data recording.
355-
- [ ] Configure and run an analog read task for the following channels:
356-
- [ ] Current (NI-9203)
357-
- [ ] Resistance (NI-9219)
358-
- [ ] RTD (NI-9217)
359-
- [ ] All RTD types and resistance configurations.
360-
- [ ] Built-in temperature sensor (USB-6289)
361-
- [ ] Thermocouple (NI-9211A)
362-
- [ ] All thermocouple types and CJC options.
363-
- [ ] Voltage (USB-6000)
364-
- **Terminal configurations:**
365-
- [ ] Default (USB-6000)
366-
- [ ] Reference Single-Ended (USB-6000)
367-
- [ ] Non-Referenced Single-Ended (NI-9206)
368-
- [ ] Differential (NI-9206)
369-
- [ ] Pseudo-Differential (NI-9234)
370-
- **Apply the following scales:**
371-
- [ ] Linear
372-
- [ ] Map
373324
- **Digital Read Task**
374325
- [ ] Plot live data.
375-
- [ ] Stop, start, and reconfigure tasks.
376-
- [ ] Enable and disable data saving.
377326
- **Digital Write Task**
378327
- [ ] Perform control actions using a schematic.
379-
- [ ] Stop, start, and reconfigure tasks.
380-
- [ ] Handle device disconnection during active tasks with appropriate feedback.
381328
- **Configure response time for specified state rates:**
382329
- [ ] 1 Hz (visible delay)
383330
- [ ] 20 Hz (near-instant response)

client/py/examples/ni/analog_read_task.py

Lines changed: 52 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -8,119 +8,106 @@
88
# included in the file licenses/APL.txt.
99

1010
import synnax as sy
11+
from synnax import ni
1112

1213
"""
13-
This examples demonstrates how to configure and start an Analog Read Task on a National
14-
Instruments USB-6289 device.
14+
This example demonstrates how to configure and start a multi-device Analog Read Task
15+
on National Instruments hardware. The task reads voltage from one device, current from
16+
another, and temperature (thermocouple) from a third — all in a single task.
1517
1618
To run this example, you'll need to have your Synnax cluster properly configured to
17-
detect National Instruments devices: https://docs.synnaxlabs.com/reference/driver/ni/get-started
19+
detect National Instruments devices:
20+
https://docs.synnaxlabs.com/reference/driver/ni/get-started
1821
19-
You'll also need to have either a physical USB-6289 device or create a simulated device
20-
via the NI-MAX software.
22+
You'll also need physical NI devices or simulated devices via NI-MAX.
2123
"""
2224

2325
# We've logged in via the CLI, so there's no need to provide credentials here.
2426
# See https://docs.synnaxlabs.com/reference/client/quick-start for more information.
2527
client = sy.Synnax()
2628

27-
dev = client.devices.retrieve(model="USB-6289")
29+
# Retrieve devices — each module is a separate device in Synnax.
30+
v_dev = client.devices.retrieve(name="Mod1_Voltage")
31+
c_dev = client.devices.retrieve(name="Mod2_Current")
32+
tc_dev = client.devices.retrieve(name="Mod1_TC")
2833

29-
# Create an index channel that will be used to store the timestamps
30-
# for the analog read data.
34+
# Create an index channel for timestamps.
3135
ai_time = client.channels.create(
3236
name="ai_time",
3337
is_index=True,
3438
data_type=sy.DataType.TIMESTAMP,
3539
retrieve_if_name_exists=True,
3640
)
3741

38-
# Create two synnax channels that will be used to store the input data. Notice
39-
# how these channels aren't specifically bound to the device. You'll do that in a
40-
# later step when you create the Analog Read Task.
41-
ai_0 = client.channels.create(
42-
name="ai_0",
43-
# Pass in the index key here to associate the channel with the index channel.
42+
# Create data channels — one per physical input.
43+
voltage_chan = client.channels.create(
44+
name="voltage_chan",
4445
index=ai_time.key,
4546
data_type=sy.DataType.FLOAT32,
4647
retrieve_if_name_exists=True,
4748
)
48-
ai_1 = client.channels.create(
49-
name="ai_1",
50-
# Pass in the index key here to associate the channel with the index channel.
49+
current_chan = client.channels.create(
50+
name="current_chan",
51+
index=ai_time.key,
52+
data_type=sy.DataType.FLOAT32,
53+
retrieve_if_name_exists=True,
54+
)
55+
temp_chan = client.channels.create(
56+
name="temp_chan",
5157
index=ai_time.key,
5258
data_type=sy.DataType.FLOAT32,
5359
retrieve_if_name_exists=True,
5460
)
5561

56-
# Instantiate the task. A task is a background process that can be used to acquire data
57-
# from, or write commands to a device. Tasks are the primary method for interacting with
58-
# hardware in Synnax.
59-
tsk = sy.ni.AnalogReadTask(
60-
# A name to find and monitor the task via the Synnax Console.
61-
name="Basic Analog Read",
62-
# The rate at which the task will sample data from the device.
62+
# Create and configure the task. Each channel specifies its own device, allowing
63+
# a single task to read from multiple NI modules simultaneously.
64+
task = ni.AnalogReadTask(
65+
name="Analog Read Task",
6366
sample_rate=sy.Rate.HZ * 100,
64-
# The rate at which data will be streamed from the device into Synnax.
65-
# Since we're sampling at 100hz and streaming at 25hz, we'll get 4 samples at a
66-
# time.
67-
# It's generally best to keep the stream rate under 100Hz.
6867
stream_rate=sy.Rate.HZ * 25,
69-
# Whether to save data acquired by the task to disk. If set to False, the data
70-
# will be streamed into Synnax for real-time consumption but not saved to disk.
7168
data_saving=True,
72-
# The list of physical channels we'd like to acquire data from.
7369
channels=[
74-
sy.ni.AIVoltageChan(
75-
# The key of the Synnax channel we're acquiring data for.
76-
channel=ai_0.key,
77-
# The key of the device on which the channel is located.
78-
device=dev.key,
79-
# The port on the device the channel is connected to.
70+
ni.AIVoltageChan(
71+
channel=voltage_chan.key,
72+
device=v_dev.key,
8073
port=0,
81-
# A custom scale to apply to the data. This is optional, but can be useful
82-
# for converting raw data into meaningful units.
83-
custom_scale=sy.ni.LinScale(
84-
slope=2e4,
85-
y_intercept=50,
86-
pre_scaled_units="Volts",
87-
scaled_units="Volts",
88-
),
74+
min_val=-10.0,
75+
max_val=10.0,
76+
terminal_config="Diff",
8977
),
90-
sy.ni.AIVoltageChan(
91-
channel=ai_1.key,
92-
device=dev.key,
93-
port=1,
94-
custom_scale=sy.ni.MapScale(
95-
pre_scaled_min=0,
96-
pre_scaled_max=10,
97-
scaled_min=0,
98-
scaled_max=200,
99-
pre_scaled_units="Volts",
100-
scaled_units="Degrees",
101-
),
78+
ni.AICurrentChan(
79+
channel=current_chan.key,
80+
device=c_dev.key,
81+
port=0,
82+
min_val=0.004,
83+
max_val=0.02,
84+
),
85+
ni.AIThermoChan(
86+
channel=temp_chan.key,
87+
device=tc_dev.key,
88+
port=0,
89+
units="DegC",
90+
thermocouple_type="J",
91+
cjc_source="BuiltIn",
10292
),
10393
],
10494
)
10595

10696
# This will create the task in Synnax and wait for the driver to validate that the
10797
# configuration is correct.
108-
client.tasks.configure(tsk)
98+
client.tasks.configure(task)
10999

110100
# Stream 100 reads, which will accumulate a total of 400 samples
111101
# for each channel over a period of 4 seconds.
112102
total_reads = 100
113103

114-
# Create a synnax frame to accumulate data.
115104
frame = sy.Frame()
116105

117-
# Start the task under a context manager, which ensures the task gets stopped
118-
# when the block exits.
119-
with tsk.run():
120-
# Open a streamer on the analog input channels.
121-
with client.open_streamer(["ai_0", "ai_1"]) as streamer:
106+
with task.run():
107+
with client.open_streamer(
108+
["voltage_chan", "current_chan", "temp_chan"]
109+
) as streamer:
122110
for i in range(total_reads):
123111
frame.append(streamer.read())
124112

125-
# Save the data to a CSV file.
126113
frame.to_df().to_csv("analog_read_result.csv")

client/py/examples/simulators/device_sim.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ def _subprocess_entry(self) -> None:
109109
os.dup2(devnull_fd, 1)
110110
os.dup2(devnull_fd, 2)
111111
os.close(devnull_fd)
112+
sys.stdout = open(os.devnull, "w")
113+
sys.stderr = open(os.devnull, "w")
112114
signal.signal(signal.SIGINT, signal.SIG_DFL)
113115
if sys.platform != "win32":
114116
signal.signal(signal.SIGTERM, signal.SIG_DFL)

client/py/synnax/ni/types.py

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1875,6 +1875,7 @@ class DIChan(BaseModel):
18751875
class BaseCIChan(BaseChan):
18761876
device: str = ""
18771877
port: int
1878+
channel: int
18781879

18791880

18801881
class CIFrequencyChan(BaseCIChan, MinMaxVal):
@@ -2416,11 +2417,17 @@ class AnalogReadTaskConfig(task.BaseReadConfig):
24162417

24172418
@field_validator("channels")
24182419
def validate_channel_ports(cls, v: list[AIChan], values: Any) -> list[AIChan]:
2419-
ports = {c.port for c in v}
2420-
if len(ports) < len(v):
2421-
used_ports = [c.port for c in v]
2422-
duplicate_ports = [port for port in ports if used_ports.count(port) > 1]
2423-
raise ValueError(f"Port {duplicate_ports[0]} has already been used")
2420+
device_ports = {(c.device, c.port) for c in v}
2421+
if len(device_ports) < len(v):
2422+
seen: set[tuple[str, int]] = set()
2423+
for c in v:
2424+
key = (c.device, c.port)
2425+
if key in seen:
2426+
raise ValueError(
2427+
f"Port {c.port} has already been used on device"
2428+
f" '{c.device}'"
2429+
)
2430+
seen.add(key)
24242431
return v
24252432

24262433

@@ -2454,11 +2461,17 @@ class CounterReadConfig(task.BaseReadConfig):
24542461

24552462
@field_validator("channels")
24562463
def validate_channel_ports(cls, v: list[CIChan]) -> list[CIChan]:
2457-
ports = {c.port for c in v}
2458-
if len(ports) < len(v):
2459-
used_ports = [c.port for c in v]
2460-
duplicate_ports = [port for port in ports if used_ports.count(port) > 1]
2461-
raise ValueError(f"Port {duplicate_ports[0]} has already been used")
2464+
device_ports = {(c.device, c.port) for c in v}
2465+
if len(device_ports) < len(v):
2466+
seen: set[tuple[str, int]] = set()
2467+
for c in v:
2468+
key = (c.device, c.port)
2469+
if key in seen:
2470+
raise ValueError(
2471+
f"Port {c.port} has already been used on device"
2472+
f" '{c.device}'"
2473+
)
2474+
seen.add(key)
24622475
return v
24632476

24642477

client/py/tests/test_ni.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,6 +797,71 @@ def test_digital_read_sample_rate_bounds(self):
797797
],
798798
)
799799

800+
def test_multi_device_duplicate_ports_allowed(self):
801+
"""Channels on different devices can reuse the same port number."""
802+
sy.ni.AnalogReadTaskConfig(
803+
sample_rate=100,
804+
stream_rate=25,
805+
data_saving=False,
806+
channels=[
807+
sy.ni.AIVoltageChan(
808+
key="test1",
809+
device="device-a",
810+
terminal_config="Cfg_Default",
811+
channel=1,
812+
port=0,
813+
enabled=True,
814+
min_val=-10,
815+
max_val=10,
816+
units="Volts",
817+
),
818+
sy.ni.AIVoltageChan(
819+
key="test2",
820+
device="device-b",
821+
terminal_config="Cfg_Default",
822+
channel=2,
823+
port=0,
824+
enabled=True,
825+
min_val=-10,
826+
max_val=10,
827+
units="Volts",
828+
),
829+
],
830+
)
831+
832+
def test_same_device_duplicate_ports_rejected(self):
833+
"""Channels on the same device cannot reuse the same port number."""
834+
with pytest.raises(ValidationError):
835+
sy.ni.AnalogReadTaskConfig(
836+
sample_rate=100,
837+
stream_rate=25,
838+
data_saving=False,
839+
channels=[
840+
sy.ni.AIVoltageChan(
841+
key="test1",
842+
device="device-a",
843+
terminal_config="Cfg_Default",
844+
channel=1,
845+
port=0,
846+
enabled=True,
847+
min_val=-10,
848+
max_val=10,
849+
units="Volts",
850+
),
851+
sy.ni.AIVoltageChan(
852+
key="test2",
853+
device="device-a",
854+
terminal_config="Cfg_Default",
855+
channel=2,
856+
port=0,
857+
enabled=True,
858+
min_val=-10,
859+
max_val=10,
860+
units="Volts",
861+
),
862+
],
863+
)
864+
800865

801866
@pytest.mark.ni
802867
class TestNIDeviceHelpers:

0 commit comments

Comments
 (0)