├── .gitignore ├── LICENSE ├── MANIFEST.in ├── PKGBUILD ├── README.rst ├── setup.py └── soapypower ├── __init__.py ├── __main__.py ├── power.py ├── psd.py ├── threadpool.py ├── version.py └── writer.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.py[cod] 3 | 4 | build/ 5 | dist/ 6 | MANIFEST 7 | *.egg-info 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Michal Krenek 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | -------------------------------------------------------------------------------- /PKGBUILD: -------------------------------------------------------------------------------- 1 | # Maintainer: Michal Krenek (Mikos) 2 | pkgname=soapy_power 3 | pkgver=1.6.1 4 | pkgrel=1 5 | pkgdesc="Obtain power spectrum from SoapySDR devices (RTL-SDR, Airspy, SDRplay, HackRF, bladeRF, USRP, LimeSDR, etc.)" 6 | arch=('any') 7 | url="https://github.com/xmikos/soapy_power" 8 | license=('MIT') 9 | depends=('python' 'python-numpy' 'simplesoapy>=1.5.0' 'simplespectral') 10 | makedepends=('python-setuptools') 11 | optdepends=( 12 | 'soapyrtlsdr-git: support for RTL-SDR (RTL2832U) dongles' 13 | 'soapyairspy-git: support for Airspy R2 and Airspy Mini' 14 | 'soapysdrplay-git: support for SDRplay RSP' 15 | 'soapyhackrf-git: support for HackRF' 16 | 'soapybladerf-git: support for Nuand bladeRF' 17 | 'soapyuhd-git: support for Ettus USRP' 18 | 'soapylms7-git: support for LimeSDR and other LMS7002M based Myriad RF boards' 19 | 'soapyredpitaya-git: support for Red Pitaya' 20 | 'soapyosmo-git: support for MiriSDR and RFSpace' 21 | 'soapyremote-git: use any SoapySDR device remotely over network' 22 | 'python-pyfftw: fastest FFT calculations with FFTW library' 23 | 'python-scipy: faster FFT calculations with scipy.fftpack library' 24 | ) 25 | source=(https://github.com/xmikos/soapy_power/archive/v$pkgver.tar.gz) 26 | 27 | build() { 28 | cd "$srcdir/${pkgname}-$pkgver" 29 | python setup.py build 30 | } 31 | 32 | package() { 33 | cd "$srcdir/${pkgname}-$pkgver" 34 | python setup.py install --root="$pkgdir" 35 | } 36 | 37 | # vim:set ts=2 sw=2 et: 38 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | soapy_power 2 | =========== 3 | 4 | Obtain power spectrum from SoapySDR devices (RTL-SDR, Airspy, SDRplay, HackRF, bladeRF, USRP, LimeSDR, etc.) 5 | 6 | Requirements 7 | ------------ 8 | 9 | - `Python 3 `_ 10 | - `NumPy `_ 11 | - `SimpleSoapy `_ 12 | - `SimpleSpectral `_ 13 | - Optional: `pyFFTW `_ (for fastest FFT calculations with FFTW library) 14 | - Optional: `SciPy `_ (for faster FFT calculations with scipy.fftpack library) 15 | 16 | You should always install SciPy or pyFFTW, because numpy.fft has horrible 17 | memory usage and is also much slower. 18 | 19 | Usage 20 | ----- 21 | :: 22 | 23 | usage: soapy_power [-h] [-f Hz|Hz:Hz] [-O FILE | --output-fd NUM] [-F {rtl_power,rtl_power_fftw,soapy_power_bin}] [-q] 24 | [--debug] [--detect] [--info] [--version] [-b BINS | -B Hz] [-n REPEATS | -t SECONDS | -T SECONDS] 25 | [-c | -u RUNS | -e SECONDS] [-d DEVICE] [-C CHANNEL] [-A ANTENNA] [-r Hz] [-w Hz] [-p PPM] 26 | [-g dB | -G STRING | -a] [--lnb-lo Hz] [--device-settings STRING] [--force-rate] [--force-bandwidth] 27 | [--tune-delay SECONDS] [--reset-stream] [-o PERCENT | -k PERCENT] [-s BUFFER_SIZE] [-S MAX_BUFFER_SIZE] 28 | [--even | --pow2] [--max-threads NUM] [--max-queue-size NUM] [--no-pyfftw] [-l] [-R] 29 | [-D {none,constant}] [--fft-window {boxcar,hann,hamming,blackman,bartlett,kaiser,tukey}] 30 | [--fft-window-param FLOAT] [--fft-overlap PERCENT] 31 | 32 | Obtain a power spectrum from SoapySDR devices 33 | 34 | Main options: 35 | -h, --help show this help message and exit 36 | -f Hz|Hz:Hz, --freq Hz|Hz:Hz 37 | center frequency or frequency range to scan, number can be followed by a k, M or G multiplier 38 | (default: 1420405752) 39 | -O FILE, --output FILE 40 | output to file (incompatible with --output-fd, default is stdout) 41 | --output-fd NUM output to existing file descriptor (incompatible with -O) 42 | -F {rtl_power,rtl_power_fftw,soapy_power_bin}, --format {rtl_power,rtl_power_fftw,soapy_power_bin} 43 | output format (default: rtl_power) 44 | -q, --quiet limit verbosity 45 | --debug detailed debugging messages 46 | --detect detect connected SoapySDR devices and exit 47 | --info show info about selected SoapySDR device and exit 48 | --version show program's version number and exit 49 | 50 | FFT bins: 51 | -b BINS, --bins BINS number of FFT bins (incompatible with -B, default: 512) 52 | -B Hz, --bin-size Hz bin size in Hz (incompatible with -b) 53 | 54 | Averaging: 55 | -n REPEATS, --repeats REPEATS 56 | number of spectra to average (incompatible with -t and -T, default: 1600) 57 | -t SECONDS, --time SECONDS 58 | integration time (incompatible with -T and -n) 59 | -T SECONDS, --total-time SECONDS 60 | total integration time of all hops (incompatible with -t and -n) 61 | 62 | Measurements: 63 | -c, --continue repeat the measurement endlessly (incompatible with -u and -e) 64 | -u RUNS, --runs RUNS number of measurements (incompatible with -c and -e, default: 1) 65 | -e SECONDS, --elapsed SECONDS 66 | scan session duration (time limit in seconds, incompatible with -c and -u) 67 | 68 | Device settings: 69 | -d DEVICE, --device DEVICE 70 | SoapySDR device to use 71 | -C CHANNEL, --channel CHANNEL 72 | SoapySDR RX channel (default: 0) 73 | -A ANTENNA, --antenna ANTENNA 74 | SoapySDR selected antenna 75 | -r Hz, --rate Hz sample rate (default: 2000000.0) 76 | -w Hz, --bandwidth Hz 77 | filter bandwidth (default: 0) 78 | -p PPM, --ppm PPM frequency correction in ppm 79 | -g dB, --gain dB total gain (incompatible with -G and -a, default: 37.2) 80 | -G STRING, --specific-gains STRING 81 | specific gains of individual amplification elements (incompatible with -g and -a, example: 82 | LNA=28,VGA=12,AMP=0 83 | -a, --agc enable Automatic Gain Control (incompatible with -g and -G) 84 | --lnb-lo Hz LNB LO frequency, negative for upconverters (default: 0) 85 | --device-settings STRING 86 | SoapySDR device settings (example: biastee=true) 87 | --force-rate ignore list of sample rates provided by device and allow any value 88 | --force-bandwidth ignore list of filter bandwidths provided by device and allow any value 89 | --tune-delay SECONDS time to delay measurement after changing frequency (to avoid artifacts) 90 | --reset-stream reset streaming after changing frequency (to avoid artifacts) 91 | 92 | Crop: 93 | -o PERCENT, --overlap PERCENT 94 | percent of overlap when frequency hopping (incompatible with -k) 95 | -k PERCENT, --crop PERCENT 96 | percent of crop when frequency hopping (incompatible with -o) 97 | 98 | Performance options: 99 | -s BUFFER_SIZE, --buffer-size BUFFER_SIZE 100 | base buffer size (number of samples, 0 = auto, default: 0) 101 | -S MAX_BUFFER_SIZE, --max-buffer-size MAX_BUFFER_SIZE 102 | maximum buffer size (number of samples, -1 = unlimited, 0 = auto, default: 0) 103 | --even use only even numbers of FFT bins 104 | --pow2 use only powers of 2 as number of FFT bins 105 | --max-threads NUM maximum number of PSD threads (0 = auto, default: 0) 106 | --max-queue-size NUM maximum size of PSD work queue (-1 = unlimited, 0 = auto, default: 0) 107 | --no-pyfftw don't use pyfftw library even if it is available (use scipy.fftpack or numpy.fft) 108 | 109 | Other options: 110 | -l, --linear linear power values instead of logarithmic 111 | -R, --remove-dc interpolate central point to cancel DC bias (useful only with boxcar window) 112 | -D {none,constant}, --detrend {none,constant} 113 | remove mean value from data to cancel DC bias (default: none) 114 | --fft-window {boxcar,hann,hamming,blackman,bartlett,kaiser,tukey} 115 | Welch's method window function (default: hann) 116 | --fft-window-param FLOAT 117 | shape parameter of window function (required for kaiser and tukey windows) 118 | --fft-overlap PERCENT 119 | Welch's method overlap between segments (default: 50) 120 | 121 | Example 122 | ------- 123 | :: 124 | 125 | [user@host ~] soapy_power -r 2.56M -f 88M:98M -B 500k -F rtl_power -O output.txt --even -T 1 --debug 126 | DEBUG: pyfftw module found (using 4 threads by default) 127 | DEBUG: Applying fixes for RTLSDR quirks... 128 | INFO: Using device: RTLSDR 129 | DEBUG: SoapySDR stream - buffer size: 8192 130 | DEBUG: SoapySDR stream - read timeout: 0.103200 131 | INFO: repeats: 106667 132 | INFO: samples: 640002 (time: 0.25000 s) 133 | INFO: max_buffer_size (samples): 32768000 (repeats: 5461333.33, time: 12.80000 s) 134 | INFO: buffer_size (samples): 647168 (repeats: 107861.33, time: 0.25280 s) 135 | INFO: buffer_repeats: 1 136 | INFO: overlap: 0.00000 137 | INFO: bin_size: 426666.67 Hz 138 | INFO: bins: 6 139 | INFO: bins (after crop): 6 140 | INFO: sample_rate: 2.560 MHz 141 | INFO: sample_rate (after crop): 2.560 MHz 142 | INFO: freq_range: 10.000 MHz 143 | INFO: hopping: YES 144 | INFO: hop_size: 2.560 MHz 145 | INFO: hops: 4 146 | INFO: min_center_freq: 89.280 MHz 147 | INFO: max_center_freq: 96.960 MHz 148 | INFO: min_freq (after crop): 88.000 MHz 149 | INFO: max_freq (after crop): 98.240 MHz 150 | DEBUG: Frequency hops table: 151 | DEBUG: Min: Center: Max: 152 | DEBUG: 88.000 MHz 89.280 MHz 90.560 MHz 153 | DEBUG: 90.560 MHz 91.840 MHz 93.120 MHz 154 | DEBUG: 93.120 MHz 94.400 MHz 95.680 MHz 155 | DEBUG: 95.680 MHz 96.960 MHz 98.240 MHz 156 | DEBUG: Run: 1 157 | DEBUG: Frequency hop: 89280000.00 Hz 158 | DEBUG: Tune time: 0.017 s 159 | DEBUG: Repeat: 1 160 | DEBUG: Acquisition time: 0.251 s 161 | DEBUG: Total hop time: 0.282 s 162 | DEBUG: FFT time: 0.103 s 163 | DEBUG: Frequency hop: 91840000.00 Hz 164 | DEBUG: Tune time: 0.010 s 165 | DEBUG: Repeat: 1 166 | DEBUG: Acquisition time: 0.251 s 167 | DEBUG: Total hop time: 0.272 s 168 | DEBUG: FFT time: 0.006 s 169 | DEBUG: Frequency hop: 94400000.00 Hz 170 | DEBUG: Tune time: 0.010 s 171 | DEBUG: Repeat: 1 172 | DEBUG: Acquisition time: 0.252 s 173 | DEBUG: Total hop time: 0.266 s 174 | DEBUG: FFT time: 0.004 s 175 | DEBUG: Frequency hop: 96960000.00 Hz 176 | DEBUG: Tune time: 0.010 s 177 | DEBUG: Repeat: 1 178 | DEBUG: Acquisition time: 0.253 s 179 | DEBUG: Total hop time: 0.267 s 180 | DEBUG: FFT time: 0.004 s 181 | DEBUG: Total run time: 1.095 s 182 | DEBUG: Number of USB buffer overflow errors: 0 183 | DEBUG: PSD worker threads: 4 184 | DEBUG: Max. PSD queue size: 2 / 40 185 | DEBUG: Writer worker threads: 1 186 | DEBUG: Max. Writer queue size: 2 / 100 187 | INFO: Total time: 1.137 s 188 | 189 | Output:: 190 | 191 | 2017-03-17, 13:18:25, 88000000.0, 90560000.0, 426666.666667, 647168, -98.6323, -98.7576, -97.3716, -98.3133, -98.8829, -98.9333 192 | 2017-03-17, 13:18:25, 90560000.0, 93120000.0, 426666.666667, 647168, -95.7163, -96.2564, -97.01, -98.1281, -90.701, -88.0872 193 | 2017-03-17, 13:18:25, 93120000.0, 95680000.0, 426666.666667, 647168, -99.0242, -91.3061, -91.9134, -85.4561, -86.0053, -97.8411 194 | 2017-03-17, 13:18:26, 95680000.0, 98240000.0, 426666.666667, 647168, -94.2324, -83.7932, -78.3108, -82.033, -89.1212, -97.4499 195 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup 4 | from soapypower.version import __version__ 5 | 6 | setup( 7 | name='soapy_power', 8 | version=__version__, 9 | description='Obtain power spectrum from SoapySDR devices (RTL-SDR, Airspy, SDRplay, HackRF, bladeRF, USRP, LimeSDR, etc.)', 10 | long_description=open('README.rst').read(), 11 | author='Michal Krenek (Mikos)', 12 | author_email='m.krenek@gmail.com', 13 | url='https://github.com/xmikos/soapy_power', 14 | license='MIT', 15 | packages=['soapypower'], 16 | entry_points={ 17 | 'console_scripts': [ 18 | 'soapy_power=soapypower.__main__:main' 19 | ], 20 | }, 21 | install_requires=[ 22 | 'numpy', 23 | 'simplesoapy>=1.5.0', 24 | 'simplespectral' 25 | ], 26 | classifiers=[ 27 | 'Development Status :: 4 - Beta', 28 | 'Environment :: Console', 29 | 'Intended Audience :: End Users/Desktop', 30 | 'Intended Audience :: Science/Research', 31 | 'Intended Audience :: Telecommunications Industry', 32 | 'License :: OSI Approved :: MIT License', 33 | 'Natural Language :: English', 34 | 'Operating System :: OS Independent', 35 | 'Programming Language :: Python :: 3', 36 | 'Topic :: Scientific/Engineering', 37 | 'Topic :: Communications :: Ham Radio', 38 | 'Topic :: Utilities' 39 | ] 40 | ) 41 | -------------------------------------------------------------------------------- /soapypower/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xmikos/soapy_power/46e12659b8d08af764dc09a1f31b0e85a68f808f/soapypower/__init__.py -------------------------------------------------------------------------------- /soapypower/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os, sys, logging, argparse, re, shutil, textwrap 4 | 5 | import simplesoapy 6 | from soapypower import writer 7 | from soapypower.version import __version__ 8 | 9 | logger = logging.getLogger(__name__) 10 | re_float_with_multiplier = re.compile(r'(?P[-+]?(\d+(\.\d*)?|\.\d+)([eE][-+]?\d+)?)(?P[kMGT])?') 11 | re_float_with_multiplier_negative = re.compile(r'^(?P-(\d+(\.\d*)?|\.\d+)([eE][-+]?\d+)?)(?P[kMGT])?$') 12 | multipliers = {'k': 1e3, 'M': 1e6, 'G': 1e9, 'T': 1e12} 13 | 14 | 15 | def float_with_multiplier(string): 16 | """Convert string with optional k, M, G, T multiplier to float""" 17 | match = re_float_with_multiplier.search(string) 18 | if not match or not match.group('num'): 19 | raise ValueError('String "{}" is not numeric!'.format(string)) 20 | 21 | num = float(match.group('num')) 22 | multi = match.group('multi') 23 | if multi: 24 | try: 25 | num *= multipliers[multi] 26 | except KeyError: 27 | raise ValueError('Unknown multiplier: {}'.format(multi)) 28 | return num 29 | 30 | 31 | def freq_or_freq_range(string): 32 | """Convert string with freq. or freq. range to list of floats""" 33 | return [float_with_multiplier(f) for f in string.split(':')] 34 | 35 | 36 | def specific_gains(string): 37 | """Convert string with gains of individual amplification elements to dict""" 38 | if not string: 39 | return {} 40 | 41 | gains = {} 42 | for gain in string.split(','): 43 | amp_name, value = gain.split('=') 44 | gains[amp_name.strip()] = float(value.strip()) 45 | return gains 46 | 47 | 48 | def device_settings(string): 49 | """Convert string with SoapySDR device settings to dict""" 50 | if not string: 51 | return {} 52 | 53 | settings = {} 54 | for setting in string.split(','): 55 | setting_name, value = setting.split('=') 56 | settings[setting_name.strip()] = value.strip() 57 | return settings 58 | 59 | 60 | def wrap(text, indent=' '): 61 | """Wrap text to terminal width with default indentation""" 62 | wrapper = textwrap.TextWrapper( 63 | width=int(os.environ.get('COLUMNS', 80)), 64 | initial_indent=indent, 65 | subsequent_indent=indent 66 | ) 67 | return '\n'.join(wrapper.wrap(text)) 68 | 69 | 70 | def detect_devices(soapy_args=''): 71 | """Returns detected SoapySDR devices""" 72 | devices = simplesoapy.detect_devices(soapy_args, as_string=True) 73 | text = [] 74 | text.append('Detected SoapySDR devices:') 75 | if devices: 76 | for i, d in enumerate(devices): 77 | text.append(' {}'.format(d)) 78 | else: 79 | text.append(' No devices found!') 80 | return (devices, '\n'.join(text)) 81 | 82 | 83 | def device_info(soapy_args=''): 84 | """Returns info about selected SoapySDR device""" 85 | text = [] 86 | try: 87 | device = simplesoapy.SoapyDevice(soapy_args) 88 | text.append('Selected device: {}'.format(device.hardware)) 89 | text.append(' Available RX channels:') 90 | text.append(' {}'.format(', '.join(str(x) for x in device.list_channels()))) 91 | text.append(' Available antennas:') 92 | text.append(' {}'.format(', '.join(device.list_antennas()))) 93 | text.append(' Available tunable elements:') 94 | text.append(' {}'.format(', '.join(device.list_frequencies()))) 95 | text.append(' Available amplification elements:') 96 | text.append(' {}'.format(', '.join(device.list_gains()))) 97 | text.append(' Available device settings:') 98 | for key, s in device.list_settings().items(): 99 | text.append(wrap('{} ... {} - {} (default: {})'.format(key, s['name'], s['description'], s['value']))) 100 | text.append(' Available stream arguments:') 101 | for key, s in device.list_stream_args().items(): 102 | text.append(wrap('{} ... {} - {} (default: {})'.format(key, s['name'], s['description'], s['value']))) 103 | text.append(' Allowed gain range [dB]:') 104 | text.append(' {:.2f} - {:.2f}'.format(*device.get_gain_range())) 105 | text.append(' Allowed frequency range [MHz]:') 106 | text.append(' {:.2f} - {:.2f}'.format(*[x / 1e6 for x in device.get_frequency_range()])) 107 | text.append(' Allowed sample rates [MHz]:') 108 | rates = [] 109 | for r in device.list_sample_rates(): 110 | if r[0] == r[1]: 111 | rates.append('{:.2f}'.format(r[0] / 1e6)) 112 | else: 113 | rates.append('{:.2f} - {:.2f}'.format(r[0] / 1e6, r[1] / 1e6)) 114 | text.append(wrap(', '.join(rates))) 115 | text.append(' Allowed bandwidths [MHz]:') 116 | bandwidths = [] 117 | for b in device.list_bandwidths(): 118 | if b[0] == b[1]: 119 | bandwidths.append('{:.2f}'.format(b[0] / 1e6)) 120 | else: 121 | bandwidths.append('{:.2f} - {:.2f}'.format(b[0] / 1e6, b[1] / 1e6)) 122 | if bandwidths: 123 | text.append(wrap(', '.join(bandwidths))) 124 | else: 125 | text.append(' N/A') 126 | except RuntimeError: 127 | device = None 128 | text.append('No devices found!') 129 | return (device, '\n'.join(text)) 130 | 131 | 132 | def setup_argument_parser(): 133 | """Setup command line parser""" 134 | # Fix help formatter width 135 | if 'COLUMNS' not in os.environ: 136 | os.environ['COLUMNS'] = str(shutil.get_terminal_size().columns) 137 | 138 | parser = argparse.ArgumentParser( 139 | prog='soapy_power', 140 | formatter_class=argparse.RawDescriptionHelpFormatter, 141 | description='Obtain a power spectrum from SoapySDR devices', 142 | add_help=False 143 | ) 144 | 145 | # Fix recognition of optional argements of type float_with_multiplier 146 | parser._negative_number_matcher = re_float_with_multiplier_negative 147 | 148 | main_title = parser.add_argument_group('Main options') 149 | main_title.add_argument('-h', '--help', action='help', 150 | help='show this help message and exit') 151 | main_title.add_argument('-f', '--freq', metavar='Hz|Hz:Hz', type=freq_or_freq_range, default='1420405752', 152 | help='center frequency or frequency range to scan, number ' 153 | 'can be followed by a k, M or G multiplier (default: %(default)s)') 154 | 155 | output_group = main_title.add_mutually_exclusive_group() 156 | output_group.add_argument('-O', '--output', metavar='FILE', type=argparse.FileType('w'), default=sys.stdout, 157 | help='output to file (incompatible with --output-fd, default is stdout)') 158 | output_group.add_argument('--output-fd', metavar='NUM', type=int, default=None, 159 | help='output to existing file descriptor (incompatible with -O)') 160 | 161 | main_title.add_argument('-F', '--format', choices=sorted(writer.formats.keys()), default='rtl_power', 162 | help='output format (default: %(default)s)') 163 | main_title.add_argument('-q', '--quiet', action='store_true', 164 | help='limit verbosity') 165 | main_title.add_argument('--debug', action='store_true', 166 | help='detailed debugging messages') 167 | main_title.add_argument('--detect', action='store_true', 168 | help='detect connected SoapySDR devices and exit') 169 | main_title.add_argument('--info', action='store_true', 170 | help='show info about selected SoapySDR device and exit') 171 | main_title.add_argument('--version', action='version', 172 | version='%(prog)s {}'.format(__version__)) 173 | 174 | bins_title = parser.add_argument_group('FFT bins') 175 | bins_group = bins_title.add_mutually_exclusive_group() 176 | bins_group.add_argument('-b', '--bins', type=int, default=512, 177 | help='number of FFT bins (incompatible with -B, default: %(default)s)') 178 | bins_group.add_argument('-B', '--bin-size', metavar='Hz', type=float_with_multiplier, 179 | help='bin size in Hz (incompatible with -b)') 180 | 181 | spectra_title = parser.add_argument_group('Averaging') 182 | spectra_group = spectra_title.add_mutually_exclusive_group() 183 | spectra_group.add_argument('-n', '--repeats', type=int, default=1600, 184 | help='number of spectra to average (incompatible with -t and -T, default: %(default)s)') 185 | spectra_group.add_argument('-t', '--time', metavar='SECONDS', type=float, 186 | help='integration time (incompatible with -T and -n)') 187 | spectra_group.add_argument('-T', '--total-time', metavar='SECONDS', type=float, 188 | help='total integration time of all hops (incompatible with -t and -n)') 189 | 190 | runs_title = parser.add_argument_group('Measurements') 191 | runs_group = runs_title.add_mutually_exclusive_group() 192 | runs_group.add_argument('-c', '--continue', dest='endless', action='store_true', 193 | help='repeat the measurement endlessly (incompatible with -u and -e)') 194 | runs_group.add_argument('-u', '--runs', type=int, default=1, 195 | help='number of measurements (incompatible with -c and -e, default: %(default)s)') 196 | runs_group.add_argument('-e', '--elapsed', metavar='SECONDS', type=float, 197 | help='scan session duration (time limit in seconds, incompatible with -c and -u)') 198 | 199 | device_title = parser.add_argument_group('Device settings') 200 | device_title.add_argument('-d', '--device', default='', 201 | help='SoapySDR device to use') 202 | device_title.add_argument('-C', '--channel', type=int, default=0, 203 | help='SoapySDR RX channel (default: %(default)s)') 204 | device_title.add_argument('-A', '--antenna', default='', 205 | help='SoapySDR selected antenna') 206 | device_title.add_argument('-r', '--rate', metavar='Hz', type=float_with_multiplier, default=2e6, 207 | help='sample rate (default: %(default)s)') 208 | device_title.add_argument('-w', '--bandwidth', metavar='Hz', type=float_with_multiplier, default=0, 209 | help='filter bandwidth (default: %(default)s)') 210 | device_title.add_argument('-p', '--ppm', type=int, default=0, 211 | help='frequency correction in ppm') 212 | 213 | gain_group = device_title.add_mutually_exclusive_group() 214 | gain_group.add_argument('-g', '--gain', metavar='dB', type=float, default=37.2, 215 | help='total gain (incompatible with -G and -a, default: %(default)s)') 216 | gain_group.add_argument('-G', '--specific-gains', metavar='STRING', type=specific_gains, default='', 217 | help='specific gains of individual amplification elements ' 218 | '(incompatible with -g and -a, example: LNA=28,VGA=12,AMP=0') 219 | gain_group.add_argument('-a', '--agc', action='store_true', 220 | help='enable Automatic Gain Control (incompatible with -g and -G)') 221 | 222 | device_title.add_argument('--lnb-lo', metavar='Hz', type=float_with_multiplier, default=0, 223 | help='LNB LO frequency, negative for upconverters (default: %(default)s)') 224 | device_title.add_argument('--device-settings', metavar='STRING', type=device_settings, default='', 225 | help='SoapySDR device settings (example: biastee=true)') 226 | device_title.add_argument('--force-rate', action='store_true', 227 | help='ignore list of sample rates provided by device and allow any value') 228 | device_title.add_argument('--force-bandwidth', action='store_true', 229 | help='ignore list of filter bandwidths provided by device and allow any value') 230 | device_title.add_argument('--tune-delay', metavar='SECONDS', type=float, default=0, 231 | help='time to delay measurement after changing frequency (to avoid artifacts)') 232 | device_title.add_argument('--reset-stream', action='store_true', 233 | help='reset streaming after changing frequency (to avoid artifacts)') 234 | 235 | crop_title = parser.add_argument_group('Crop') 236 | crop_group = crop_title.add_mutually_exclusive_group() 237 | crop_group.add_argument('-o', '--overlap', metavar='PERCENT', type=float, default=0, 238 | help='percent of overlap when frequency hopping (incompatible with -k)') 239 | crop_group.add_argument('-k', '--crop', metavar='PERCENT', type=float, default=0, 240 | help='percent of crop when frequency hopping (incompatible with -o)') 241 | 242 | perf_title = parser.add_argument_group('Performance options') 243 | perf_title.add_argument('-s', '--buffer-size', type=int, default=0, 244 | help='base buffer size (number of samples, 0 = auto, default: %(default)s)') 245 | perf_title.add_argument('-S', '--max-buffer-size', type=int, default=0, 246 | help='maximum buffer size (number of samples, -1 = unlimited, 0 = auto, default: %(default)s)') 247 | 248 | fft_rules_group = perf_title.add_mutually_exclusive_group() 249 | fft_rules_group.add_argument('--even', action='store_true', 250 | help='use only even numbers of FFT bins') 251 | fft_rules_group.add_argument('--pow2', action='store_true', 252 | help='use only powers of 2 as number of FFT bins') 253 | 254 | perf_title.add_argument('--max-threads', metavar='NUM', type=int, default=0, 255 | help='maximum number of PSD threads (0 = auto, default: %(default)s)') 256 | perf_title.add_argument('--max-queue-size', metavar='NUM', type=int, default=0, 257 | help='maximum size of PSD work queue (-1 = unlimited, 0 = auto, default: %(default)s)') 258 | perf_title.add_argument('--no-pyfftw', action='store_true', 259 | help='don\'t use pyfftw library even if it is available (use scipy.fftpack or numpy.fft)') 260 | 261 | other_title = parser.add_argument_group('Other options') 262 | other_title.add_argument('-l', '--linear', action='store_true', 263 | help='linear power values instead of logarithmic') 264 | other_title.add_argument('-R', '--remove-dc', action='store_true', 265 | help='interpolate central point to cancel DC bias (useful only with boxcar window)') 266 | other_title.add_argument('-D', '--detrend', choices=['none', 'constant'], default='none', 267 | help='remove mean value from data to cancel DC bias (default: %(default)s)') 268 | other_title.add_argument('--fft-window', choices=['boxcar', 'hann', 'hamming', 'blackman', 'bartlett', 'kaiser', 'tukey'], 269 | default='hann', help='Welch\'s method window function (default: %(default)s)') 270 | other_title.add_argument('--fft-window-param', metavar='FLOAT', type=float, default=None, 271 | help='shape parameter of window function (required for kaiser and tukey windows)') 272 | other_title.add_argument('--fft-overlap', metavar='PERCENT', type=float, default=50, 273 | help='Welch\'s method overlap between segments (default: %(default)s)') 274 | 275 | return parser 276 | 277 | 278 | def main(): 279 | # Parse command line arguments 280 | parser = setup_argument_parser() 281 | args = parser.parse_args() 282 | 283 | # Setup logging 284 | if args.quiet: 285 | log_level = logging.WARNING 286 | elif args.debug: 287 | log_level = logging.DEBUG 288 | else: 289 | log_level = logging.INFO 290 | 291 | logging.basicConfig( 292 | level=log_level, 293 | format='%(levelname)s: %(message)s' 294 | ) 295 | 296 | # Import soapypower.power module only after setting log level 297 | from soapypower import power 298 | 299 | # Detect SoapySDR devices 300 | if args.detect: 301 | devices, devices_text = detect_devices(args.device) 302 | print(devices_text) 303 | sys.exit(0 if devices else 1) 304 | 305 | # Show info about selected SoapySDR device 306 | if args.info: 307 | device, device_text = device_info(args.device) 308 | print(device_text) 309 | sys.exit(0 if device else 1) 310 | 311 | # Prepare arguments for SoapyPower 312 | if args.no_pyfftw: 313 | power.psd.simplespectral.use_pyfftw = False 314 | 315 | # Create SoapyPower instance 316 | try: 317 | sdr = power.SoapyPower( 318 | soapy_args=args.device, sample_rate=args.rate, bandwidth=args.bandwidth, corr=args.ppm, 319 | gain=args.specific_gains if args.specific_gains else args.gain, auto_gain=args.agc, 320 | channel=args.channel, antenna=args.antenna, settings=args.device_settings, 321 | force_sample_rate=args.force_rate, force_bandwidth=args.force_bandwidth, 322 | output=args.output_fd if args.output_fd is not None else args.output, 323 | output_format=args.format 324 | ) 325 | logger.info('Using device: {}'.format(sdr.device.hardware)) 326 | except RuntimeError: 327 | parser.error('No devices found!') 328 | 329 | # Prepare arguments for SoapyPower.sweep() 330 | if len(args.freq) < 2: 331 | args.freq = [args.freq[0], args.freq[0]] 332 | 333 | if args.bin_size: 334 | args.bins = sdr.bin_size_to_bins(args.bin_size) 335 | 336 | args.bins = sdr.nearest_bins(args.bins, even=args.even, pow2=args.pow2) 337 | 338 | if args.endless: 339 | args.runs = 0 340 | 341 | if args.elapsed: 342 | args.runs = 0 343 | 344 | if args.crop: 345 | args.overlap = args.crop 346 | args.crop = True 347 | else: 348 | args.crop = False 349 | 350 | if args.overlap: 351 | args.overlap /= 100 352 | args.overlap = sdr.nearest_overlap(args.overlap, args.bins) 353 | 354 | if args.total_time: 355 | args.time = args.total_time / len(sdr.freq_plan(args.freq[0], args.freq[1], args.bins, args.overlap, quiet=True)) 356 | 357 | if args.time: 358 | args.repeats = sdr.time_to_repeats(args.bins, args.time) 359 | 360 | if args.fft_window in ('kaiser', 'tukey'): 361 | if args.fft_window_param is None: 362 | parser.error('argument --fft-window: --fft-window-param is required when using kaiser or tukey windows') 363 | args.fft_window = (args.fft_window, args.fft_window_param) 364 | 365 | # Start frequency sweep 366 | sdr.sweep( 367 | args.freq[0], args.freq[1], args.bins, args.repeats, 368 | runs=args.runs, time_limit=args.elapsed, overlap=args.overlap, crop=args.crop, 369 | fft_window=args.fft_window, fft_overlap=args.fft_overlap / 100, log_scale=not args.linear, 370 | remove_dc=args.remove_dc, detrend=args.detrend if args.detrend != 'none' else None, 371 | lnb_lo=args.lnb_lo, tune_delay=args.tune_delay, reset_stream=args.reset_stream, 372 | base_buffer_size=args.buffer_size, max_buffer_size=args.max_buffer_size, 373 | max_threads=args.max_threads, max_queue_size=args.max_queue_size 374 | ) 375 | 376 | 377 | if __name__ == '__main__': 378 | main() 379 | -------------------------------------------------------------------------------- /soapypower/power.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys, time, datetime, math, logging, signal 4 | 5 | import numpy 6 | import simplesoapy 7 | from simplespectral import zeros 8 | 9 | from soapypower import psd, writer 10 | 11 | logger = logging.getLogger(__name__) 12 | _shutdown = False 13 | 14 | 15 | def _shutdown_handler(sig, frame): 16 | """Set global _shutdown flag when receiving SIGTERM or SIGINT signals""" 17 | global _shutdown 18 | _shutdown = True 19 | 20 | 21 | # Register signals with _shutdown_handler 22 | signal.signal(signal.SIGTERM, _shutdown_handler) 23 | signal.signal(signal.SIGINT, _shutdown_handler) 24 | 25 | if sys.platform == 'win32': 26 | signal.signal(signal.SIGBREAK, _shutdown_handler) 27 | 28 | 29 | class SoapyPower: 30 | """SoapySDR spectrum analyzer""" 31 | def __init__(self, soapy_args='', sample_rate=2.00e6, bandwidth=0, corr=0, gain=20.7, 32 | auto_gain=False, channel=0, antenna='', settings=None, 33 | force_sample_rate=False, force_bandwidth=False, 34 | output=sys.stdout, output_format='rtl_power'): 35 | self.device = simplesoapy.SoapyDevice( 36 | soapy_args=soapy_args, sample_rate=sample_rate, bandwidth=bandwidth, corr=corr, 37 | gain=gain, auto_gain=auto_gain, channel=channel, antenna=antenna, settings=settings, 38 | force_sample_rate=force_sample_rate, force_bandwidth=force_bandwidth 39 | ) 40 | 41 | self._output = output 42 | self._output_format = output_format 43 | 44 | self._buffer = None 45 | self._buffer_repeats = None 46 | self._base_buffer_size = None 47 | self._max_buffer_size = None 48 | self._bins = None 49 | self._repeats = None 50 | self._tune_delay = None 51 | self._reset_stream = None 52 | self._psd = None 53 | self._writer = None 54 | 55 | def nearest_freq(self, freq, bin_size): 56 | """Return nearest frequency based on bin size""" 57 | return round(freq / bin_size) * bin_size 58 | 59 | def nearest_bins(self, bins, even=False, pow2=False): 60 | """Return nearest number of FFT bins (even or power of two)""" 61 | if pow2: 62 | bins_log2 = math.log(bins, 2) 63 | if bins_log2 % 1 != 0: 64 | bins = 2**math.ceil(bins_log2) 65 | logger.warning('number of FFT bins should be power of two, changing to {}'.format(bins)) 66 | elif even: 67 | if bins % 2 != 0: 68 | bins = math.ceil(bins / 2) * 2 69 | logger.warning('number of FFT bins should be even, changing to {}'.format(bins)) 70 | 71 | return bins 72 | 73 | def nearest_overlap(self, overlap, bins): 74 | """Return nearest overlap/crop factor based on number of bins""" 75 | bins_overlap = overlap * bins 76 | if bins_overlap % 2 != 0: 77 | bins_overlap = math.ceil(bins_overlap / 2) * 2 78 | overlap = bins_overlap / bins 79 | logger.warning('number of overlapping FFT bins should be even, ' 80 | 'changing overlap/crop factor to {:.5f}'.format(overlap)) 81 | return overlap 82 | 83 | def bin_size_to_bins(self, bin_size): 84 | """Convert bin size [Hz] to number of FFT bins""" 85 | return math.ceil(self.device.sample_rate / bin_size) 86 | 87 | def bins_to_bin_size(self, bins): 88 | """Convert number of FFT bins to bin size [Hz]""" 89 | return self.device.sample_rate / bins 90 | 91 | def time_to_repeats(self, bins, integration_time): 92 | """Convert integration time to number of repeats""" 93 | return math.ceil((self.device.sample_rate * integration_time) / bins) 94 | 95 | def repeats_to_time(self, bins, repeats): 96 | """Convert number of repeats to integration time""" 97 | return (repeats * bins) / self.device.sample_rate 98 | 99 | def freq_plan(self, min_freq, max_freq, bins, overlap=0, quiet=False): 100 | """Returns list of frequencies for frequency hopping""" 101 | bin_size = self.bins_to_bin_size(bins) 102 | bins_crop = round((1 - overlap) * bins) 103 | sample_rate_crop = (1 - overlap) * self.device.sample_rate 104 | 105 | freq_range = max_freq - min_freq 106 | hopping = True if freq_range >= sample_rate_crop else False 107 | hop_size = self.nearest_freq(sample_rate_crop, bin_size) 108 | hops = math.ceil(freq_range / hop_size) if hopping else 1 109 | min_center_freq = min_freq + (hop_size / 2) if hopping else min_freq + (freq_range / 2) 110 | max_center_freq = min_center_freq + ((hops - 1) * hop_size) 111 | 112 | freq_list = [min_center_freq + (i * hop_size) for i in range(hops)] 113 | 114 | if not quiet: 115 | logger.info('overlap: {:.5f}'.format(overlap)) 116 | logger.info('bin_size: {:.2f} Hz'.format(bin_size)) 117 | logger.info('bins: {}'.format(bins)) 118 | logger.info('bins (after crop): {}'.format(bins_crop)) 119 | logger.info('sample_rate: {:.3f} MHz'.format(self.device.sample_rate / 1e6)) 120 | logger.info('sample_rate (after crop): {:.3f} MHz'.format(sample_rate_crop / 1e6)) 121 | logger.info('freq_range: {:.3f} MHz'.format(freq_range / 1e6)) 122 | logger.info('hopping: {}'.format('YES' if hopping else 'NO')) 123 | logger.info('hop_size: {:.3f} MHz'.format(hop_size / 1e6)) 124 | logger.info('hops: {}'.format(hops)) 125 | logger.info('min_center_freq: {:.3f} MHz'.format(min_center_freq / 1e6)) 126 | logger.info('max_center_freq: {:.3f} MHz'.format(max_center_freq / 1e6)) 127 | logger.info('min_freq (after crop): {:.3f} MHz'.format((min_center_freq - (hop_size / 2)) / 1e6)) 128 | logger.info('max_freq (after crop): {:.3f} MHz'.format((max_center_freq + (hop_size / 2)) / 1e6)) 129 | 130 | logger.debug('Frequency hops table:') 131 | logger.debug(' {:8s} {:8s} {:8s}'.format('Min:', 'Center:', 'Max:')) 132 | for f in freq_list: 133 | logger.debug(' {:8.3f} MHz {:8.3f} MHz {:8.3f} MHz'.format( 134 | (f - (self.device.sample_rate / 2)) / 1e6, 135 | f / 1e6, 136 | (f + (self.device.sample_rate / 2)) / 1e6, 137 | )) 138 | 139 | return freq_list 140 | 141 | def create_buffer(self, bins, repeats, base_buffer_size, max_buffer_size=0): 142 | """Create buffer for reading samples""" 143 | samples = bins * repeats 144 | buffer_repeats = 1 145 | buffer_size = math.ceil(samples / base_buffer_size) * base_buffer_size 146 | 147 | if not max_buffer_size: 148 | # Max buffer size about 100 MB 149 | max_buffer_size = (100 * 1024**2) / 8 150 | 151 | if max_buffer_size > 0: 152 | max_buffer_size = math.ceil(max_buffer_size / base_buffer_size) * base_buffer_size 153 | if buffer_size > max_buffer_size: 154 | logger.warning('Required buffer size ({}) will be shrinked to max_buffer_size ({})!'.format( 155 | buffer_size, max_buffer_size 156 | )) 157 | buffer_repeats = math.ceil(buffer_size / max_buffer_size) 158 | buffer_size = max_buffer_size 159 | 160 | logger.info('repeats: {}'.format(repeats)) 161 | logger.info('samples: {} (time: {:.5f} s)'.format(samples, samples / self.device.sample_rate)) 162 | if max_buffer_size > 0: 163 | logger.info('max_buffer_size (samples): {} (repeats: {:.2f}, time: {:.5f} s)'.format( 164 | max_buffer_size, max_buffer_size / bins, max_buffer_size / self.device.sample_rate 165 | )) 166 | else: 167 | logger.info('max_buffer_size (samples): UNLIMITED') 168 | logger.info('buffer_size (samples): {} (repeats: {:.2f}, time: {:.5f} s)'.format( 169 | buffer_size, buffer_size / bins, buffer_size / self.device.sample_rate 170 | )) 171 | logger.info('buffer_repeats: {}'.format(buffer_repeats)) 172 | 173 | return (buffer_repeats, zeros(buffer_size, numpy.complex64)) 174 | 175 | def setup(self, bins, repeats, base_buffer_size=0, max_buffer_size=0, fft_window='hann', 176 | fft_overlap=0.5, crop_factor=0, log_scale=True, remove_dc=False, detrend=None, 177 | lnb_lo=0, tune_delay=0, reset_stream=False, max_threads=0, max_queue_size=0): 178 | """Prepare samples buffer and start streaming samples from device""" 179 | if self.device.is_streaming: 180 | self.device.stop_stream() 181 | 182 | base_buffer = self.device.start_stream(buffer_size=base_buffer_size) 183 | self._bins = bins 184 | self._repeats = repeats 185 | self._base_buffer_size = len(base_buffer) 186 | self._max_buffer_size = max_buffer_size 187 | self._buffer_repeats, self._buffer = self.create_buffer( 188 | bins, repeats, self._base_buffer_size, self._max_buffer_size 189 | ) 190 | self._tune_delay = tune_delay 191 | self._reset_stream = reset_stream 192 | self._psd = psd.PSD(bins, self.device.sample_rate, fft_window=fft_window, fft_overlap=fft_overlap, 193 | crop_factor=crop_factor, log_scale=log_scale, remove_dc=remove_dc, detrend=detrend, 194 | lnb_lo=lnb_lo, max_threads=max_threads, max_queue_size=max_queue_size) 195 | self._writer = writer.formats[self._output_format](self._output) 196 | 197 | def stop(self): 198 | """Stop streaming samples from device and delete samples buffer""" 199 | if not self.device.is_streaming: 200 | return 201 | 202 | self.device.stop_stream() 203 | self._writer.close() 204 | 205 | self._bins = None 206 | self._repeats = None 207 | self._base_buffer_size = None 208 | self._max_buffer_size = None 209 | self._buffer_repeats = None 210 | self._buffer = None 211 | self._tune_delay = None 212 | self._reset_stream = None 213 | self._psd = None 214 | self._writer = None 215 | 216 | def psd(self, freq): 217 | """Tune to specified center frequency and compute Power Spectral Density""" 218 | if not self.device.is_streaming: 219 | raise RuntimeError('Streaming is not initialized, you must run setup() first!') 220 | 221 | # Tune to new frequency in main thread 222 | logger.debug(' Frequency hop: {:.2f} Hz'.format(freq)) 223 | t_freq = time.time() 224 | if self.device.freq != freq: 225 | # Deactivate streaming before tuning 226 | if self._reset_stream: 227 | self.device.device.deactivateStream(self.device.stream) 228 | 229 | # Actually tune to new center frequency 230 | self.device.freq = freq 231 | 232 | # Reactivate straming after tuning 233 | if self._reset_stream: 234 | self.device.device.activateStream(self.device.stream) 235 | 236 | # Delay reading samples after tuning 237 | if self._tune_delay: 238 | t_delay = time.time() 239 | while True: 240 | self.device.read_stream() 241 | t_delay_end = time.time() 242 | if t_delay_end - t_delay >= self._tune_delay: 243 | break 244 | logger.debug(' Tune delay: {:.3f} s'.format(t_delay_end - t_delay)) 245 | else: 246 | logger.debug(' Same frequency as before, tuning skipped') 247 | psd_state = self._psd.set_center_freq(freq) 248 | t_freq_end = time.time() 249 | logger.debug(' Tune time: {:.3f} s'.format(t_freq_end - t_freq)) 250 | 251 | for repeat in range(self._buffer_repeats): 252 | logger.debug(' Repeat: {}'.format(repeat + 1)) 253 | # Read samples from SDR in main thread 254 | t_acq = time.time() 255 | acq_time_start = datetime.datetime.utcnow() 256 | self.device.read_stream_into_buffer(self._buffer) 257 | acq_time_stop = datetime.datetime.utcnow() 258 | t_acq_end = time.time() 259 | logger.debug(' Acquisition time: {:.3f} s'.format(t_acq_end - t_acq)) 260 | 261 | # Start FFT computation in another thread 262 | self._psd.update_async(psd_state, numpy.copy(self._buffer)) 263 | 264 | t_final = time.time() 265 | 266 | if _shutdown: 267 | break 268 | 269 | psd_future = self._psd.result_async(psd_state) 270 | logger.debug(' Total hop time: {:.3f} s'.format(t_final - t_freq)) 271 | 272 | return (psd_future, acq_time_start, acq_time_stop) 273 | 274 | def sweep(self, min_freq, max_freq, bins, repeats, runs=0, time_limit=0, overlap=0, 275 | fft_window='hann', fft_overlap=0.5, crop=False, log_scale=True, remove_dc=False, detrend=None, lnb_lo=0, 276 | tune_delay=0, reset_stream=False, base_buffer_size=0, max_buffer_size=0, max_threads=0, max_queue_size=0): 277 | """Sweep spectrum using frequency hopping""" 278 | self.setup( 279 | bins, repeats, base_buffer_size, max_buffer_size, 280 | fft_window=fft_window, fft_overlap=fft_overlap, crop_factor=overlap if crop else 0, 281 | log_scale=log_scale, remove_dc=remove_dc, detrend=detrend, lnb_lo=lnb_lo, tune_delay=tune_delay, 282 | reset_stream=reset_stream, max_threads=max_threads, max_queue_size=max_queue_size 283 | ) 284 | 285 | try: 286 | freq_list = self.freq_plan(min_freq - lnb_lo, max_freq - lnb_lo, bins, overlap) 287 | t_start = time.time() 288 | run = 0 289 | while not _shutdown and (runs == 0 or run < runs): 290 | run += 1 291 | t_run_start = time.time() 292 | logger.debug('Run: {}'.format(run)) 293 | 294 | for freq in freq_list: 295 | # Tune to new frequency, acquire samples and compute Power Spectral Density 296 | psd_future, acq_time_start, acq_time_stop = self.psd(freq) 297 | 298 | # Write PSD to stdout (in another thread) 299 | self._writer.write_async(psd_future, acq_time_start, acq_time_stop, 300 | len(self._buffer) * self._buffer_repeats) 301 | 302 | if _shutdown: 303 | break 304 | 305 | # Write end of measurement marker (in another thread) 306 | write_next_future = self._writer.write_next_async() 307 | t_run = time.time() 308 | logger.debug(' Total run time: {:.3f} s'.format(t_run - t_run_start)) 309 | 310 | # End measurement if time limit is exceeded 311 | if time_limit and (time.time() - t_start) >= time_limit: 312 | logger.info('Time limit of {} s exceeded, completed {} runs'.format(time_limit, run)) 313 | break 314 | 315 | # Wait for last write to be finished 316 | write_next_future.result() 317 | 318 | # Debug thread pool queues 319 | logging.debug('Number of USB buffer overflow errors: {}'.format(self.device.buffer_overflow_count)) 320 | logging.debug('PSD worker threads: {}'.format(self._psd._executor._max_workers)) 321 | logging.debug('Max. PSD queue size: {} / {}'.format(self._psd._executor.max_queue_size_reached, 322 | self._psd._executor.max_queue_size)) 323 | logging.debug('Writer worker threads: {}'.format(self._writer._executor._max_workers)) 324 | logging.debug('Max. Writer queue size: {} / {}'.format(self._writer._executor.max_queue_size_reached, 325 | self._writer._executor.max_queue_size)) 326 | finally: 327 | # Shutdown SDR 328 | self.stop() 329 | t_stop = time.time() 330 | logger.info('Total time: {:.3f} s'.format(t_stop - t_start)) 331 | -------------------------------------------------------------------------------- /soapypower/psd.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import math, logging, threading, concurrent.futures 4 | 5 | import numpy 6 | import simplespectral 7 | 8 | from soapypower import threadpool 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class PSD: 14 | """Compute averaged power spectral density using Welch's method""" 15 | def __init__(self, bins, sample_rate, fft_window='hann', fft_overlap=0.5, 16 | crop_factor=0, log_scale=True, remove_dc=False, detrend=None, 17 | lnb_lo=0, max_threads=0, max_queue_size=0): 18 | self._bins = bins 19 | self._sample_rate = sample_rate 20 | self._fft_window = fft_window 21 | self._fft_overlap = fft_overlap 22 | self._fft_overlap_bins = math.floor(self._bins * self._fft_overlap) 23 | self._crop_factor = crop_factor 24 | self._log_scale = log_scale 25 | self._remove_dc = remove_dc 26 | self._detrend = detrend 27 | self._lnb_lo = lnb_lo 28 | self._executor = threadpool.ThreadPoolExecutor( 29 | max_workers=max_threads, 30 | max_queue_size=max_queue_size, 31 | thread_name_prefix='PSD_thread' 32 | ) 33 | self._base_freq_array = numpy.fft.fftfreq(self._bins, 1 / self._sample_rate) 34 | 35 | def set_center_freq(self, center_freq): 36 | """Set center frequency and clear averaged PSD data""" 37 | psd_state = { 38 | 'repeats': 0, 39 | 'freq_array': self._base_freq_array + self._lnb_lo + center_freq, 40 | 'pwr_array': None, 41 | 'update_lock': threading.Lock(), 42 | 'futures': [], 43 | } 44 | return psd_state 45 | 46 | def result(self, psd_state): 47 | """Return freqs and averaged PSD for given center frequency""" 48 | freq_array = numpy.fft.fftshift(psd_state['freq_array']) 49 | pwr_array = numpy.fft.fftshift(psd_state['pwr_array']) 50 | 51 | if self._crop_factor: 52 | crop_bins_half = round((self._crop_factor * self._bins) / 2) 53 | freq_array = freq_array[crop_bins_half:-crop_bins_half] 54 | pwr_array = pwr_array[crop_bins_half:-crop_bins_half] 55 | 56 | if psd_state['repeats'] > 1: 57 | pwr_array = pwr_array / psd_state['repeats'] 58 | 59 | if self._log_scale: 60 | pwr_array = 10 * numpy.log10(pwr_array) 61 | 62 | return (freq_array, pwr_array) 63 | 64 | def wait_for_result(self, psd_state): 65 | """Wait for all PSD threads to finish and return result""" 66 | if len(psd_state['futures']) > 1: 67 | concurrent.futures.wait(psd_state['futures']) 68 | elif psd_state['futures']: 69 | psd_state['futures'][0].result() 70 | return self.result(psd_state) 71 | 72 | def result_async(self, psd_state): 73 | """Return freqs and averaged PSD for given center frequency (asynchronously in another thread)""" 74 | return self._executor.submit(self.wait_for_result, psd_state) 75 | 76 | def _release_future_memory(self, future): 77 | """Remove result from future to release memory""" 78 | future._result = None 79 | 80 | def update(self, psd_state, samples_array): 81 | """Compute PSD from samples and update average for given center frequency""" 82 | freq_array, pwr_array = simplespectral.welch(samples_array, self._sample_rate, nperseg=self._bins, 83 | window=self._fft_window, noverlap=self._fft_overlap_bins, 84 | detrend=self._detrend) 85 | 86 | if self._remove_dc: 87 | pwr_array[0] = (pwr_array[1] + pwr_array[-1]) / 2 88 | 89 | with psd_state['update_lock']: 90 | psd_state['repeats'] += 1 91 | if psd_state['pwr_array'] is None: 92 | psd_state['pwr_array'] = pwr_array 93 | else: 94 | psd_state['pwr_array'] += pwr_array 95 | 96 | def update_async(self, psd_state, samples_array): 97 | """Compute PSD from samples and update average for given center frequency (asynchronously in another thread)""" 98 | future = self._executor.submit(self.update, psd_state, samples_array) 99 | future.add_done_callback(self._release_future_memory) 100 | psd_state['futures'].append(future) 101 | return future 102 | -------------------------------------------------------------------------------- /soapypower/threadpool.py: -------------------------------------------------------------------------------- 1 | import os, queue, concurrent.futures 2 | 3 | 4 | class ThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor): 5 | """ThreadPoolExecutor which allows setting max. work queue size""" 6 | def __init__(self, max_workers=0, thread_name_prefix='', max_queue_size=0): 7 | #super().__init__(max_workers or os.cpu_count() or 1, thread_name_prefix) 8 | super().__init__(max_workers or os.cpu_count() or 1) 9 | self.max_queue_size = max_queue_size or self._max_workers * 10 10 | if self.max_queue_size > 0: 11 | self._work_queue = queue.Queue(self.max_queue_size) 12 | self.max_queue_size_reached = 0 13 | 14 | def submit(self, fn, *args, **kwargs): 15 | """Submits a callable to be executed with the given arguments. 16 | 17 | Count maximum reached work queue size in ThreadPoolExecutor.max_queue_size_reached. 18 | """ 19 | future = super().submit(fn, *args, **kwargs) 20 | work_queue_size = self._work_queue.qsize() 21 | if work_queue_size > self.max_queue_size_reached: 22 | self.max_queue_size_reached = work_queue_size 23 | return future 24 | -------------------------------------------------------------------------------- /soapypower/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '1.6.1' 2 | -------------------------------------------------------------------------------- /soapypower/writer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys, logging, struct, collections, io 4 | 5 | import numpy 6 | 7 | from soapypower import threadpool 8 | 9 | if sys.platform == 'win32': 10 | import msvcrt 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class BaseWriter: 16 | """Power Spectral Density writer base class""" 17 | def __init__(self, output=sys.stdout): 18 | self._close_output = False 19 | 20 | # If output is integer, assume it is file descriptor and open it 21 | if isinstance(output, int): 22 | self._close_output = True 23 | if sys.platform == 'win32': 24 | output = msvcrt.open_osfhandle(output, 0) 25 | output = open(output, 'wb') 26 | 27 | # Get underlying buffered file object 28 | try: 29 | self.output = output.buffer 30 | except AttributeError: 31 | self.output = output 32 | 33 | # Use only one writer thread to preserve sequence of written frequencies 34 | self._executor = threadpool.ThreadPoolExecutor( 35 | max_workers=1, 36 | max_queue_size=100, 37 | thread_name_prefix='Writer_thread' 38 | ) 39 | 40 | def write(self, psd_data_or_future, time_start, time_stop, samples): 41 | """Write PSD of one frequency hop""" 42 | raise NotImplementedError 43 | 44 | def write_async(self, psd_data_or_future, time_start, time_stop, samples): 45 | """Write PSD of one frequncy hop (asynchronously in another thread)""" 46 | return self._executor.submit(self.write, psd_data_or_future, time_start, time_stop, samples) 47 | 48 | def write_next(self): 49 | """Write marker for next run of measurement""" 50 | raise NotImplementedError 51 | 52 | def write_next_async(self): 53 | """Write marker for next run of measurement (asynchronously in another thread)""" 54 | return self._executor.submit(self.write_next) 55 | 56 | def close(self): 57 | """Close output (only if it has been opened by writer)""" 58 | if self._close_output: 59 | self.output.close() 60 | 61 | 62 | class SoapyPowerBinFormat: 63 | """Power Spectral Density binary file format""" 64 | header_struct = struct.Struct('