6. USRP на Python

Сімейство USRP-радіостанцій від Ettus Research

У цій главі ми навчимося використовувати UHD Python API для керування та приймання/передавання сигналів за допомогою USRP - серії SDR від компанії Ettus Research (тепер частина NI). Ми обговоримо передавання та приймання на USRP у Python, а також зануримося в специфічні для USRP теми, включаючи аргументи потоку, субпристрої, канали, синхронізацію за 10 МГц і PPS.

Встановлення програмного забезпечення/драйверів

Хоча код на Python, наведений у цьому підручнику, має працювати під Windows, Mac і Linux, ми надамо лише інструкції зі встановлення драйверів/API для Ubuntu 22 (хоча наведені нижче інструкції мають працювати на більшості дистрибутивів на основі Debian). Ми почнемо зі створення віртуальної машини Ubuntu 22 VirtualBox; можете пропустити частину про віртуальну машину, якщо у вас вже є готова до роботи ОС. Крім того, якщо ви використовуєте Windows 11, Windows Subsystem for Linux (WSL) з Ubuntu 22 працює досить добре і підтримує графіку “з коробки”.

Налаштування віртуальної машини Ubuntu 22

(Необов’язково)

  1. Завантажте Ubuntu 22.04 Desktop .iso - https://ubuntu.com/download/desktop
  2. Встановіть і відкрийте VirtualBox.
  3. Створіть нову віртуальну машину. Для розміру пам’яті я рекомендую використовувати 50% оперативної пам’яті вашого комп’ютера.
  4. Створіть віртуальний жорсткий диск, виберіть VDI і динамічно розподіліть обсяг. 15 ГБ повинно бути достатньо. Якщо ви хочете перестрахуватися, можете виділити більше.
  5. Запустіть віртуальну машину. Вона запитає вас про інсталяційний носій. Виберіть файл .iso для робочого столу Ubuntu 22. Виберіть “встановити Ubuntu”, скористайтеся параметрами за замовчуванням, і спливаюче вікно попередить вас про зміни, які ви збираєтеся зробити. Натисніть “продовжити”. Виберіть ім’я/пароль і зачекайте, поки віртуальна машина закінчить ініціалізацію. Після завершення ВМ перезавантажиться, але вам слід вимкнути ВМ після перезавантаження.
  6. Перейдіть до налаштувань ВМ (іконка з шестернею).
  7. У розділі система > процесор > виберіть принаймні 3 процесори. Якщо у вас справжня відеокарта, то в розділі дисплей > відеопам’ять > виберіть значно більше значення.
  8. Запустіть віртуальну машину.
  9. Для USRP з інтерфейсом USB вам потрібно буде встановити гостьові доповнення до ВМ. У віртуальній машині перейдіть до Пристрої > Вставити компакт-диск з гостьовими доповненнями > натисніть запустити, коли з’явиться вікно. Дотримуйтесь інструкцій. Перезапустіть віртуальну машину, а потім спробуйте перенаправити USRP до віртуальної машини, за умови, що він з’явиться у списку Пристрої > USB. Спільний буфер обміну можна увімкнути за допомогою Пристрої > Спільний буфер обміну > Двонаправлений.

Встановлення UHD та Python API

Наведені нижче команди терміналу мають зібрати та встановити останню версію UHD, включно з Python API:

sudo apt update
sudo apt install git cmake libboost-all-dev libusb-1.0-0-dev build-essential
sudo pip install pybind11[global]
pip install numpy==1.26.4 docutils mako requests ruamel.yaml setuptools
cd ~
git clone https://github.com/EttusResearch/uhd.git
cd uhd
git checkout v4.8.0.0
cd host
mkdir build
cd build
cmake -DENABLE_TESTS=OFF -DENABLE_C_API=OFF -DENABLE_PYTHON_API=ON -DENABLE_MANUAL=OFF ..
make -j8
sudo make install
sudo ldconfig

Докладнішу інформацію можна знайти на офіційній сторінці Ettus Складання та встановлення UHD з коду. Зауважте, що існують також способи встановлення драйверів, які не потребують збирання з вихідного коду.

Тестування драйверів UHD і Python API

Відкрийте новий термінал і введіть наступні команди:

python3
import uhd
usrp = uhd.usrp.MultiUSRP()
samples = usrp.recv_num_samps(10000, 100e6, 1e6, [0], 50)
print(samples[0:10])

Якщо не виникло жодних помилок, ви готові до роботи!

Бенчмаркінг швидкості USRP на Python

(Необов’язково)

