diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..47acdf5 Binary files /dev/null and b/.DS_Store differ diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..c10b6bc --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,58 @@ +name: coverage + +on: + push: + branches: [ master, dev ] + pull_request: + branches: [ master, dev ] + +jobs: + coverage: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.11'] + steps: + - name: Check out code from GitHub + uses: actions/checkout@v4.1.6 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements_test.txt + pip install tox + - name: Run Coverage + run: | + tox -r -e cov + - name: Upload coverage + uses: actions/upload-artifact@v4.3.3 + with: + name: coverage-${{ matrix.python-version }} + path: coverage.xml + overwrite: true + upload-coverage: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.11'] + needs: + - coverage + timeout-minutes: 10 + steps: + - name: Check out code from GitHub + uses: actions/checkout@v4.1.6 + - name: Download all coverage artifacts + uses: actions/download-artifact@v4.1.7 + with: + name: coverage-${{ matrix.python-version }} + path: coverage.xml + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4.4.1 + with: + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} + name: python-adc-eval diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 9bc50f3..076f74e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.9] + python-version: [3.11] steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..8b2ae6e --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,33 @@ +name: tests + +on: + push: + branches: [ main, dev ] + pull_request: + branches: [ main, dev ] + +jobs: + build: + runs-on: ${{ matrix.platform }} + strategy: + max-parallel: 4 + matrix: + platform: + - ubuntu-latest + python-version: ['3.9', '3.10', '3.11', '3.12'] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements_test.txt + pip install tox + - name: Test + run: | + tox -r diff --git a/.gitignore b/.gitignore index b1d9456..cfec992 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__ python_adc_eval.egg-info dist .ruff_cache +.coverage diff --git a/README.rst b/README.rst index 21d9f5d..fa08338 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,5 @@ -python-adc-eval |Lint| |PyPi Version| |Codestyle| -=================================================== +python-adc-eval |Lint| |PyPi Version| |Codecov| |Codestyle| +============================================================= A python-based ADC evaluation tool, suitable for standalone or library-based usage @@ -48,7 +48,9 @@ Given an array of values representing the output of an ADC, the spectrum can be .. |Lint| image:: https://github.com/fronzbot/python-adc-eval/workflows/Lint/badge.svg :target: https://github.com/fronzbot/python-adc-eval/actions?query=workflow%3ALint -.. |PyPi Version| image:: https://img.shields.io/pypi/v/spithon.svg +.. |PyPi Version| image:: https://img.shields.io/pypi/v/python-adc-eval.svg :target: https://pypi.org/project/python-adc-eval .. |Codestyle| image:: https://img.shields.io/badge/code%20style-black-000000.svg :target: https://github.com/psf/black +.. |Codecov| image:: https://codecov.io/gh/fronzbot/python-adc-eval/graph/badge.svg?token=156GMQ4NNV + :target: https://codecov.io/gh/fronzbot/python-adc-eval diff --git a/adc_eval/analyser.png b/adc_eval/analyser.png deleted file mode 100644 index e637646..0000000 Binary files a/adc_eval/analyser.png and /dev/null differ diff --git a/adc_eval/spectrum.py b/adc_eval/spectrum.py index fc0837c..66662ae 100644 --- a/adc_eval/spectrum.py +++ b/adc_eval/spectrum.py @@ -7,7 +7,7 @@ def db_to_pow(value, places=3): """Convert dBW to W.""" if isinstance(value, np.ndarray): - return 10 * np.log10(value) + return 10 ** (0.1 * value) return round(10 ** (0.1 * value), places) @@ -90,18 +90,25 @@ def find_harmonics(spectrum, freq, nfft, bin_sig, psig, harms=5, leak=20): bin_harm = int(harm - (zone - 1) * nfft / 2) # Make sure we pick the max bin where power is maximized; due to spectral leakage - bin_harm_max = bin_harm + # if bin_harm == nfft/2, set to bin of 0 + if bin_harm == nfft / 2: + bin_harm = 0 + pwr_max = spectrum[bin_harm] for i in range(bin_harm - leak, bin_harm + leak + 1): - if spectrum[i] > spectrum[bin_harm_max]: - bin_harm_max = i - - bin_harm = bin_harm_max + try: + pwr = spectrum[i] + if pwr > pwr_max: + bin_harm = i + pwr_max = pwr + except IndexError: + # bin + leakage out of bounds, so stop looking + break harm_stats["harm"][harm_index]["bin"] = bin_harm - harm_stats["harm"][harm_index]["power"] = spectrum[bin_harm] + harm_stats["harm"][harm_index]["power"] = pwr harm_stats["harm"][harm_index]["freq"] = round(freq[bin_harm] / 1e6, 1) - harm_stats["harm"][harm_index]["dBc"] = dBW(spectrum[bin_harm] / psig) - harm_stats["harm"][harm_index]["dB"] = dBW(spectrum[bin_harm]) + harm_stats["harm"][harm_index]["dBc"] = dBW(pwr / psig) + harm_stats["harm"][harm_index]["dB"] = dBW(pwr) harm_index = harm_index + 1 @@ -118,7 +125,9 @@ def calc_psd(data, fs, nfft=2**12, single_sided=False): psd = np.mean(XF, axis=1) / (fs / nfft) # average the ffts and divide by bin width freq = fs * np.linspace(0, 1, nfft) if single_sided: + # First we double all the bins, then we halve the DC bin psd = 2 * psd[0 : int(nfft / 2)] + psd[0] /= 2 freq = freq[0 : int(nfft / 2)] return (freq, psd) diff --git a/pyproject.toml b/pyproject.toml index 82035ea..a8fb201 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ classifiers = [ ] requires-python = ">=3.8.0" dependencies = [ - "matplotlib==3.8.4", + "matplotlib==3.9.0", ] [project.urls] diff --git a/requirements.txt b/requirements.txt index b04e714..9a9169b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -matplotlib==3.8.4 +matplotlib==3.9.0 diff --git a/requirements_test.txt b/requirements_test.txt index 86c9901..4e29ee8 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,7 +1,11 @@ -black==24.4.1 -coverage==7.5.0 -pylint==3.1.0 -ruff==0.4.1 -tox==4.14.2 +black==24.4.2 +coverage==7.5.3 +pylint==3.2.3 +pytest==8.2.2 +pytest-cov==5.0.0 +pytest-sugar==1.0.0 +pytest-timeout==2.3.1 +ruff==0.4.8 +tox==4.15.1 restructuredtext-lint==1.4.0 -pygments==2.17.2 +pygments==2.18.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..c5d4cb8 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Init file for tests directory.""" diff --git a/tests/test_calc_psd.py b/tests/test_calc_psd.py new file mode 100644 index 0000000..8aae2e8 --- /dev/null +++ b/tests/test_calc_psd.py @@ -0,0 +1,139 @@ +"""Test the calc_psd method.""" + +import unittest +import numpy as np +from unittest import mock +from adc_eval import spectrum + + +class TestCalcPSD(unittest.TestCase): + """Test the calc_psd method.""" + + def setUp(self): + """Initialize tests.""" + self.nfft = 2**8 + self.nlen = 2**18 + accuracy = 0.01 + self.bounds = [1 - accuracy, 1 + accuracy] + np.random.seed(1) + + def test_calc_psd_randomized_dual(self): + """Test calc_psd with random data.""" + for i in range(0, 10): + data = np.random.randn(self.nlen) + (freq, psd) = spectrum.calc_psd(data, 1, nfft=self.nfft, single_sided=False) + mean_val = np.mean(psd) + self.assertTrue(self.bounds[0] <= mean_val <= self.bounds[1], msg=mean_val) + + def test_calc_psd_randomized_single(self): + """Test calc_psd with random data and single-sided.""" + for i in range(0, 10): + data = np.random.randn(self.nlen) + (freq, psd) = spectrum.calc_psd(data, 1, nfft=self.nfft, single_sided=True) + mean_val = np.mean(psd) + self.assertTrue( + 2 * self.bounds[0] <= mean_val <= 2 * self.bounds[1], msg=mean_val + ) + + def test_calc_psd_zeros_dual(self): + """Test calc_psd with zeros.""" + data = np.zeros(self.nlen) + (freq, psd) = spectrum.calc_psd(data, 1, nfft=self.nfft, single_sided=False) + mean_val = np.mean(psd) + self.assertTrue( + self.bounds[0] - 1 <= mean_val <= self.bounds[1] - 1, msg=mean_val + ) + + def test_calc_psd_zeros_single(self): + """Test calc_psd with zeros and single-sided..""" + data = np.zeros(self.nlen) + (freq, psd) = spectrum.calc_psd(data, 1, nfft=self.nfft, single_sided=True) + mean_val = np.mean(psd) + self.assertTrue( + self.bounds[0] - 1 <= mean_val <= self.bounds[1] - 1, msg=mean_val + ) + + def test_calc_psd_ones_dual(self): + """Test calc_psd with ones.""" + data = np.ones(self.nlen) + (freq, psd) = spectrum.calc_psd(data, 1, nfft=self.nfft, single_sided=False) + mean_val = np.mean(psd) + self.assertTrue(self.bounds[0] <= mean_val <= self.bounds[1], msg=mean_val) + + def test_calc_psd_ones_single(self): + """Test calc_psd with ones and single-sided.""" + data = np.ones(self.nlen) + (freq, psd) = spectrum.calc_psd(data, 1, nfft=self.nfft, single_sided=True) + mean_val = np.mean(psd) + self.assertTrue( + 2 * self.bounds[0] <= mean_val <= 2 * self.bounds[1], msg=mean_val + ) + + def test_calc_psd_two_sine_dual(self): + """Test calc_psd with two sine waves.""" + fs = 1 + fbin = fs / self.nfft + f1 = 29 * fbin + f2 = 97 * fbin + a1 = 0.37 + a2 = 0.11 + t = 1 / fs * np.linspace(0, self.nlen - 1, self.nlen) + data = a1 * np.sin(2 * np.pi * f1 * t) + a2 * np.sin(2 * np.pi * f2 * t) + (freq, psd) = spectrum.calc_psd(data, fs, nfft=self.nfft, single_sided=False) + exp_peaks = [ + round(a1**2 / 4 * self.nfft, 3), + round(a2**2 / 4 * self.nfft, 3), + ] + exp_f1 = [round(f1, 2), round(fs - f1, 2)] + exp_f2 = [round(f2, 2), round(fs - f2, 2)] + + peak1 = max(psd) + ipeaks = np.where(psd >= peak1 * self.bounds[0])[0] + fpeaks = [round(freq[ipeaks[0]], 2), round(freq[ipeaks[1]], 2)] + + self.assertEqual(round(peak1, 3), exp_peaks[0]) + self.assertListEqual(fpeaks, exp_f1) + + psd[ipeaks[0]] = 0 + psd[ipeaks[1]] = 0 + + peak2 = max(psd) + ipeaks = np.where(psd >= peak2 * self.bounds[0])[0] + fpeaks = [round(freq[ipeaks[0]], 2), round(freq[ipeaks[1]], 2)] + + self.assertEqual(round(peak2, 3), exp_peaks[1]) + self.assertListEqual(fpeaks, exp_f2) + + def test_calc_psd_two_sine_single(self): + """Test calc_psd with two sine waves, single-eided.""" + fs = 1 + fbin = fs / self.nfft + f1 = 29 * fbin + f2 = 97 * fbin + a1 = 0.37 + a2 = 0.11 + t = 1 / fs * np.linspace(0, self.nlen - 1, self.nlen) + data = a1 * np.sin(2 * np.pi * f1 * t) + a2 * np.sin(2 * np.pi * f2 * t) + (freq, psd) = spectrum.calc_psd(data, fs, nfft=self.nfft, single_sided=True) + exp_peaks = [ + round(a1**2 / 2 * self.nfft, 3), + round(a2**2 / 2 * self.nfft, 3), + ] + exp_f1 = round(f1, 2) + exp_f2 = round(f2, 2) + + peak1 = max(psd) + ipeak = np.where(psd == peak1)[0][0] + fpeak = round(freq[ipeak], 2) + + self.assertEqual(round(peak1, 3), exp_peaks[0]) + self.assertEqual(fpeak, exp_f1) + + psd[ipeak] = 0 + + peak2 = max(psd) + ipeak = np.where(psd == peak2)[0][0] + fpeak = round(freq[ipeak], 2) + + self.assertEqual(round(peak2, 3), exp_peaks[1]) + self.assertEqual(fpeak, exp_f2) diff --git a/tests/test_spectrum.py b/tests/test_spectrum.py new file mode 100644 index 0000000..03cfd51 --- /dev/null +++ b/tests/test_spectrum.py @@ -0,0 +1,106 @@ +"""Test the spectrum module.""" + +import unittest +import numpy as np +from unittest import mock +from adc_eval import spectrum + + +class TestSpectrum(unittest.TestCase): + """Test the spectrum module.""" + + def setUp(self): + """Initialize tests.""" + pass + + def test_db_to_pow_places(self): + """Test the db_to_pow conversion with multiple places.""" + test_val = 29.9460497 + exp_val = [988, 987.7, 987.65, 987.654, 987.6543] + + for i in range(0, len(exp_val)): + self.assertEqual(spectrum.db_to_pow(test_val, places=i), exp_val[i]) + + def test_db_to_pow_ndarray(self): + """Test db_to_pow with ndarray input.""" + test_val = np.array([30.0]) + self.assertEqual(spectrum.db_to_pow(test_val), np.array([1000.0])) + + def test_dbW(self): + """Test the dbW conversion with normal inputs.""" + test_val = 9.7197255 + exp_val = [10, 9.9, 9.88, 9.877, 9.8765] + + for i in range(0, len(exp_val)): + self.assertEqual(spectrum.dBW(test_val, places=i), exp_val[i]) + + def test_dbW_ndarray(self): + """Test dbW with ndarray input.""" + test_val = np.array([100.0]) + self.assertEqual(spectrum.dBW(test_val), np.array([20.0])) + + def test_enob(self): + """Test enob with muliple places.""" + test_val = 60.123456 + exp_val = [10, 9.7, 9.69, 9.695, 9.6949] + + for i in range(0, len(exp_val)): + self.assertEqual(spectrum.enob(test_val, places=i), exp_val[i]) + + @mock.patch("adc_eval.spectrum.calc_psd") + def test_get_spectrum(self, mock_calc_psd): + """Test that the get_spectrum method returns power spectrum.""" + fs = 4 + nfft = 3 + data = np.array([1]) + exp_spectrum = np.array([fs / nfft]) + + mock_calc_psd.return_value = (None, data) + + self.assertEqual( + spectrum.get_spectrum(None, fs=fs, nfft=nfft), (None, exp_spectrum) + ) + + def test_sndr_sfdr_outputs(self): + """Test the sndr_sfdr method outputs.""" + data = np.array([1, 2, 91, 7]) + freq = np.array([100, 200, 300, 400]) + full_scale = -3 + nfft = 2**8 + exp_return = { + "sig": { + "freq": 300, + "bin": 2, + "power": 91, + "dB": 19.6, + "dBFS": round(19.590 - full_scale, 1), + }, + "spur": { + "freq": 400, + "bin": 3, + "power": 7, + "dB": 8.5, + "dBFS": round(8.451 - full_scale, 1), + }, + "noise": { + "floor": 18 / nfft, + "power": 9, + "rms": 3, + "dBHz": round(-11.5297 - full_scale, 1), + }, + "sndr": { + "dBc": 10.0, + "dBFS": round(full_scale - 9.542, 1), + }, + "sfdr": { + "dBc": 11.1, + "dBFS": round(full_scale - 8.451, 1), + }, + "enob": { + "bits": round((full_scale - 11.3024) / 6.02, 1), + }, + } + + result = spectrum.sndr_sfdr(data, freq, nfft, 0, full_scale=full_scale) + for key, val in exp_return.items(): + self.assertDictEqual(result[key], val, msg=key) diff --git a/tests/test_spectrum_plotting.py b/tests/test_spectrum_plotting.py new file mode 100644 index 0000000..d282609 --- /dev/null +++ b/tests/test_spectrum_plotting.py @@ -0,0 +1,136 @@ +"""Test the spectrum plotting functions.""" + +import unittest +import numpy as np +from unittest import mock +from adc_eval import spectrum + + +class TestSpectrumPlotting(unittest.TestCase): + """Test the spectrum module.""" + + def setUp(self): + """Initialize tests.""" + self.nlen = 2**16 + self.nfft = 2**12 + self.fs = 1 + self.bin = 13 + self.arms = 0.5 / np.sqrt(2) + self.fin = self.fs / self.nfft * self.bin + + def gen_spectrum(self, harmonics): + """Generate a wave with arbitrary harmonics.""" + t = 1 / self.fs * np.linspace(0, self.nlen - 1, self.nlen) + vin = np.zeros(len(t)) + for i in range(1, harmonics + 1): + vin += np.sqrt(2) * self.arms / i * np.sin(2 * np.pi * i * self.fin * t) + + return spectrum.get_spectrum(vin, fs=self.fs, nfft=self.nfft) + + def test_find_harmonics(self): + """Test the find harmonics method.""" + for i in range(2, 10): + (freq, pwr) = self.gen_spectrum(10) + + stats = spectrum.find_harmonics( + pwr, freq, self.nfft, self.bin, self.arms, harms=i, leak=0 + ) + + for x in range(2, i + 1): + msg_txt = f"harm={i}, index={x}" + self.assertEqual(stats["harm"][x]["bin"], x * self.bin, msg=msg_txt) + self.assertEqual( + round(stats["harm"][x]["power"], 4), + round((self.arms / x) ** 2, 4), + msg=msg_txt, + ) + self.assertEqual( + stats["harm"][x]["freq"], + round(freq[x * self.bin] / 1e6, 1), + msg=msg_txt, + ) + + def test_find_harmonics_with_leakage(self): + """Test the find harmonics method with spectral leakage.""" + self.bin = 13.5 + leakage_bins = 5 + for i in range(2, 10): + (freq, pwr) = self.gen_spectrum(10) + + stats = spectrum.find_harmonics( + pwr, freq, self.nfft, self.bin, self.arms, harms=i, leak=leakage_bins + ) + + for x in range(2, i + 1): + msg_txt = f"harm={i}, index={x}" + self.assertTrue( + x * self.bin - leakage_bins + <= stats["harm"][x]["bin"] + <= x * self.bin + leakage_bins, + msg=msg_txt, + ) + + def test_find_harmonics_with_leakage_outside_bounds(self): + """Test find harmonics with leakage bins exceeding array bounds.""" + self.bin = self.nfft / 4 - 0.5 + (freq, pwr) = self.gen_spectrum(5) + leakage_bins = 2 + stats = spectrum.find_harmonics( + pwr, freq, self.nfft, self.bin, self.arms, harms=2, leak=leakage_bins + ) + self.assertTrue(self.nfft / 2 - 3 <= stats["harm"][2]["bin"], self.nfft / 2 - 1) + + def test_find_harmonics_on_fft_bound(self): + """Test find harmonics with harmonics landing at nfft/2.""" + self.nfft = 2**12 + self.bin = self.nfft / 8 + (freq, pwr) = self.gen_spectrum(10) + leakage_bins = 0 + stats = spectrum.find_harmonics( + pwr, freq, self.nfft, self.bin, self.arms, harms=5, leak=leakage_bins + ) + self.assertEqual(stats["harm"][2]["bin"], 2 * self.bin) + self.assertEqual(stats["harm"][3]["bin"], 3 * self.bin) + self.assertEqual(stats["harm"][4]["bin"], 0) + self.assertEqual(stats["harm"][5]["bin"], self.nfft - 5 * self.bin) + + def test_plot_string(self): + """Test proper return of plotting string.""" + self.bin = 13 + (freq, pwr) = self.gen_spectrum(3) + stats = spectrum.sndr_sfdr(pwr, freq, self.nfft, leak=0, full_scale=0) + harms = spectrum.find_harmonics( + pwr, freq, self.nfft, self.bin, self.arms, harms=3, leak=0 + ) + all_stats = {**stats, **harms} + + plt_str = spectrum.get_plot_string( + all_stats, 0, self.fs, self.nfft, window="rectangular" + ) + + # Check for important information, not everything + msg_txt = f"{all_stats}\n{plt_str}" + self.assertTrue(f"NFFT = {self.nfft}" in plt_str, msg=msg_txt) + self.assertTrue( + f"ENOB = {all_stats['enob']['bits']} bits" in plt_str, msg=msg_txt + ) + self.assertTrue( + f"SNDR = {all_stats['sndr']['dBFS']} dBFS" in plt_str, msg=msg_txt + ) + self.assertTrue( + f"SFDR = {all_stats['sfdr']['dBFS']} dBFS" in plt_str, msg=msg_txt + ) + self.assertTrue( + f"Noise Floor = {all_stats['noise']['dBHz']} dBFS/Hz" in plt_str, + msg=msg_txt, + ) + self.assertTrue( + f"HD2 = {round(all_stats['harm'][2]['dB'], 1)} dBFS @ {all_stats['harm'][2]['freq']} MHz" + in plt_str, + msg=msg_txt, + ) + self.assertTrue( + f"HD3 = {round(all_stats['harm'][3]['dB'], 1)} dBFS @ {all_stats['harm'][3]['freq']} MHz" + in plt_str, + msg=msg_txt, + ) diff --git a/tox.ini b/tox.ini index 3e99ad3..fc9bbe1 100644 --- a/tox.ini +++ b/tox.ini @@ -1,10 +1,14 @@ [tox] -envlist = lint,build +envlist = lint,build,py39,py310,py311,py312 +skip_missing_interpreters = True +skipsdist = True [testenv] setenv = LANG=en_US.UTF-8 PYTHONPATH = {toxinidir} +commands = + pytest --timeout=9 --durations=10 --cov=adc_eval --cov-report term-missing {posargs} deps = -r{toxinidir}/requirements.txt -r{toxinidir}/requirements_test.txt @@ -26,3 +30,13 @@ basepython = python3 ignore_errors = True commands = pip install . + +[testenv:cov] +setenv = + LANG=en_US.UTF-8 + PYTHONPATH = {toxinidir} +commands = + pytest --timeout=9 --durations=10 --cov=adc_eval --cov-report=xml {posargs} +deps = + -r{toxinidir}/requirements.txt + -r{toxinidir}/requirements_test.txt