Data Acquisition¶
This guide covers data acquisition using different hardware interfaces.
Hardware Support¶
pydvma supports multiple acquisition hardware:
- Soundcards: Using the sounddevice library
- National Instruments DAQ: Using NI-DAQmx (Windows only)
Soundcard Acquisition¶
Basic Setup¶
import pydvma as dvma
settings = dvma.MySettings()
settings.device_driver = 'soundcard'
settings.fs = 44100 # Typical soundcard sample rate
settings.stored_time = 2.0
settings.channels = 2
Listing Available Devices¶
import sounddevice as sd
# List all available devices
print(sd.query_devices())
# Set specific device by index
settings.device_index = 1 # Use the index from sd.query_devices()
Recording¶
# Using the GUI
logger = dvma.Logger(settings)
# Programmatically
dataset = dvma.log_data(settings, test_name="recording_01")
National Instruments DAQ¶
Requirements¶
- NI-DAQmx driver installed
- nidaqmx Python package (
pip install "pydvma[ni]") - Windows operating system
Configuration¶
settings = dvma.MySettings()
settings.device_driver = 'nidaq'
settings.device_index = 0 # Device index (typically 0 for first NI device)
settings.fs = 10000
settings.stored_time = 2.0
settings.channels = 4
# Voltage range (maximum voltage)
settings.VmaxNI = 10 # ±10V range
Finding your device index¶
device_index is an index into the NI device list as nidaqmx
enumerates it, with each cDAQ chassis collapsed to a single entry.
Don't guess — print the list (its nidaq section is indexed exactly the
way device_index expects):
dvma.list_available_devices()
# ...
# Devices available using device_driver='nidaq', by index:
# 0: cDAQ1 (cDAQ-9174, chassis) AI=4 AO=2 modules=['cDAQ1Mod1', 'cDAQ1Mod2']
# 1: Dev1 (USB-6003, device) AI=8 AO=2
Here the chassis is device_index=0 and the USB-6003 is device_index=1.
(dvma.get_devices_NI() exists too but returns a flat list that lists
the chassis and each module separately, so its indices do not match
device_index — use list_available_devices() for choosing the index.)
dvma.suggest_ni_settings(device_index) then returns safe ranges, rate
and terminal mode for whatever is at that index (see below).
Terminal Configuration¶
# Referenced Single-Ended (default)
settings.NI_mode = 'DAQmx_Val_RSE'
# Differential
settings.NI_mode = 'DAQmx_Val_Diff'
# Non-referenced single-ended
settings.NI_mode = 'DAQmx_Val_NRSE'
cDAQ chassis with multiple modules¶
A CompactDAQ chassis is addressed as a single device — use the one
device_index for the chassis, not one per module. channels=N is
then consumed across the chassis's AI modules in slot order, so a
chassis with two 4-channel AI modules (e.g. two NI 9234s) gives eight
channels that span both modules automatically:
settings = dvma.MySettings(
device_driver='nidaq',
device_index=0, # the chassis (one logical device)
channels=8, # spans both AI modules
NI_mode='DAQmx_Val_PseudoDiff', # required by the 9234 (see below)
VmaxNI=5, # the 9234 is fixed at ±5 V
fs=12800,
)
The captured array's columns follow slot order. With a chassis whose
slots are Mod1 (4-ch AI), Mod2 (AO), Mod4 (4-ch AI), the AI task
skips the AO-only module and maps:
| Column | Physical channel |
|---|---|
| 0–3 | Mod1/ai0–ai3 |
| 4–7 | Mod4/ai0–ai3 |
So an accelerometer wired to the second module's ai1 is column 5
of time_data, and any per-channel setting (iepe_excit_current_A,
channel_sensitivities, pretrig_channel) is indexed the same way.
AO-only modules in the middle of the chassis are simply skipped when
counting AI channels (and vice-versa for output).
Sensible defaults: suggest_ni_settings¶
suggest_ni_settings(device_index) inspects the configured device and
returns safe, in-range values (terminal config, full-scale ranges,
sample rate) you can splat straight into MySettings:
kwargs = dvma.suggest_ni_settings(0) # for chassis at index 0
settings = dvma.MySettings(channels=8, **kwargs)
For the lab cDAQ (two 9234s + a 9260 AO) this yields
NI_mode='DAQmx_Val_PseudoDiff', VmaxNI=5, output_VmaxNI≈4.24, and a
rate on the 9234's discrete ladder.
NI 9234 / DSA module constraints¶
Delta-sigma (DSA) modules like the 9234 differ from the multiplexed USB-600x/621x devices, and pydvma enforces or depends on several of their quirks:
- Pseudo-differential only — set
NI_mode='DAQmx_Val_PseudoDiff'. Other terminal modes are rejected. - Fixed ±5 V range —
VmaxNIother than5is silently accepted by the driver but does not change the hardware range. - Simultaneous sampling — every channel has its own ADC, so all channels (across both modules, via the chassis timebase) are sampled at the same instant; there is no inter-channel skew like the multiplexed USB DAQs.
- Automatic anti-alias filter — the brick-wall AA filter is locked to the sample rate and is not user-configurable. AC coupling adds a ~0.5 Hz high-pass.
- Discrete sample-rate ladder — the 9234 only runs at rates on its
internal divider ladder; the driver coerces
fsto the nearest legal value rather than running arbitrary rates.
Non-standard / gappy layouts¶
The count-based channels=N assumes each module is filled from ai0
upward. If you need a non-contiguous set (skip a channel, start partway
into a module, mix specific channels across modules), bypass the
builder with an explicit DAQmx physical-channel string:
settings.input_channels_spec = 'cDAQ1Mod1/ai0:3,cDAQ1Mod4/ai1' # AI
settings.output_channels_spec = 'cDAQ1Mod2/ao0' # AO
When set, these override the auto-constructed channel strings verbatim (nidaqmx backend only).
Triggered Acquisition¶
Pre-trigger Recording¶
Useful for capturing transient events like impacts:
settings.pretrig_samples = 2000 # Samples to keep before trigger
# Set trigger parameters
settings.pretrig_threshold = 0.5 # Voltage threshold
settings.pretrig_channel = 0 # Channel to monitor
settings.pretrig_timeout = 20 # Timeout in seconds
When recording starts, the system continuously buffers data. When the trigger condition is met (signal exceeds threshold), it saves the pre-trigger samples plus the post-trigger duration.
Output Generation¶
Generate signals during acquisition (e.g., for transfer function measurements). The built-in generator supports sig='gaussian', 'uniform', or 'sweep' and returns (t, output) where output has shape (samples, settings.output_channels).
amplitude is in volts; the generator clamps the waveform to ±settings.output_vmax() (= output_VmaxNI on NI, output_VmaxSC on the soundcard) so it can never drive the hardware past its rails.
Gaussian White Noise Output¶
# Generate ~0.1 V RMS white noise
t, output = dvma.signal_generator(
settings,
sig='gaussian',
T=settings.stored_time,
amplitude=0.1 # volts
)
# Record with output
dataset = dvma.log_data(settings, output=output)
Sine Sweep (Chirp) Output¶
# Generate ±0.5 V sine sweep from f1 to f2
t, output = dvma.signal_generator(
settings,
sig='sweep',
T=settings.stored_time,
amplitude=0.5, # volts (peak)
f=[10, 1000] # start and end frequencies (Hz)
)
# Record with output
dataset = dvma.log_data(settings, output=output)
Custom NumPy output¶
signal_generator is convenient but limited — three shapes
('gaussian', 'uniform', 'sweep'), a single amplitude and an
optional band. For anything else (arbitrary multi-tone, a measured or
imported waveform, per-channel-different drives, an MLS sequence, a
stepped sine…) build the array yourself and pass it to
log_data(..., output=...). The format log_data expects is small but
strict:
| Requirement | Detail |
|---|---|
| Shape | 2-D (N_samples, output_channels) — one column per AO channel, even for a single channel (use arr[:, None]). The column count must equal settings.output_channels. |
| Units | Volts — there is no ±1 normalisation. A value of 2.5 means 2.5 V at the terminal. |
| Sample rate | The array is clocked out at settings.output_fs (defaults to fs). Build the time base with 1 / settings.output_fs, and make it ≈ stored_time long to span the capture. |
| Range | Every sample must lie within ±settings.output_vmax() (output_VmaxNI on NI, output_VmaxSC on soundcard). On NI, out-of-range samples are rejected by DAQmx (error -200077). |
| dtype | Any float — cast internally (to volts on NI, to ±1 float32 on the soundcard). |
A hand-built array gets no ramp and no safety clamp
signal_generator fades its waveform in/out and clamps to
full-scale for you. A raw array does neither — you own both. A
discontinuity at the first or last sample will click and can ring
the structure, so window the ends yourself for transient-sensitive
work, and keep the signal inside ±output_vmax().
import numpy as np
fs = settings.output_fs # output clock (defaults to settings.fs)
T = settings.stored_time # match the capture length
vmax = settings.output_vmax() # full-scale output, in volts
t = np.arange(0, T, 1 / fs)
# --- build any waveform you like, in volts ---
# multi-tone: 100 + 220 + 505 Hz, 0.3 V peak each
tones = np.array([100.0, 220.0, 505.0])
y = 0.3 * np.sin(2 * np.pi * np.outer(t, tones)).sum(axis=1)
# raised-cosine fade over the first/last 10 ms to avoid a click
n_ramp = int(0.01 * fs)
ramp = 0.5 * (1 - np.cos(np.linspace(0, np.pi, n_ramp)))
y[:n_ramp] *= ramp
y[-n_ramp:] *= ramp[::-1]
# stay inside the rails — there is no auto-clamp on a custom array
y = np.clip(y, -vmax, vmax)
output = y[:, None] # -> (N, 1): a single AO channel
dataset = dvma.log_data(settings, output=output)
Multiple output channels — one column per channel, with
settings.output_channels set to match. For example a 50 Hz sine on
ao0 and an independent noise drive on ao1:
settings.output_channels = 2
a = 0.5 * np.sin(2 * np.pi * 50 * t)
b = np.clip(0.1 * np.random.randn(t.size), -vmax, vmax)
output = np.column_stack([a, b]) # (N, 2): columns map to ao0, ao1
dataset = dvma.log_data(settings, output=output)
Record the drive as a reference channel
Set settings.use_output_as_ch0 = True and the played output is
prepended as channel 0 of the returned data — useful for transfer
functions, where you want the excitation captured alongside the
response rather than assumed. The prepended column passes through
uncalibrated (cal factor 1).
Output via the Logger GUI¶
The desktop Logger drives output from its "Generate output" panel
rather than from a signal_generator / log_data(output=...) call.
Pre-fill that panel by passing an Output_Signal_Settings when you open
the Logger:
oss = dvma.Output_Signal_Settings(
type='gaussian', # 'None' | 'sweep' | 'gaussian' | 'uniform'
amp=0.1, # peak amplitude in volts (clamped to output_vmax())
f1=100, # sweep start / noise lower band corner (Hz)
f2=300, # sweep end / noise upper band corner (Hz)
)
logger = dvma.Logger(settings, output_signal_settings=oss)
The panel's Type drop-down, Amplitude, f1 and f2 fields
open populated from those values. Preview plots the waveform and its
FFT; Generate output plays it (the GUI refuses a maximum frequency
above Nyquist, fs/2). Under the hood it calls the same
signal_generator shown above — type becomes sig and [f1, f2]
becomes f.
A few notes:
- Duration is a separate panel field, so it is not part of
Output_Signal_Settings— set it in the GUI (it isT=when scriptingsignal_generator). - The four
typevalues are exactly'None','sweep','gaussian'and'uniform'.'None'(the default) opens the Logger with output off — equivalent to omittingoutput_signal_settingsentirely. Output_Signal_Settingsis GUI-only. For scripted/headless output use the array path above; the two are independent.
Voltage-Based I/O¶
Since v1.2 both acquired data and generated output are in volts
everywhere — there is no ±1 normalisation step. Time series, FFTs,
transfer functions, output signals: all in volts (and then in
engineering units once channel_cal_factors is applied for display).
NI inputs and outputs¶
settings.VmaxNI(default5 V) is the AI task's full-scale range: the recorder is configured withmin_val=-VmaxNI,max_val=+VmaxNI, and DAQmx will reject samples outside that range with error -200077. Pick the smallest range that covers your signal — smaller ranges give better resolution.settings.output_VmaxNI(default =VmaxNI) is the AO task's full scale. NI 9260, for example, is hard-limited to ±4.24 V; anysignal_generator(amplitude=X)you ask for abovesettings.output_VmaxNIis clamped automatically and a message prints.suggest_ni_settings(device_index)returns safe defaults for the configured device.
Soundcard inputs and outputs¶
sounddevice itself delivers samples in ±1 normalised float32 — but
pydvma scales those to volts using a per-instance calibration constant
so the downstream code only ever sees voltages:
settings.VmaxSC(default1.0) is the input-side calibration: the voltage at the jack corresponding to a normalised reading of 1.0. Default1.0means "treat normalised as volts at unit scale" — identical numeric behaviour to a pre-v1.2 capture. Once you've measured your soundcard's input sensitivity, set this and acquisitions become calibrated.settings.output_VmaxSC(default =VmaxSC) is the output-side calibration:output_signaldivides the requested voltage waveform byoutput_VmaxSCto recover the ±1 sounddevice expects.
IEPE / ICP excitation (NI DSA modules)¶
The NI 9234 (and other DSA modules with internal excitation) can power IEPE/ICP accelerometers directly. Set per-channel current via:
settings = dvma.MySettings(
device_driver='nidaq',
channels=4,
iepe_excit_current_A=[0.002, 0.002, 0.0, 0.0], # 2 mA on ai0/ai1
)
Channels with > 0 are switched to AC coupling and the recorder
blocks for ~2 s after task start to let the sensor's DC bias settle
through the AC-coupling HPF before reading. Subsequent log_data
calls with matching hardware settings reuse the live task and skip
the warm-up. The 9234 only accepts the discrete values 0.0 and
0.002; other values raise a clear error.
On a multi-module chassis the list is indexed in the same slot order
as the captured columns (see
cDAQ chassis with multiple modules),
and each requested current is validated against the module that
actually supplies that channel — so an accelerometer on the second AI
module is enabled by setting the current at its column index, e.g.
iepe_excit_current_A[5] = 0.002 for Mod4/ai1. Do not enable IEPE
on a channel that is wired to an AO output (e.g. a loopback test
channel): the excitation current is driven back into the AO terminal.
Leave loopback/driven channels at 0.0.
IEPE must-knows
- Only enable excitation on channels with an actual ICP/IEPE
sensor. A charge/voltage input (force hammer, signal generator,
loopback to an AO) must stay at
0.0— forcing 2 mA into a non-ICP input can damage it. - Legal currents on the 9234 are exactly
0.0or0.002A (off / 2 mA). Any other value raises a clear error, validated against the module that actually owns each channel. - The list is positional — one entry per channel in captured-column (slot) order; a scalar broadcasts to every channel.
- Enabling a channel switches it to AC coupling and adds a ~2 s bias-settle on the first capture.
iepe_excit_current_A > 0requiresdevice_driver='nidaq'and a DSA module; soundcard inputs have no configurable excitation.
Worked example: IEPE accelerometers on a cDAQ¶
End-to-end recipe for the most common DSA setup — ICP/IEPE
accelerometers powered straight off an NI 9234 in a cDAQ chassis, with
per-channel calibration so results come out in engineering units.
Suppose the chassis is at device_index=0, its first module
(cDAQ1Mod1) is a 4-channel 9234, and you have two 100 mV/g
accelerometers on ai0/ai1 plus a 2.3 mV/N force hammer on ai2:
import pydvma as dvma
# 1. Confirm the chassis index, and grab safe range/rate/mode for it.
dvma.list_available_devices() # -> the chassis is index 0
base = dvma.suggest_ni_settings(0) # PseudoDiff, VmaxNI=5, a 9234-legal fs, ...
# 2. Three channels: IEPE on the two accelerometers only, and
# per-channel sensitivities in volts per engineering unit.
settings = dvma.MySettings(
channels=3,
iepe_excit_current_A=[0.002, 0.002, 0.0], # 2 mA on ai0/ai1; hammer is not ICP
channel_sensitivities=[0.1, 0.1, 0.0023], # 100 mV/g, 100 mV/g, 2.3 mV/N
stored_time=2.0,
**base, # device_driver='nidaq', device_index=0, NI_mode, VmaxNI, fs, ...
)
# 3. Record. log_data powers the ICP sensors, switches their channels to
# AC coupling, and blocks ~2 s for the bias to settle before capturing.
dataset = dvma.log_data(settings, test_name='hammer_test_01')
# 4. Samples are stored in volts; cal factors [10, 10, 434.8] are attached,
# so plots/FFTs/TFs read in engineering units automatically.
dataset.time_data_list[0].channel_cal_factors # array([ 10. , 10. , 434.78])
dataset.time_data_list[0].units = ['g', 'g', 'N'] # optional axis labels
dataset.plot_time_data()
What this relies on, all covered above:
- Index by capture column, not by terminal label.
channels=3consumescDAQ1Mod1/ai0:2, so list position 0→ai0, 1→ai1, 2→ai2. The same index drivesiepe_excit_current_A,channel_sensitivitiesandpretrig_channel. If sensors span two AI modules the indices keep counting across the slot boundary (see the channel-mapping table). - IEPE only where there's an ICP sensor. The force hammer is a
voltage/charge input, so its channel stays at
0.0(DC-coupled, no excitation). Forcing 2 mA into a non-ICP input can damage it. suggest_ni_settingsdoes the 9234 housekeeping (PseudoDiff,VmaxNI=5, anfson the module's discrete ladder) so you don't have to recall the DSA constraints each time. Override any of its keys by listing them after**base.
Clipping detection¶
log_data checks the captured buffer against 0.95 * input_vmax()
(where input_vmax() returns VmaxNI on NI / VmaxSC on soundcard)
and prints a WARNING: Data may be clipped message if any sample
sits within 5 % of the rails. The output-side signal_generator
applies the same kind of safety clamp at output_vmax() so any
hand-rolled waveform you pass via output=... is implicitly bounded.
Quick reference¶
| Field | Path | Default | What it means |
|---|---|---|---|
VmaxNI |
input | 5 |
NI AI full-scale (volts) |
VmaxSC |
input | 1.0 |
Soundcard input cal: V at norm = 1 |
output_VmaxNI |
output | VmaxNI |
NI AO full-scale (volts) |
output_VmaxSC |
output | VmaxSC |
Soundcard output cal: V at norm = 1 |
channel_sensitivities |
input | 1.0 |
V/eu per channel — see below |
iepe_excit_current_A |
input | 0.0 |
IEPE excitation per channel (NI 9234 etc.) |
Calibration and Scaling¶
Sensor sensitivity¶
Pass per-channel sensitivity (in V/eu — volts per engineering unit) to
MySettings at acquisition time. log_data inverts it into
TimeData.channel_cal_factors, and plotting / modal fitting multiply
by those factors automatically, so the displayed values are in
engineering units (g, m/s², N, ...) without any post-hoc scaling.
settings = dvma.MySettings(
channels=3,
channel_sensitivities=[0.1, 0.1, 0.0023], # V/g, V/g, V/N
)
dataset = dvma.log_data(settings)
# dataset.time_data_list[0].channel_cal_factors is [10, 10, 434.78]
A scalar channel_sensitivities=X broadcasts to all channels. Default
1.0 means "no calibration applied" (cal_factor = 1). Every value must
be non-zero (a zero sensitivity would mean an infinite cal factor), so
use 1.0, not 0.0, for "leave this channel uncalibrated".
Reading sensitivity off the cal sheet
Manufacturers usually print sensitivity in mV per unit — divide
by 1000 to get the V/eu value pydvma expects. A 100 mV/g
accelerometer is 0.1, a 10 mV/g one is 0.01, and a 2.3 mV/N
force transducer is 0.0023. (A common slip is entering 100
instead of 0.1 — that would scale your results by 1000×.)
How calibration is stored and applied¶
channel_sensitivities is consumed once, at logging time: log_data
computes channel_cal_factors = 1 / channel_sensitivities and stores
them on the resulting TimeData. The raw time_data array is always
kept in volts — the cal factors are applied lazily, multiplied in
only when data is displayed or fitted:
- Plotting multiplies each channel by its cal factor, so the axes read in engineering units.
calculate_fft,calculate_cross_spectrum_matrixandcalculate_sonogramcopy the cal factors (andunits) onto the derivedFreqData, so spectra are scaled the same way.calculate_tfinherits the calibration ratio: the stored per-output factor iscal[ch_out] / cal[ch_in], so a transfer function is automatically in output-eu / input-eu (e.g. ag/Naccelerance from agresponse and anNdrive).
Because the stored samples stay in volts, calibration is
non-destructive: you can change it after the fact without re-recording,
and VmaxNI clip-checking still works against the true voltage.
Setting or correcting calibration after logging¶
If you recorded without sensitivities (or fixed a wrong value), set the cal factor directly on the data list. Note this is the reciprocal of sensitivity (engineering-units per volt), because it is the multiplier applied to the stored volts — a 100 mV/g accelerometer (0.1 V/g) has a cal factor of 10:
# One channel of one set (set index and channel index are both 0-based):
dataset.time_data_list.set_calibration_factor(10, n_set=0, n_chan=0)
# Inspect, or set a whole list at once:
factors = dataset.time_data_list.get_calibration_factors()
dataset.time_data_list.set_calibration_factors_all(factors)
The same get_calibration_factors / set_calibration_factor /
set_calibration_factors_all API exists on freq_data_list and
tf_data_list for adjusting already-computed spectra.
Engineering-unit labels¶
TimeData.units accepts a per-channel list of strings; it propagates
through calculate_fft, calculate_cross_spectrum_matrix, and
calculate_sonogram, and calculate_tf builds units like
"<out_unit>/<in_unit>" per output channel.
# Set units after acquisition if you didn't pass them via MySettings
time_data.units = ['g', 'g', 'N']
Multiple Measurements¶
Recording Multiple Datasets¶
# Create dataset to hold multiple measurements
dataset = dvma.DataSet()
for i in range(10):
# Record
data = dvma.log_data(settings, test_name=f"test_{i:02d}")
# Add to dataset
dataset.time_data_list.append(data.time_data_list[0])
Batch Processing¶
# Process all measurements
for i, time_data in enumerate(dataset.time_data_list):
# Calculate FFT for each
freq_data = dvma.calculate_fft(time_data)
dataset.freq_data_list.append(freq_data)
Monitoring and Visualization¶
Oscilloscope view¶
The Logger GUI provides a live oscilloscope of the incoming signal — launch the GUI and use the Oscilloscope view to monitor levels and adjust trigger settings before committing to a recording.
For a one-shot programmatic peek at the live buffer without going via
the GUI, use dvma.stream_snapshot(streams.REC) while a stream is
running (e.g. immediately after a log_data call).
Best Practices¶
Sample Rate Selection¶
Choose appropriate sample rates:
- Audio/vibration: 10-50 kHz
- Ultrasonic: 100+ kHz
- Slow processes: 1-10 Hz
Remember Nyquist: sample at least 2× the highest frequency of interest.
Duration Selection¶
# For frequency resolution Δf
df = 1.0 # Hz resolution desired
settings.stored_time = 1.0 / df # Minimum duration needed
Anti-aliasing¶
Ensure hardware anti-aliasing filters are enabled or use appropriate sample rates to avoid aliasing.
Grounding and Shielding¶
- Use proper grounding to reduce noise
- Shield cables for low-level signals
- Keep signal cables away from power cables
Troubleshooting¶
No Signal Detected¶
- Check connections
- Verify device settings
- Check input range/sensitivity
- Test with known signal source
Clipping/Saturation¶
- Reduce input signal amplitude
- Adjust voltage range settings
- Check sensor sensitivity
High Noise Floor¶
- Improve grounding
- Use differential inputs
- Shield cables
- Reduce gain if possible
- Check for ground loops
Trigger Not Working¶
- Adjust trigger level
- Check trigger channel
- Verify signal amplitude
- Try different trigger slope
Next Steps¶
- Learn about Data Analysis
- Explore Examples