Якщо ви використовували стандартне встановлення з вихідного коду, наступна команда повинна протестувати швидкість приймання вашого USRP за допомогою Python API. Якщо використання 56e6 спричинило багато втрачених відліків або переповнень, спробуйте зменшити це число. Втрачені відліки не обов’язково щось зіпсують, але це хороший спосіб перевірити неефективність, яка може виникнути, наприклад, при використанні віртуальної машини або старого комп’ютера. Якщо ви використовуєте B 2X0, досить сучасний комп’ютер з портом USB 3.0, який працює належним чином, повинен впоратися з 56 МГц без втрачених відліків, особливо з таким високим значенням num_recv_frames.

python /usr/lib/uhd/examples/python/benchmark_rate.py --rx_rate 56e6 --args "num_recv_frames=1000"

Приймання

Приймати відліки з USRP надзвичайно просто за допомогою вбудованої зручної функції “recv_num_samps()”, нижче наведено код на Python, який налаштовує USRP на 100 МГц, використовуючи частоту дискретизації 1 МГц, і отримує 10 000 відліків з USRP, використовуючи коефіцієнт підсилення приймання 50 дБ:

import uhd
usrp = uhd.usrp.MultiUSRP()
samples = usrp.recv_num_samps(10000, 100e6, 1e6, [0], 50) # units: N, Hz, Hz, list of channel IDs, dB
print(samples[0:10])

Значення [0] вказує USRP використовувати його перший вхідний порт і приймати відліки лише з одного каналу (наприклад, щоб B210 приймав на двох каналах одночасно, ви могли б використати [0, 1]).

Ось порада, якщо ви намагаєтеся приймати на високій швидкості, але отримуєте переповнення (у консолі з’являються символи O). Замість usrp = uhd.usrp.MultiUSRP() використайте:

usrp = uhd.usrp.MultiUSRP("num_recv_frames=1000")

що робить буфер приймання набагато більшим (значення за замовчуванням - 32), допомагаючи зменшити кількість переповнень. Фактичний розмір буфера в байтах залежить від USRP і типу з’єднання, але просте встановлення num_recv_frames у значення, значно більше за 32, зазвичай допомагає.

Для серйозніших застосувань я рекомендую не використовувати зручну функцію recv_num_samps(), оскільки вона приховує частину цікавої поведінки, що відбувається під капотом, і при кожному виклику виконується певне налаштування, яке ми, можливо, хотіли б зробити лише один раз на початку, наприклад, якщо ми хочемо приймати відліки нескінченно. Наведений нижче код має ту саму функціональність, що й recv_num_samps(), фактично це майже точно те, що викликається при використанні зручної функції, але тепер ми маємо можливість змінювати поведінку:

import uhd
import numpy as np

usrp = uhd.usrp.MultiUSRP()

num_samps = 10000 # number of samples received
center_freq = 100e6 # Hz
sample_rate = 1e6 # Hz
gain = 50 # dB

usrp.set_rx_rate(sample_rate, 0)
usrp.set_rx_freq(uhd.libpyuhd.types.tune_request(center_freq), 0)
usrp.set_rx_gain(gain, 0)

# Set up the stream and receive buffer
st_args = uhd.usrp.StreamArgs("fc32", "sc16")
st_args.channels = [0]
metadata = uhd.types.RXMetadata()
streamer = usrp.get_rx_stream(st_args)
recv_buffer = np.zeros((1, 1000), dtype=np.complex64)

# Start Stream
stream_cmd = uhd.types.StreamCMD(uhd.types.StreamMode.start_cont)
stream_cmd.stream_now = True
streamer.issue_stream_cmd(stream_cmd)

