Skip to content

Commit e1fe366

Browse files
authored
Add pyadi helpers with doc and tests (#63)
* Add pyadi helper functions with example Signed-off-by: Travis F. Collins <[email protected]> * Add test for pyadi helper Signed-off-by: Travis F. Collins <[email protected]> * Add doc for pyadi helpers Signed-off-by: Travis F. Collins <[email protected]> * Rename helpers module Signed-off-by: Travis F. Collins <[email protected]> * Remove IIO dependency and add licensing Signed-off-by: Travis F. Collins <[email protected]> * Add helper to example to ignore cases when pyadi-iio is not installed Signed-off-by: Travis F. Collins <[email protected]> --------- Signed-off-by: Travis F. Collins <[email protected]>
1 parent 6197a9e commit e1fe366

File tree

6 files changed

+461
-0
lines changed

6 files changed

+461
-0
lines changed
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# Example of Fourier analysis using Genalyzer and an ADI SDR
2+
# Requires: genalyzer, pyadi-iio, matplotlib, numpy
3+
def main():
4+
5+
import time
6+
7+
import numpy as np
8+
import genalyzer as gn
9+
10+
from importlib.util import find_spec
11+
if find_spec("adi") is None or find_spec("iio") is None:
12+
print("pyadi-iio must be installed to run this example")
13+
return
14+
15+
import adi
16+
17+
#
18+
# Setup Example parameters
19+
#
20+
plot = True
21+
navg = 8
22+
nfft = 2**16
23+
fshift = 0e6
24+
tone_freq = 70e6
25+
uri = "ip:192.168.86.35"
26+
axis_fmt = gn.FreqAxisFormat.FREQ
27+
axis_type = gn.FreqAxisType.DC_CENTER
28+
window = gn.Window.BLACKMAN_HARRIS
29+
# Single sideband parameters (width in bins)
30+
ssb_fund = 4
31+
ssb_rest = 3
32+
if gn.Window.NO_WINDOW == window:
33+
ssb_fund = 0
34+
ssb_rest = 0
35+
36+
# Setup SDR
37+
sdr = adi.adrv9009_zu11eg(uri)
38+
fs = int(sdr.rx_sample_rate)
39+
sdr.rx_lo = int(1e9)
40+
sdr.tx_lo = int(1e9)
41+
sdr.rx_buffer_size = nfft * navg
42+
sdr.rx_enabled_channels = [0]
43+
44+
# Start transmission
45+
sdr.dds_single_tone(tone_freq, 0.9, 0)
46+
time.sleep(2)
47+
48+
# Acquisition and processing
49+
for i in range(16):
50+
x = sdr.rx()
51+
52+
fft_cplx, fft_db, fft_freq_out = gn.pai.fft(sdr, x, navg, window)
53+
54+
#
55+
# Fourier analysis configuration
56+
#
57+
key = "fa"
58+
gn.mgr_remove(key)
59+
gn.fa_create(key)
60+
gn.fa_analysis_band(key, "fdata*0.0", "fdata*1.0")
61+
gn.fa_fixed_tone(key, "A", gn.FaCompTag.SIGNAL, tone_freq, ssb_fund)
62+
gn.fa_conv_offset(key, 0.0 != fshift)
63+
gn.fa_hd(key, 3)
64+
gn.fa_ssb(key, gn.FaSsb.DEFAULT, ssb_rest)
65+
gn.fa_ssb(key, gn.FaSsb.DC, -1)
66+
gn.fa_ssb(key, gn.FaSsb.SIGNAL, -1)
67+
gn.fa_ssb(key, gn.FaSsb.WO, -1)
68+
gn.fa_fdata(key, fs)
69+
gn.fa_fsample(key, fs)
70+
gn.fa_fshift(key, fshift)
71+
print(gn.fa_preview(key, True))
72+
73+
#
74+
# Fourier analysis execution
75+
#
76+
results = gn.fft_analysis(key, fft_cplx, nfft, axis_type)
77+
carrier = gn.fa_result_string(results, "carrierindex")
78+
maxspur = gn.fa_result_string(results, "maxspurindex")
79+
80+
#
81+
# Print results
82+
#
83+
for k in [
84+
"fsnr",
85+
"sfdr",
86+
"dc:mag_dbfs",
87+
"A:freq",
88+
"A:ffinal",
89+
"A:mag_dbfs",
90+
"A:phase",
91+
"-3A:mag_dbc",
92+
]:
93+
print("{:20s}{:20.6f}".format(k, results[k]))
94+
print("{:20s}{:20s}".format("Carrier", carrier))
95+
print("{:20s}{:20s}".format("MaxSpur", maxspur))
96+
97+
#
98+
# Plot
99+
#
100+
if plot:
101+
import matplotlib.pyplot as pl
102+
from matplotlib.patches import Rectangle as MPRect
103+
104+
# freq_axis = gn.freq_axis(nfft, axis_type, fs, axis_fmt)
105+
# fft_db = gn.db(fft_cplx)
106+
fig = pl.figure(1)
107+
fig.clf()
108+
pl.plot(fft_freq_out, fft_db)
109+
pl.grid(True)
110+
pl.xlim(fft_freq_out[0], fft_freq_out[-1])
111+
pl.ylim(-140.0, 20.0)
112+
annots = gn.fa_annotations(results, axis_type, axis_fmt)
113+
for x, y, label in annots["labels"]:
114+
pl.annotate(label, xy=(x, y), ha="center", va="bottom")
115+
for line in annots["lines"]:
116+
pl.axline((line[0], line[1]), (line[2], line[3]), c="pink")
117+
for box in annots["ab_boxes"]:
118+
fig.axes[0].add_patch(
119+
MPRect(
120+
(box[0], box[1]),
121+
box[2],
122+
box[3],
123+
ec="lightgray",
124+
fc="gainsboro",
125+
fill=True,
126+
hatch="x",
127+
)
128+
)
129+
for box in annots["tone_boxes"]:
130+
fig.axes[0].add_patch(
131+
MPRect(
132+
(box[0], box[1]),
133+
box[2],
134+
box[3],
135+
ec="pink",
136+
fc="pink",
137+
fill=True,
138+
hatch="x",
139+
)
140+
)
141+
pl.show()
142+
143+
144+
if __name__ == "__main__":
145+
main()

bindings/python/genalyzer/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,4 @@
9292

9393
import genalyzer.simplified_beta as simplified_beta
9494
import genalyzer.helpers as helpers
95+
import genalyzer.pai_inf as pai
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# Copyright (C) 2025 Analog Devices, Inc.
2+
#
3+
# SPDX short identifier: ADIBSD OR GPL-2.0-or-later
4+
"""Helper functions for pyadi-iio devices."""
5+
6+
import numpy as np
7+
8+
import genalyzer as gn
9+
10+
11+
def fft(
12+
interface,
13+
data,
14+
navg=1,
15+
window=gn.Window.NO_WINDOW,
16+
axis_type=gn.FreqAxisType.DC_CENTER,
17+
axis_fmt=gn.FreqAxisFormat.FREQ,
18+
):
19+
"""Perform FFT based off pyadi-iio interface.
20+
21+
This function performs an FFT on data acquired from a pyadi-iio device and
22+
generate the scaled FFT result in dB along with the corresponding frequency axis.
23+
The dB scaled data is shifted if the axis_type is set to DC_CENTER.
24+
The function supports both real and complex data. For complex data, the input
25+
numpy array should have a complex dtype (np.complex64 or np.complex128).
26+
For real data, the input numpy array should have an integer dtype (e.g., np.int16,
27+
np.int32, etc.) matching the data format of the device channel.
28+
The function also supports multiple channels if the pyadi-iio device has more
29+
than one enabled channel. In this case, the input data should be a list of numpy
30+
arrays, one for each enabled channel.
31+
32+
Args:
33+
``interface``: pyadi-iio device instance.
34+
35+
``data``: numpy array or list of numpy arrays (for multiple channels).
36+
37+
``navg``: number of averages.
38+
39+
``window``: window type from gn.Window enum.
40+
41+
``axis_type``: frequency axis type from gn.FreqAxisType enum.
42+
43+
``axis_fmt``: frequency axis format from gn.FreqAxisFormat enum.
44+
45+
Returns:
46+
``fft_out``: FFT output as a numpy array or list of numpy arrays (for multiple channels).
47+
48+
``fft_out_db``: dB scaled FFT output as a numpy array or list of numpy arrays (for multiple channels).
49+
50+
``fft_freq_out``: frequency axis as a numpy array or list of numpy arrays (for multiple channels).
51+
"""
52+
# Checks
53+
assert hasattr(
54+
interface, "_rxadc"
55+
), "Non standard pyadi-iio device. interface must have _rxadc attribute"
56+
57+
# assert isinstance(
58+
# interface._rxadc, iio.Device
59+
# ), "interface must be an iio.Device as _rxadc attribute"
60+
if not isinstance(data, (np.ndarray, list)) and not all(
61+
isinstance(d, np.ndarray) for d in data
62+
):
63+
raise ValueError("data must be a numpy array or a list of numpy arrays")
64+
assert navg > 0 and isinstance(navg, int), "navg must be a positive integer"
65+
66+
# Check data meets channels
67+
if len(interface.rx_enabled_channels) > 1:
68+
if not isinstance(data, list):
69+
raise ValueError("data must be a list when multiple channels are enabled")
70+
if len(data) != len(interface.rx_enabled_channels):
71+
raise ValueError("data length must match number of enabled channels")
72+
elif isinstance(data, list):
73+
raise ValueError("data must be a numpy array when a single channel is enabled")
74+
elif len(interface.rx_enabled_channels) == 0:
75+
raise ValueError("No enabled channels found in interface")
76+
77+
if hasattr(interface, "rx_sample_rate"):
78+
fs = int(interface.rx_sample_rate)
79+
elif hasattr(interface, "sample_rate"):
80+
fs = int(interface.sample_rate)
81+
else:
82+
raise ValueError("Sample rate not found in interface")
83+
84+
fft_out = {}
85+
fft_out_db = {}
86+
fft_freq_out = {}
87+
88+
for i, rx_ch in enumerate(interface.rx_enabled_channels):
89+
if isinstance(rx_ch, int):
90+
channel = interface._rxadc.channels[rx_ch]
91+
elif isinstance(rx_ch, str):
92+
channel = interface._rxadc.find_channel(rx_ch, False)
93+
else:
94+
raise ValueError("rx_channel must be int or str")
95+
96+
if not channel:
97+
raise ValueError(
98+
f"Channel {rx_ch} not found in device {interface._rxadc.name}"
99+
)
100+
101+
df = channel.data_format
102+
fmt = ("i" if df.is_signed is True else "u") + str(df.length // 8)
103+
fmt = f">{fmt}" if df.is_be else fmt
104+
105+
qres = df.bits
106+
107+
fmt = np.dtype(fmt)
108+
109+
assert fmt in [
110+
np.dtype("int16"),
111+
np.dtype("int32"),
112+
], "Unsupported data format"
113+
114+
code_fmt = gn.CodeFormat.TWOS_COMPLEMENT # Binary scaling not supported for now
115+
116+
rx_data = data[i] if isinstance(data, list) else data
117+
118+
is_complex = rx_data.dtype in [np.complex64, np.complex128]
119+
nfft = len(rx_data) // (navg)
120+
assert navg * nfft == len(rx_data), "data length must be multiple of navg"
121+
122+
if is_complex:
123+
x_re = np.array(rx_data.real).astype(fmt)
124+
x_im = np.array(rx_data.imag).astype(fmt)
125+
fft_cplx = gn.fft(x_re, x_im, qres, navg, nfft, window, code_fmt)
126+
else:
127+
x = np.array(rx_data).astype(fmt)
128+
fft_cplx = gn.fft_real(x, qres, navg, nfft, window, code_fmt)
129+
130+
# Frequency axis and dB
131+
freq_axis = gn.freq_axis(nfft, axis_type, fs, axis_fmt)
132+
fft_db = gn.db(fft_cplx)
133+
if gn.FreqAxisType.DC_CENTER == axis_type:
134+
fft_db = gn.fftshift(fft_db)
135+
136+
fft_out[rx_ch] = fft_cplx
137+
fft_out_db[rx_ch] = fft_db
138+
fft_freq_out[rx_ch] = freq_axis
139+
140+
if len(fft_out) == 1:
141+
first = list(fft_out.keys())[0]
142+
fft_out = fft_out[first]
143+
fft_out_db = fft_out_db[first]
144+
fft_freq_out = fft_freq_out[first]
145+
146+
return fft_out, fft_out_db, fft_freq_out

0 commit comments

Comments
 (0)