diff --git a/tests/analog_functions.py b/tests/analog_functions.py index a1dfb585..4bf467de 100644 --- a/tests/analog_functions.py +++ b/tests/analog_functions.py @@ -26,18 +26,23 @@ import libm2k import time from multiprocessing.pool import ThreadPool -import threading import os -from pandas import DataFrame +from pathlib import Path import pandas import random -import sys import reset_def_values as reset -from helpers import get_result_files, get_sample_rate_display_format, get_time_format, save_data_to_csv, plot_to_file, plot_to_file_multiline +from helpers import ( + get_result_files, + get_sample_rate_display_format, + get_time_format, + save_data_to_csv, + plot_to_file, + plot_to_file_multiline, +) from open_context import ctx_timeout, ctx -from create_files import results_file, results_dir, csv from shapefile import shape_gen, Shape +import logging # dicts that will be saved to csv files shape_csv_vals = {} @@ -1685,4 +1690,311 @@ def configure_trigger(trig: libm2k.M2kHardwareTrigger, ylim=(-6, 6), ) aout.stop() - return result \ No newline at end of file + return result + + +def test_dual_channel_sync( + ain: libm2k.M2kAnalogIn, + aout: libm2k.M2kAnalogOut, + trig: libm2k.M2kHardwareTrigger, + ctx: libm2k.M2k, +) -> tuple[bool, str]: + """Test dual-channel waveform synchronization with hardware oversampling. + + Validation Strategy: + 1. Detects falling edges in the ramp signal (CH1) + 2. Verifies that triangle wave (CH0) minima align with these falling edges + within a specified tolerance (200 samples) + 3. Checks monotonicity of the triangle wave before and after each minimum + using filtered data with relaxed constraints (70% threshold) to account + for noise and low ADC resolution sampling (1 MHz vs 75 MHz DAC rate) + 4. Ensures both channels have sufficient signal amplitude (> 1.0V range) + 5. Enforces the period ratio: exactly 5 triangle periods must occur between + two consecutive stair-step falling edges. The ramp resets (5 -> 1) every + 5 stair steps, and each step lasts as long as one triangle period, so a + correctly synchronized start yields exactly 5 triangle maxima per stair + cycle. An out-of-sync start shows up as 6 (or 4) periods per cycle even + when the per-edge alignment check still passes. + """ + + ctx.reset() + ctx.calibrateADC() + ctx.calibrateDAC() + ctx.setTimeout(10_000) # [ms] + + file_name, dir_name, csv_path = get_result_files(gen_reports) + + reset.analog_in(ain) + reset.analog_out(aout) + reset.trigger(trig) + + # Generate waveform data programmatically + # CH0: Triangle wave - 750,000 samples, 0-5V amplitude + n_samples = 750_000 + n_rising = 375_042 + amplitude = 5.0 + + n_falling = n_samples - n_rising + step = amplitude / (n_rising - 1) + + rising = np.linspace(0, amplitude, n_rising) + falling_start = amplitude - step + falling_end = falling_start - step * (n_falling - 1) + falling = np.linspace(falling_start, falling_end, n_falling) + ch0_buffer = np.concatenate([rising, falling]) + + # CH1: Ramp/stair pattern - 20 samples, values 0-4 repeated + ch1_buffer = np.tile(np.arange(1,6), 4).astype(float) + + logger = logging.getLogger(__name__) + logger.info(f"Generated waveform data: CH0={len(ch0_buffer)} samples, CH1={len(ch1_buffer)} samples") + + # Hardware configuration + dac_sr = 75_000_000 # 75 MHz base sample rate + adc_sr = 1_000_000 # 1 MHz ADC sample rate + + # Oversampling configuration + ch0_oversampling = 1 # native rate + ch1_oversampling = 750_000 # repeat each sample 750000 times + + # Trigger configuration + trig_delay = 8_000 # Pre-trigger delay in samples + trig_level = 1.5 # [V] + trig_hysteresis = 0.25 # [V] + + # Capture configuration + in_samples = 75_000 # Capture 75ms worth of data at 1MHz (fewer periods for easier visual inspection) + + # Configure analog input + ain.setSampleRate(adc_sr) + actual_adc_sr = ain.getSampleRate() + assert abs(actual_adc_sr - adc_sr) / adc_sr < 0.001, ( + f"ADC sample rate mismatch: expected {adc_sr}, got {actual_adc_sr}" + ) + ain.enableChannel(libm2k.ANALOG_IN_CHANNEL_1, True) + ain.enableChannel(libm2k.ANALOG_IN_CHANNEL_2, True) + assert ain.isChannelEnabled(libm2k.ANALOG_IN_CHANNEL_1), "ADC CH1 not enabled" + assert ain.isChannelEnabled(libm2k.ANALOG_IN_CHANNEL_2), "ADC CH2 not enabled" + ain.setRange(libm2k.ANALOG_IN_CHANNEL_1, libm2k.PLUS_MINUS_25V) + ain.setRange(libm2k.ANALOG_IN_CHANNEL_2, libm2k.PLUS_MINUS_25V) + assert ain.getRange(libm2k.ANALOG_IN_CHANNEL_1) == libm2k.PLUS_MINUS_25V, ( + "ADC CH1 range mismatch" + ) + assert ain.getRange(libm2k.ANALOG_IN_CHANNEL_2) == libm2k.PLUS_MINUS_25V, ( + "ADC CH2 range mismatch" + ) + + # Configure analog output + aout.setSampleRate(libm2k.ANALOG_IN_CHANNEL_1, dac_sr) + aout.setSampleRate(libm2k.ANALOG_IN_CHANNEL_2, dac_sr) + assert aout.getSampleRate(libm2k.ANALOG_IN_CHANNEL_1) == dac_sr, ( + f"DAC CH1 sample rate mismatch: expected {dac_sr}, got {aout.getSampleRate(libm2k.ANALOG_IN_CHANNEL_1)}" + ) + assert aout.getSampleRate(libm2k.ANALOG_IN_CHANNEL_2) == dac_sr, ( + f"DAC CH2 sample rate mismatch: expected {dac_sr}, got {aout.getSampleRate(libm2k.ANALOG_IN_CHANNEL_2)}" + ) + aout.setOversamplingRatio(libm2k.ANALOG_IN_CHANNEL_1, ch0_oversampling) + aout.setOversamplingRatio(libm2k.ANALOG_IN_CHANNEL_2, ch1_oversampling) + assert aout.getOversamplingRatio(libm2k.ANALOG_IN_CHANNEL_1) == ch0_oversampling, ( + f"DAC CH1 oversampling mismatch: expected {ch0_oversampling}, got {aout.getOversamplingRatio(libm2k.ANALOG_IN_CHANNEL_1)}" + ) + assert aout.getOversamplingRatio(libm2k.ANALOG_IN_CHANNEL_2) == ch1_oversampling, ( + f"DAC CH2 oversampling mismatch: expected {ch1_oversampling}, got {aout.getOversamplingRatio(libm2k.ANALOG_IN_CHANNEL_2)}" + ) + aout.enableChannel(libm2k.ANALOG_IN_CHANNEL_1, True) + aout.enableChannel(libm2k.ANALOG_IN_CHANNEL_2, True) + assert aout.isChannelEnabled(libm2k.ANALOG_IN_CHANNEL_1), "DAC CH1 not enabled" + assert aout.isChannelEnabled(libm2k.ANALOG_IN_CHANNEL_2), "DAC CH2 not enabled" + aout.setCyclic(True) + + # Configure trigger on CH0 + trig.setAnalogSource(libm2k.ANALOG_IN_CHANNEL_1) + assert trig.getAnalogSource() == libm2k.ANALOG_IN_CHANNEL_1, ( + "Trigger source mismatch" + ) + trig.setAnalogSourceChannel(libm2k.ANALOG_IN_CHANNEL_1) + trig.setAnalogMode(libm2k.ANALOG_IN_CHANNEL_1, libm2k.ANALOG) + assert trig.getAnalogMode(libm2k.ANALOG_IN_CHANNEL_1) == libm2k.ANALOG, ( + "Trigger mode mismatch" + ) + trig.setAnalogCondition(libm2k.ANALOG_IN_CHANNEL_1, libm2k.RISING_EDGE_ANALOG) + assert ( + trig.getAnalogCondition(libm2k.ANALOG_IN_CHANNEL_1) == libm2k.RISING_EDGE_ANALOG + ), "Trigger condition mismatch" + trig.setAnalogLevel(libm2k.ANALOG_IN_CHANNEL_1, trig_level) + assert abs(trig.getAnalogLevel(libm2k.ANALOG_IN_CHANNEL_1) - trig_level) < 0.05, ( + f"Trigger level mismatch: expected {trig_level}, got {trig.getAnalogLevel(libm2k.ANALOG_IN_CHANNEL_1)}" + ) + trig.setAnalogHysteresis(libm2k.ANALOG_IN_CHANNEL_1, trig_hysteresis) + assert ( + abs(trig.getAnalogHysteresis(libm2k.ANALOG_IN_CHANNEL_1) - trig_hysteresis) + < 0.05 + ), ( + f"Trigger hysteresis mismatch: expected {trig_hysteresis}, got {trig.getAnalogHysteresis(libm2k.ANALOG_IN_CHANNEL_1)}" + ) + trig.setAnalogDelay(-trig_delay) # Negative delay to see pre-trigger data + assert trig.getAnalogDelay() == -trig_delay, ( + f"Trigger delay mismatch: expected {-trig_delay}, got {trig.getAnalogDelay()}" + ) + + # Start acquisition before pushing to ensure we capture the signal + ain.startAcquisition(in_samples) + + # Synchronized push - both channels simultaneously + aout.push([ch0_buffer, ch1_buffer]) + + try: + input_data = ain.getSamples(in_samples) + except Exception as e: + ain.stopAcquisition() + aout.stop() + return False, f"Timeout during acquisition: {e}" + + ain.stopAcquisition() + aout.stop() + + ch0_data = np.array(input_data[libm2k.ANALOG_IN_CHANNEL_1]) # Triangle + ch1_data = np.array(input_data[libm2k.ANALOG_IN_CHANNEL_2]) # Ramp + + if gen_reports: + subdir_name = f"{dir_name}/dual_channel_sync" + os.makedirs(subdir_name, exist_ok=True) + x_time, x_label = get_time_format(in_samples, adc_sr) + plot_to_file( + "Dual Channel Sync Test (HW Oversampling)", + ch0_data, + subdir_name, + "dual_channel_sync_captured.png", + data1=ch1_data, + x_data=x_time, + xlabel=x_label, + ) + + # Validation: Check synchronization between channels + + # Detect falling edges in stair step signal using derivative + ch1_diff = np.diff(ch1_data) + + # Find indices where there are large negative jumps (falling edges) + falling_threshold = -0.5 # Significant drop + falling_edges = np.where(ch1_diff < falling_threshold)[0] + + if len(falling_edges) == 0: + return ( + False, + "No falling edges detected in ramp signal - signal may not be outputting correctly", + ) + + # Verify signal ranges before running sync validation + ch0_range = ch0_data.max() - ch0_data.min() + ch1_range = ch1_data.max() - ch1_data.min() + if ch0_range < 1.0: + return False, f"CH0 (triangle) signal too weak: range = {ch0_range:.2f}V" + if ch1_range < 1.0: + return False, f"CH1 (ramp) signal too weak: range = {ch1_range:.2f}V" + + # Validate synchronization: Check that triangle minima align with stair step falling edges + window_size = 250 + tolerance = 10 + + sync_errors = [] + + for i, edge_idx in enumerate(falling_edges): + # Define window around the falling edge + window_start = max(0, edge_idx - window_size) + window_end = min(len(ch0_data), edge_idx + window_size) + + # Extract triangle data (CH0) in this window + triangle_window = ch0_data[window_start:window_end] + + if len(triangle_window) < 10: + logger.warning( + f"Edge {i}: insufficient data in window for analysis, skipping" + ) + continue + + # Find where triangle transitions from decreasing to increasing (local minimum) + min_idx = np.argmin(triangle_window) + min_position = window_start + min_idx + + # Check if minimum is close to falling edge + distance = abs(min_position - edge_idx) + if distance > tolerance: + sync_errors.append( + f"Edge {i}: triangle minimum at {min_position} is {distance} samples " + f"from falling edge at {edge_idx} (tolerance: {tolerance})" + ) + + if min_idx > 0: + filtered_before = np.convolve( + triangle_window[: min_idx + 1], np.ones(25) / 25, mode="valid" + ) + if len(filtered_before) > 1: + decreasing_count = np.sum(np.diff(filtered_before) <= 0) + total_count = len(np.diff(filtered_before)) + if decreasing_count < total_count * 0.7: + sync_errors.append( + f"Edge {i}: triangle not decreasing before minimum at {min_position}. " + f"Decreasing: {decreasing_count}/{total_count} samples" + ) + if min_idx < len(triangle_window) - 1: + filtered_after = np.convolve( + triangle_window[min_idx:], np.ones(25) / 25, mode="valid" + ) + if len(filtered_after) > 1: + increasing_count = np.sum(np.diff(filtered_after) >= 0) + total_count = len(np.diff(filtered_after)) + if increasing_count < total_count * 0.7: + sync_errors.append( + f"Edge {i}: triangle not increasing after minimum at {min_position}. " + f"Increasing: {increasing_count}/{total_count} samples" + ) + + if sync_errors: + return False, f"Sync errors ({len(sync_errors)}): " + "; ".join(sync_errors[:3]) + + # Enforce the period ratio: exactly 5 triangle periods before the first stair + # falling edge. Each stair step lasts one triangle period and the ramp resets + # (5 -> 1) after 5 steps, so a correctly synchronized start yields exactly 5 + # completed triangle periods by the time the first falling edge appears. An + # out-of-sync start shows up as 6 (or 4) periods here even when the per-edge + # minima alignment above still passes. + # NOTE: A full stair cycle is 5 * (n_samples / dac_sr) = 50 ms, but the capture + # window (in_samples / adc_sr) is only ~75 ms, so it contains a single falling + # edge. We therefore validate the count *up to* the first edge rather than + # between two consecutive edges. + EXPECTED_PERIODS_BEFORE_FIRST_EDGE = 5 + + # Expected triangle period in ADC samples, derived from hardware constants: + # period_seconds = (n_samples * ch0_oversampling) / dac_sr + # period_adc = period_seconds * adc_sr + expected_period = (n_samples * ch0_oversampling) * adc_sr / dac_sr + # Peak-separation guard at ~60% of a period prevents a single flat-topped maximum + # from being counted twice at this low ADC resolution while still resolving + # adjacent periods. + min_peak_distance = max(1, int(expected_period * 0.6)) + + # Detect triangle peaks (maxima). One maximum marks one completed period. + # Height gate at 50% of range rejects noise ripple and the flat idle region. + peak_height = ch0_data.min() + 0.5 * ch0_range + triangle_peaks, _ = find_peaks( + ch0_data, height=peak_height, distance=min_peak_distance + ) + + first_edge = falling_edges[0] + periods_before_first_edge = int(np.sum(triangle_peaks < first_edge)) + + if periods_before_first_edge != EXPECTED_PERIODS_BEFORE_FIRST_EDGE: + return False, ( + f"Period ratio error: {periods_before_first_edge} triangle periods before " + f"the first stair falling edge (at sample {first_edge}), expected exactly " + f"{EXPECTED_PERIODS_BEFORE_FIRST_EDGE} (out-of-sync start)" + ) + + return ( + True, + f"Sync validated: {len(falling_edges)} stair step falling edge(s) detected, " + f"all triangle minima within {tolerance} samples tolerance, and exactly " + f"{EXPECTED_PERIODS_BEFORE_FIRST_EDGE} triangle periods before the first " + f"falling edge", + ) \ No newline at end of file diff --git a/tests/doc/README.md b/tests/doc/README.md new file mode 100644 index 00000000..7985669e --- /dev/null +++ b/tests/doc/README.md @@ -0,0 +1,59 @@ +# Test Design Documentation + +This directory contains design documentation for tests in the libm2k test suite. Each document explains the rationale behind test design decisions, making it easier to understand, maintain, and extend the test suite. + +## Purpose + +- **Document design decisions** - Explain why tests are structured the way they are +- **Capture domain knowledge** - Record hardware-specific considerations +- **Enable maintenance** - Help future developers understand test intent +- **Track issue references** - Link tests to the bugs/features they validate + +## Document Structure + +Each test document should follow this template: + +```markdown +# Test: [Test Name] + +## Overview +| Field | Value | +| -------------------- | ------------------------------------ | +| **Test Name** | `test_function_name` | +| **Test File** | `tests/file.py` | +| **Related Issue** | Link to GitHub issue (if applicable) | +| **Minimum Firmware** | Version requirement (if applicable) | + +## Problem Statement +What problem or feature does this test validate? + +## Test Configuration +What parameters are used and why? + +## Design Rationale +Why was this approach chosen? What alternatives were considered? + +## Expected Results +What indicates pass/fail? What error messages mean what? + +## Running the Test +Command-line examples for running the test. + +## Running the Test +Instructions for running the test. + +## References +Links to issues, documentation, datasheets, etc. +``` + +## Naming Convention + +Documentation files should be named after the test method they document: +- `test_dual_channel_waveform_sync.md` + +## Index + +| Document | Test | Description | +| ------------------------------------------------------------------------ | --------------------------------- | -------------------------------------------- | +| [test_dual_channel_waveform_sync.md](test_dual_channel_waveform_sync.md) | `test_dual_channel_waveform_sync` | Validates dual-channel phase synchronization | + diff --git a/tests/doc/test_dual_channel_waveform_sync.md b/tests/doc/test_dual_channel_waveform_sync.md new file mode 100644 index 00000000..31f2842f --- /dev/null +++ b/tests/doc/test_dual_channel_waveform_sync.md @@ -0,0 +1,110 @@ +# Test: Dual-Channel Waveform Synchronization + +## Overview + +| Field | Value | +| -------------------- | ---------------------------------------------------------------------------------- | +| **Test Name** | `test_dual_channel_waveform_sync` | +| **Test File** | `tests/m2k_analog_test.py` | +| **Data Generation** | Programmatic (waveforms generated in test code) | +| **Related Issue** | [analogdevicesinc/m2k-fw#20](https://github.com/analogdevicesinc/m2k-fw/issues/20) | +| **Minimum Firmware** | v0.34+ | + + +## Problem Statement + +Firmware v0.33 exhibits a synchronization issue when both DAC channels operate simultaneously with different hardware oversampling ratios. The phase relationship between channels deviates from the configured settings, causing misalignment in the output waveforms. + +**Root Cause:** Incorrect handling of IIO oversampling attributes in the hardware configuration. When libm2k's `setOversamplingRatio` API configures different oversampling ratios for each DAC channel, the channels lose their temporal synchronization. + +**Manifestation:** The issue occurs when: +- Both DAC channels generate waveforms concurrently +- Channels use different oversampling ratios +- The expected phase relationship between channels is not maintained + + +## Test Configuration + +This test validates hardware oversampling functionality using pre-generated waveform data from a CSV file. The test deliberately stresses the synchronization logic by using drastically different oversampling ratios on each channel. + +### Data File Structure + +| Column | Samples | Signal | Description | +| ------ | ------- | -------- | ---------------------------------------- | +| CH0 | 750,000 | Triangle | Full resolution, no HW oversampling | +| CH1 | 20 | Ramp | Minimal samples, maximum HW oversampling | + +### Channel 0: Triangle Wave (Native Resolution) + +| Parameter | Value | Rationale | +| -------------- | ------- | -------------------------------------------- | +| Sample Count | 750,000 | One complete period at native DAC resolution | +| Sample Rate | 75 MHz | Maximum DAC sample rate | +| Oversampling | 1 | No hardware oversampling (native samples) | +| Effective Rate | 75 MHz | Full resolution output | +| Period | 10 ms | 750,000 samples ÷ 75 MHz = 10 ms per cycle | + +### Channel 1: Ramp Signal (Maximum Hardware Oversampling) + +| Parameter | Value | Rationale | +| -------------- | ------- | ---------------------------------------------- | +| Sample Count | 20 | Minimal buffer, maximum hardware interpolation | +| Sample Rate | 75 MHz | Maximum DAC sample rate | +| Oversampling | 750,000 | Each sample repeated 750,000 times by hardware | +| Effective Rate | 100 Hz | 75 MHz ÷ 750,000 = 100 Hz effective rate | + +> **Note on terminology:** The CSV contains a 20-sample "ramp" waveform, but due to hardware sample-and-hold behavior with 750,000x oversampling, each sample is held constant for 10 ms. This creates a "stair step" pattern when captured by the ADC, which is why the validation algorithm refers to "stair step falling edges." + +## Design Rationale + +### Why Hardware Oversampling? + +The original bug report ([m2k-fw#20](https://github.com/analogdevicesinc/m2k-fw/issues/20)) specifically identifies issues with the firmware's IIO oversampling attribute handling. Testing with software-generated high-resolution waveforms would **not** reproduce the bug because: + +1. **Software generation bypasses the problematic code path** - The issue is in the hardware oversampling logic, not the data itself +2. **IIO attribute configuration is the trigger** - The bug only manifests when oversampling ratios are configured through IIO attributes +3. **Different ratios stress synchronization** - Using drastically different oversampling ratios on each channel exercises the synchronization logic that was broken in v0.33 + +### Why Programmatic Waveform Generation? + +The waveforms are generated programmatically in the test code to ensure: +- **Self-contained tests** - No external data files required +- **Reproducibility** - Waveform parameters are explicitly defined in code +- **Consistency** - IIO oversampling and sample rate configurations are applied via libm2k API calls, isolating the hardware synchronization behavior + +The generated waveforms match the original buffer layout used in Scopy when the issue was discovered: +- **CH0 (Triangle)**: 750,000 samples, 0-5V amplitude, symmetric rising/falling edges +- **CH1 (Ramp/Stair)**: 20 samples, pattern [0,1,2,3,4] repeated 4 times + + +### Synchronization Validation + +The test validates that the triangle wave (CH0) and stair step signal (CH1) maintain proper phase alignment. When synchronized correctly, the triangle wave's local minima should align with the stair step's falling edges. + + +## Expected Results + +### Pass (firmware v0.34+) + +- Both channels display expected waveform characteristics in captured data +- Triangle minima is located around the stair step falling edges + +### Fail (firmware <= v0.34) + +- By design the test is skipped on older firmware versions since it's a known bug. + It can still be executed by removing the `@unittest.skipIf` decorator in the test class. + +## Running the Test + +Prerequisites: +- Loopback connection from `W1 -> 1+`, `W2 -> 2+`, `{1-,2-} -> GND`. + +From the `tests/` directory run: + +```bash +python3 main.py A_AnalogTests.test_dual_channel_waveform_sync +``` + +## References + +- [m2k-fw Issue #20](https://github.com/analogdevicesinc/m2k-fw/issues/20) - Original bug report and discussion \ No newline at end of file diff --git a/tests/helpers.py b/tests/helpers.py index 8d5bc1ed..e108263b 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -47,7 +47,9 @@ def save_data_to_csv(csv_vals, csv_file): return -def plot_to_file(title, data, dir_name, filename, xlabel=None, x_lim = None, ylabel=None, y_lim = None, data1=None, x_data = None, data_marked=None): +def plot_to_file(title, data, dir_name, filename, xlabel=None, x_lim=None, + ylabel=None, y_lim=None, data1=None, x_data=None, + data_marked=None, label0="CH0", label1="CH1"): # Saves the plots in a separate folder # Arguments: # title -- Title of the plot @@ -58,10 +60,12 @@ def plot_to_file(title, data, dir_name, filename, xlabel=None, x_lim = None, yla # ylabel -- Label of y-Axis(default: {None}) # data1 -- Data that should be plotted on the same plot(default: {None}) # data_marked -- Data that represents specific points on the plot(default: {None}) + # label0 -- Legend label for first dataset (default: {"CH0"}) + # label1 -- Legend label for second dataset (default: {"CH1"}) # plot the signals in a separate folder plt.title(title) # if xlabel and ylabel are not specified there will be default values - if xlabel is not None: + if xlabel is not None: plt.xlabel(xlabel) else: plt.xlabel('Samples') @@ -72,22 +76,23 @@ def plot_to_file(title, data, dir_name, filename, xlabel=None, x_lim = None, yla plt.grid(visible=True) # if x_data is not None, the plot will be displayed with the specified x_data if x_data is not None: - plt.plot(x_data, data) + plt.plot(x_data, data, label=label0) else: - plt.plot(data) + plt.plot(data, label=label0) # if a second set of data must be printed (for ch0 and ch1 phase difference in this case) if data1 is not None: if x_data is not None: - plt.plot(x_data, data1) + plt.plot(x_data, data1, label=label1) else: - plt.plot(data1) + plt.plot(data1, label=label1) # Optional configurations if x_lim is not None: plt.xlim(*x_lim) if y_lim is not None: plt.ylim(*y_lim) if data_marked is not None: - plt.plot(data_marked, data[data_marked], 'xr') + plt.plot(data_marked, data[data_marked], 'xr') + plt.legend() plt.savefig(dir_name + "/" + filename) plt.close() return diff --git a/tests/m2k_analog_test.py b/tests/m2k_analog_test.py index a3786bea..19a76240 100644 --- a/tests/m2k_analog_test.py +++ b/tests/m2k_analog_test.py @@ -19,21 +19,42 @@ # import itertools -import sys import unittest import libm2k from shapefile import shape_gen, ref_shape_gen, shape_name -from analog_functions import get_experiment_config_for_sample_hold, test_amplitude, test_aout_triggering, test_last_sample_hold, test_shape, phase_diff_ch0_ch1, test_offset, test_analog_trigger, \ - test_voltmeter_functionality, test_kernel_buffers, test_buffer_transition_glitch -from analog_functions import noncyclic_buffer_test, set_samplerates_for_shapetest, set_trig_for_cyclicbuffer_test, \ - test_calibration -from analog_functions import compare_in_out_frequency, test_oversampling_ratio, channels_diff_in_samples, test_timeout, \ - cyclic_buffer_test +from analog_functions import ( + get_experiment_config_for_sample_hold, + test_amplitude, + test_aout_triggering, + test_last_sample_hold, + test_shape, + phase_diff_ch0_ch1, + test_offset, + test_analog_trigger, + test_voltmeter_functionality, + test_kernel_buffers, + test_buffer_transition_glitch, + test_dual_channel_sync, +) +from analog_functions import ( + noncyclic_buffer_test, + set_samplerates_for_shapetest, + set_trig_for_cyclicbuffer_test, + test_calibration, +) +from analog_functions import ( + compare_in_out_frequency, + test_oversampling_ratio, + channels_diff_in_samples, + test_timeout, + cyclic_buffer_test, +) import reset_def_values as reset from open_context import ctx, ain, aout, dig, trig, create_dir from create_files import results_dir, csv, results_file import logger +import main from repeat_test import repeat from helpers import get_sample_rate_display_format @@ -45,17 +66,19 @@ class A_AnalogTests(unittest.TestCase): @classmethod def setUpClass(self): # wait for user to make appropriate connections - global wait_for_input_a - wait_for_input_a = True log = logger.myLogger() log.info("\nAnalogical Segment\n" "Connections:\n" "W1 ====> 1+\n" "W2 ====> 2+\n" - "GND ===> 1-\n") + "GND ===> 1-\n" + "If needed, press enter, when you've made the connections.") + + if main.wait_for_input: + input() def test_1_analog_objects(self): - # Verify through open_context() function if the analog objects AnalogIn, AnalogOut and Trigger were + # Verify through open_context() function if the analog objects AnalogIn, AnalogOut and Trigger were # successfully retrieved. with self.subTest(msg='test if AnalogIn, AnalogOut and Trigger objects were retrieved'): @@ -266,13 +289,13 @@ def test_timeout(self): @unittest.skipIf(ctx.getFirmwareVersion() < 'v0.32', 'This is a known bug in previous firmware implementations which is fixed in v0.32') def test_buffer_transition_glitch(self): - # Pushing a new cyclic buffer should output the value of the raw attr. In previous firmware versions, the new buffer would start + # Pushing a new cyclic buffer should output the value of the raw attr. In previous firmware versions, the new buffer would start # with the last sample from previous buffer which lead to a glitch in the output signal. This test verifies that the glitch is not present anymore. - + for channel in [libm2k.ANALOG_IN_CHANNEL_1, libm2k.ANALOG_IN_CHANNEL_2]: for waveform in ['dc', 'sine']: num_glitches = test_buffer_transition_glitch(channel, ain, aout, trig, waveform) - + with self.subTest(msg='Test buffer transition glitch: ' + waveform + ' on ch' + str(channel)): self.assertEqual(num_glitches, 0, 'Found ' + str(num_glitches) + ' glitches on channel ' + str(channel)) @@ -280,9 +303,9 @@ def test_buffer_transition_glitch(self): 'The sample and hold feature is available starting with firmware v0.33. Note: v0.32 had a glitch that is handled in this test.') def test_last_sample_hold(self): # Tests the last sample hold functionality for different channels and DAC sample rates. - # This test iterates over different channels (each channel individually and both channels together) - # and then tests the last sample hold functionality. When testing both channels together, 'None' - # is used to denote this case. + # This test iterates over different channels (each channel individually and both channels together) + # and then tests the last sample hold functionality. When testing both channels together, 'None' + # is used to denote this case. # It verifies that the last sample is held correctly and that there are no glitches in the output signal in between the last sample and a new push. for channel in [libm2k.ANALOG_IN_CHANNEL_1, libm2k.ANALOG_IN_CHANNEL_2, None]: @@ -314,3 +337,16 @@ def test_aout_triggering(self): status_str = "START" if status == libm2k.START else "STOP" with self.subTest(msg=f'Test aout start with trigger for: status={status_str}, isCyclic={isCyclic}, autorearm={autorearm} '): self.assertEqual(test_result, True, msg=f'Specification not met') + + @unittest.skipIf(ctx.getFirmwareVersion() < 'v0.34', + 'Dual-channel sync fix for m2k-fw#20 requires firmware v0.34+') + def test_dual_channel_waveform_sync(self): + """Test that dual-channel waveform phase relationships are correct. + + Verifies fix for m2k-fw issue #20: When running both waveform generator + channels, the phase relationship should match configured settings. + """ + success, details = test_dual_channel_sync(ain, aout, trig, ctx) + + with self.subTest(msg='Dual-channel waveform synchronization (m2k-fw#20)'): + self.assertTrue(success, f'Synchronization failed: {details}') diff --git a/tests/m2k_trigger_test.py b/tests/m2k_trigger_test.py index 1953b016..ffa54d2c 100644 --- a/tests/m2k_trigger_test.py +++ b/tests/m2k_trigger_test.py @@ -25,6 +25,7 @@ import libm2k from trig_functions import * # trigger_jitter import logger +import main from repeat_test import repeat @@ -35,7 +36,15 @@ class B_TriggerTests(unittest.TestCase): def setUpClass(self): # print on the terminal some info log = logger.myLogger() - log.info("\n\n Trigger\n") + log.info("\n\n Trigger\n" + "Connections:\n" + "W1 ====> 1+\n" + "W2 ====> 2+\n" + "GND ===> 1-\n" + "If needed, press enter, when you've made the connections.") + + if main.wait_for_input: + input() def test_1_trigger_object(self): # Verifies if the Trigger object was successfully retrieved diff --git a/tests/main.py b/tests/main.py index 580eca9f..2ef52734 100644 --- a/tests/main.py +++ b/tests/main.py @@ -36,6 +36,7 @@ from m2k_trigger_test import * from m2k_digital_test import * from m2k_emulator_test import * +import re # from m2k_bug_checks import * global gen_reports, wait_for_input gen_reports = True @@ -54,7 +55,18 @@ def no_reports(): def wait_(): global wait_for_input - if len(sys.argv) == 1 or sys.argv[1] == "nofiles" or (len(sys.argv) > 3 and "C_PowerSupplyTests" in sys.argv): + + no_args_provided = len(sys.argv) == 1 + nofiles_flag_present = any(arg == "nofiles" for arg in sys.argv) + + has_power_supply_tests = any(re.search(r'C_PowerSupplyTests', arg) for arg in sys.argv) + has_analog_tests = any(re.search(r'A_AnalogTests', arg) for arg in sys.argv) + has_trigger_tests = any(re.search(r'B_TriggerTests', arg) for arg in sys.argv) + + # Wait for input if running tests that require hardware setup + running_hw_tests = has_power_supply_tests or has_analog_tests or has_trigger_tests + + if no_args_provided or nofiles_flag_present or running_hw_tests: wait_for_input = True else: wait_for_input = False