# Receive Samples
samples = np.zeros(num_samps, dtype=np.complex64)
for i in range(num_samps//1000):
    streamer.recv(recv_buffer, metadata)
    samples[i*1000:(i+1)*1000] = recv_buffer[0]

# Stop Stream
stream_cmd = uhd.types.StreamCMD(uhd.types.StreamMode.stop_cont)
streamer.issue_stream_cmd(stream_cmd)

print(len(samples))
print(samples[0:10])

Оскільки num_samps дорівнює 10 000, а recv_buffer - 1000, цикл for виконається 10 разів, тобто буде 10 викликів streamer.recv. Зауважте, що ми жорстко задали recv_buffer рівним 1000, але ви можете дізнатися максимально допустиме значення за допомогою streamer.get_max_num_samps(), яке часто становить близько 3000 з чимось. Також зверніть увагу, що recv_buffer має бути двовимірним, оскільки той самий API використовується при прийманні на кількох каналах одночасно, але в нашому випадку ми приймали лише один канал, тому recv_buffer[0] дав нам одновимірний масив відліків, який нам був потрібен. Наразі вам не потрібно надто глибоко розуміти, як потік запускається/зупиняється, але знайте, що є й інші варіанти, окрім “безперервного” режиму, наприклад приймання певної кількості відліків з автоматичною зупинкою потоку. Хоча в цьому прикладі коду ми не обробляємо метадані, вони містять, серед іншого, будь-які помилки, що виникають, і за бажанням ви можете перевіряти їх, дивлячись на metadata.error_code на кожній ітерації циклу (помилки зазвичай також з’являються в самій консолі завдяки UHD, тому не думайте, що ви обов’язково маєте перевіряти їх у своєму коді на Python).

Коефіцієнт підсилення приймання

У наведеному нижче списку показано діапазон коефіцієнта підсилення різних USRP: усі вони починаються з 0 дБ і сягають вказаного нижче значення. Зауважте, що це не дБм, це фактично дБм у поєднанні з певним невідомим зміщенням, оскільки ці пристрої не є каліброваними.

  • B200/B210/B200-mini: 76 дБ
  • X310/N210 з WBX/SBX/UBX: 31,5 дБ
  • X310 з TwinRX: 93 дБ
  • E310/E312: 76 дБ
  • N320/N321: 60 дБ

Ви також можете скористатися командою uhd_usrp_probe у терміналі, і в розділі RX Frontend буде вказано діапазон коефіцієнта підсилення.

Задаючи коефіцієнт підсилення, ви можете скористатися звичайною функцією set_rx_gain(), яка приймає значення підсилення в дБ, але ви також можете використати set_normalized_rx_gain(), яка приймає значення від 0 до 1 і автоматично перетворює його у діапазон USRP, який ви використовуєте. Це зручно при створенні застосунку, що підтримує різні моделі USRP. Недолік нормалізованого підсилення полягає в тому, що ви більше не маєте одиниць у дБ, тож якщо ви захочете, наприклад, збільшити підсилення на 10 дБ, вам доведеться самостійно обчислити відповідну величину.

Автоматичне регулювання підсилення

Деякі USRP, зокрема серії B200 та E310, підтримують автоматичне регулювання підсилення (АРУ), яке автоматично підлаштовує коефіцієнт підсилення приймання відповідно до рівня прийнятого сигналу, намагаючись якнайкраще “заповнити” біти АЦП. АРУ можна увімкнути так:

usrp.set_rx_agc(True, 0) # 0 for channel 0, i.e. the first channel of the USRP

Якщо у вас USRP, який не реалізує АРУ, при виконанні наведеного вище рядка буде згенеровано виключення. Якщо АРУ увімкнено, встановлення коефіцієнта підсилення нічого не дасть.

Аргументи потоку

У повному прикладі вище ви побачите рядок st_args = uhd.usrp.StreamArgs("fc32", "sc16"). Перший аргумент - це формат даних процесора, тобто тип даних відліків після того, як вони опиняться на вашому хост-комп’ютері. UHD підтримує наступні типи даних процесора при використанні Python API:

Аргумент потоку Тип даних Numpy Опис
fc64 np.complex128 Комплексні дані подвійної точності
fc32 np.complex64 Комплексні дані одинарної точності

Ви можете побачити інші варіанти у документації до UHD C++ API, але вони ніколи не були реалізовані у Python API, принаймні на момент написання цього тексту.

Другий аргумент - це “дротовий” формат даних, тобто тип даних, у якому відліки надсилаються через USB/Ethernet/SFP на хост. Для Python API можливі такі варіанти: “sc16”, “sc12” і “sc8”, причому 12-бітовий варіант підтримується лише певними USRP. Цей вибір важливий, оскільки з’єднання між USRP і хост-комп’ютером часто є вузьким місцем, тому, переключившись з 16 біт на 8 біт, ви можете досягти вищої швидкості. Також пам’ятайте, що багато USRP мають АЦП, обмежені 12 або 14 бітами, тому використання “sc16” не означає, що АЦП має 16 біт.

Щодо канальної частини st_args, див. підрозділ про підпристрій і канали нижче.

Передавання

Подібно до зручної функції recv_num_samps(), UHD надає функцію send_waveform() для передавання пачки відліків, приклад якої показано нижче. Якщо ви вкажете тривалість (у секундах), більшу за наданий сигнал, він просто повторюватиметься. Варто утримувати значення відліків між -1.0 і 1.0.

import uhd
import numpy as np
usrp = uhd.usrp.MultiUSRP()
samples = 0.1*np.random.randn(10000) + 0.1j*np.random.randn(10000) # create random signal
duration = 10 # seconds
center_freq = 915e6
sample_rate = 1e6
gain = 20 # [dB] start low then work your way up
usrp.send_waveform(samples, duration, center_freq, sample_rate, [0], gain)

Детальніше про те, як ця зручна функція працює під капотом, дивіться у вихідному коді тут.

Коефіцієнт підсилення передавання

Як і на стороні приймання, діапазон коефіцієнта підсилення передавання залежить від моделі USRP і сягає від 0 дБ до вказаного нижче значення:

  • B200/B210/B200-mini: 90 дБ
  • N210 з WBX: 25 дБ
  • N210 з SBX або UBX: 31,5 дБ
  • E310/E312: 90 дБ
  • N320/N321: 60 дБ

Існує також функція set_normalized_tx_gain(), якщо ви хочете вказати коефіцієнт підсилення передавання, використовуючи діапазон від 0 до 1.

Одночасне передавання та приймання

Якщо ви хочете одночасно передавати і приймати за допомогою одного і того ж USRP, ключовим моментом є використання декількох потоків у межах одного процесу; USRP не може охоплювати декілька процесів. Наприклад, у прикладі на C++ txrx_loopback_to_file створюється окремий потік для роботи передавача, а приймання виконується у головному потоці. Ви також можете просто створити два потоки, один для передавання і один для приймання, як це зроблено у прикладі на Python benchmark_rate. Повний приклад тут не наведено просто тому, що він був би досить довгим, а benchmark_rate.py від Ettus завжди може слугувати відправною точкою.

Підпристрій, канали та антени

Одне з поширених джерел плутанини при використанні USRP - це вибір правильного підпристрою та ідентифікатора каналу. Ви могли помітити, що в кожному з наведених вище прикладів ми використовували канал 0 і не вказували нічого, пов’язаного з підпристроєм. Якщо ви використовуєте B210 і хочете використовувати RF:B замість RF:A, все, що вам потрібно зробити, це вибрати канал 1 замість 0. Але на таких USRP, як X310, які мають два слоти для дочірніх плат, ви повинні вказати UHD, чи хочете ви використовувати слот A або B, і який канал на цій дочірній платі, наприклад:

usrp.set_rx_subdev_spec("B:0")

Якщо ви хочете використовувати порт TX/RX замість RX2 (за замовчуванням), це так само просто, як:

usrp.set_rx_antenna('TX/RX', 0) # set channel 0 to 'TX/RX'

що, по суті, просто керує радіочастотним перемикачем на борту USRP, спрямовуючи сигнал з іншого роз’єму SMA.

Щоб приймати або передавати на двох каналах одночасно, замість st_args.channels = [0] ви вказуєте список, наприклад [0,1]. У цьому випадку буфер прийнятих відліків повинен мати розмір (2, N), а не (1,N). Просто пам’ятайте, що у більшості USRP обидва канали мають спільний гетеродин, тому ви не можете налаштуватися на різні частоти одночасно.

Синхронізація за 10 МГц і PPS

Однією з величезних переваг використання USRP над іншими SDR є можливість синхронізації з зовнішнім джерелом або бортовим GPSDO, що уможливлює застосування з кількома приймачами, наприклад TDOA. Якщо ви підключили зовнішнє джерело 10 МГц і PPS до вашого USRP, вам потрібно переконатися, що ви викликаєте ці два рядки після ініціалізації вашого USRP:

usrp.set_clock_source("external")
usrp.set_time_source("external")

Якщо ви використовуєте вбудований GPSDO, замість цього використовуйте:

usrp.set_clock_source("gpsdo")
usrp.set_time_source("gpsdo")

Щодо синхронізації частоти, то тут більше нічого не потрібно робити; гетеродин, що використовується у змішувачі USRP, тепер буде прив’язаний до зовнішнього джерела або GPSDO. Але з точки зору синхронізації в часі ви, можливо, захочете наказати USRP починати дискретизацію точно за сигналом PPS, наприклад. Це можна зробити за допомогою наступного коду:

# copy the receive example above, everything up until # Start Stream

# Wait for 1 PPS to happen, then set the time at next PPS to 0.0
time_at_last_pps = usrp.get_time_last_pps().get_real_secs()
while time_at_last_pps == usrp.get_time_last_pps().get_real_secs():
    time.sleep(0.1) # keep waiting till it happens- if this while loop never finishes then the PPS signal isn't there
usrp.set_time_next_pps(uhd.libpyuhd.types.time_spec(0.0))

# Schedule Rx of num_samps samples exactly 3 seconds from last PPS
stream_cmd = uhd.types.StreamCMD(uhd.types.StreamMode.num_done)
stream_cmd.num_samps = num_samps
stream_cmd.stream_now = False
stream_cmd.time_spec = uhd.libpyuhd.types.time_spec(3.0) # set start time (try tweaking this)
streamer.issue_stream_cmd(stream_cmd)

# Receive Samples.  recv() will return zeros, then our samples, then more zeros, letting us know it's done
waiting_to_start = True # keep track of where we are in the cycle (see above comment)
nsamps = 0
i = 0
samples = np.zeros(num_samps, dtype=np.complex64)
while nsamps != 0 or waiting_to_start:
    nsamps = streamer.recv(recv_buffer, metadata)
    if nsamps and waiting_to_start:
        waiting_to_start = False
    elif nsamps:
        samples[i:i+nsamps] = recv_buffer[0][0:nsamps]
    i += nsamps

Якщо здається, що це не працює, але не видає жодних помилок, спробуйте змінити число 3.0 на будь-що від 1.0 до 5.0. Ви також можете перевірити метадані після виклику recv(), просто перевірте if metadata.error_code != uhd.types.RXMetadataErrorCode.none:.

Для налагодження ви можете переконатися, що сигнал 10 МГц надходить до USRP, перевіривши значення, яке повертає usrp.get_mboard_sensor("ref_locked", 0). Якщо сигнал PPS не надходить, ви дізнаєтеся про це, оскільки перший цикл while у наведеному вище коді ніколи не завершиться.

Фазокогерентна синхронізація кількох B210 для MIMO

Щоб виконувати такі операції, як визначення напрямку приходу сигналу (DOA) та цифрове формування променя у фазованій антенній решітці, зазвичай потрібно, щоб усі канали приймання були фазокогерентними, тобто відносні фази між каналами приймання залишалися сталими і їх можна було скалібрувати. USRP B200 і B210 побудовані на основі РЧ-мікросхеми AD9361, яка генерує гетеродин усередині, і немає способу подати на неї зовнішній гетеродин, тож навіть якщо ви подасте на USRP опорний сигнал 10 МГц і PPS, це дозволить синхронізувати кілька USRP лише за частотою та тактовим сигналом дискретизації, але не за фазою, оскільки щоразу, коли пристрій вмикається або змінює частоту, виникає новий випадковий фазовий зсув через дільники у ланцюгах VCO/ФАПЧ; докладніше див. цю сторінку. Один зі способів досягти фазової синхронізації - додати апаратуру, яка бере калібрувальний сигнал (згенерований самим USRP, або широкосмуговим джерелом шуму, або тональним генератором), розділяє його і подає на всі приймальні порти, та виконувати швидке калібрування щоразу, коли USRP вмикаються або перелаштовуються. Зауважте, що зміна коефіцієнта підсилення також призводить до фазових зсувів, але доки всі B210 утримуються на однаковому коефіцієнті підсилення, різниця фаз не повинна суттєво змінюватися. У проєкті Techtile є додаткова інформація на цю тему, зокрема власні образи, які можуть дозволити кільком B210 перелаштовуватися разом так, щоб зберігати синхронізацію, хоча, ймовірно, все одно потрібне калібрування із зовнішньою апаратурою щоразу, коли радіомодулі вмикаються.

GPIO

Більшість USRP містять роз’єм GPIO. На B200/B210 це роз’єм J504, а на X310 він розташований на передній панелі.

Спочатку визначимо деякі терміни, які використовує Ettus. CTRL задає, чи керується вивід через ATR (автоматично), чи лише вручну (1 - ATR, 0 - ручне керування). DDR (Data Direction Register, регістр напрямку даних) визначає, чи є GPIO виходом (0), чи входом (1). OUT використовується для ручного встановлення значення виводу (лише в ручному режимі CTRL).

Приклад використання GPIO як виходу для передньої панелі роз’єму “AUX I/O” на X310, докладніше див. цей документ.

import uhd
import time
usrp = uhd.usrp.MultiUSRP()
usrp.set_gpio_attr('FP0A', 'CTRL', 0x000, 0xFFF)
usrp.set_gpio_attr('FP0A', 'DDR', 0xFFF, 0xFFF)
for i in range(10):
    print("Off")
    usrp.set_gpio_attr('FP0A', 'OUT', 0x000, 0xFFF)
    time.sleep(1)
    print("On")
    usrp.set_gpio_attr('FP0A', 'OUT', 0xFFF, 0xFFF)
    time.sleep(1)