12. IQ файли та SigMF

У всіх наших попередніх прикладах на Python ми зберігали сигнали у вигляді 1D NumPy масивів типу “complex float”. У цій главі ми дізнаємося, як зберігати сигнали у файлі, а потім зчитувати їх назад у Python, а також познайомимося зі стандартом SigMF. Зберігання даних сигналів у файлі є надзвичайно корисним: ви можете записати сигнал у файл, щоб вручну проаналізувати його в автономному режимі, поділитися ним з колегою або створити цілий набір даних.

Двійкові файли

Нагадаємо, що цифровий сигнал у базовій смузі частот - це послідовність комплексних чисел.

Приклад: [0.123 + j0.512, 0.0312 + j0.4123, 0.1423 + j0.06512, …].

Ці числа відповідають [I+jQ, I+jQ, I+jQ, I+jQ, I+jQ, I+jQ, I+jQ, I+jQ, I+jQ, …].

Коли ми хочемо зберегти комплексні числа у файл, ми зберігаємо їх у форматі IQIQIQIQIQIQIQIQIQIQIQ. Тобто, ми зберігаємо купу чисел з плаваючою комою підряд, а коли ми їх зчитуємо, ми повинні розділити їх назад на [I+jQ, I+jQ, …].

Хоча комплексні числа можна зберігати в текстовому файлі або csv-файлі, ми вважаємо за краще зберігати їх у так званому “двійковому файлі”, щоб заощадити місце. При високій частоті дискретизації ваші записи сигналів можуть легко займати кілька гігабайт, і ми хочемо бути максимально ефективними в плані використання пам’яті. Якщо ви коли-небудь відкривали файл у текстовому редакторі і він виглядав незрозуміло, як на скріншоті нижче, ймовірно, він був бінарним. Бінарні файли містять серію байтів, і вам доведеться самостійно відстежувати формат. Двійкові файли є найефективнішим способом зберігання даних, якщо припустити, що було виконано все можливе стиснення. Оскільки наші сигнали зазвичай виглядають як випадкова послідовність чисел з плаваючою комою, ми зазвичай не намагаємося стискати дані. Двійкові файли використовуються для багатьох інших речей, наприклад, для компіляції програм (так званих “бінарників”). Коли вони використовуються для збереження сигналів, ми називаємо їх двійковими “IQ-файлами”, використовуючи розширення .iq.

../_images/binary_file.png

У Python за замовчуванням комплексним типом є np.complex128, який використовує два 64-бітних плаваючих числа на семпл. Але в DSP/SDR ми, як правило, використовуємо 32-розрядні плаваючі числа, тому що АЦП на наших SDR не мають такої точності, щоб гарантувати 64-розрядні плаваючі числа. У Python ми будемо використовувати np.complex64, який використовує два 32-бітних плаваючих числа. Коли ви просто обробляєте сигнал у Python, це не має значення, але коли ви збираєтеся зберегти масив 1d у файл, ви хочете спочатку переконатися, що це масив np.complex64.

Приклади на Python

У Python, зокрема у numpy, ми використовуємо функцію tofile() для збереження масиву numpy у файл. Ось короткий приклад створення простого BPSK-сигналу з шумом і збереження його у файлі в тому ж каталозі, звідки ми запускали наш скрипт:

import numpy as np
import matplotlib.pyplot as plt

num_symbols = 10000

x_symbols = np.random.randint(0, 2, num_symbols)*2-1 # -1 та 1
n = (np.random.randn(num_symbols) + 1j*np.random.randn(num_symbols))/np.sqrt(2) # AWGN з одиничним степенем
r = x_symbols + n * np.sqrt(0.01) # потужність шуму 0.01
print(r)
plt.plot(np.real(r), np.imag(r), '.')
plt.grid(True)
plt.show()

# Тепер зберігаємо у IQ-файл
print(type(r[0])) # Перевіряємо тип даних.  Упс, 128, а не 64!
r = r.astype(np.complex64) # Переводимо в 64
print(type(r[0])) # Переконатись, що це 64
r.tofile('bpsk_in_noise.iq') # Зберегти у файл

Тепер подивіться на деталі створеного файлу і перевірте, скільки у ньому байт. Він має бути num_symbols * 8, тому що ми використовували np.complex64, який має 8 байт на семпл, 4 байти на плаваючу комірку (2 плаваючі комірки на семпл).

Використовуючи новий скрипт Python, ми можемо прочитати цей файл за допомогою np.fromfile(), наприклад, так:

import numpy as np
import matplotlib.pyplot as plt

samples = np.fromfile('bpsk_in_noise.iq', np.complex64) # Читаємо у файл.  Треба вказати, у якому він форматі
print(samples)

# Побудуємо сузір'я, щоб переконатися, що воно виглядає правильно
plt.plot(np.real(samples), np.imag(samples), '.')
plt.grid(True)
plt.show()

Велика помилка - забути вказати np.fromfile() формат файлу. Двійкові файли не містять жодної інформації про свій формат. За замовчуванням np.fromfile() припускає, що він читає у форматі масиву float64.

Більшість інших мов мають методи для читання у двійкових файлах, наприклад, у MATLAB ви можете використовувати fread(). Для візуального аналізу RF-файлу дивіться розділ нижче.

Якщо ви коли-небудь матимете справу з int16 (так званими короткими int) або будь-яким іншим типом даних, для якого numpy не має комплексного еквівалента, ви будете змушені читати приклади як справжні, навіть якщо вони насправді є комплексними. Хитрість полягає у тому, щоб прочитати їх як дійсні, але потім перетворити їх назад у формат IQIQIQ… самостійно, кілька різних способів зробити це показано нижче:

samples = np.fromfile('iq_samples_as_int16.iq', np.int16).astype(np.float32).view(np.complex64)

або

samples = np.fromfile('iq_samples_as_int16.iq', np.int16)
samples /= 32768 # конвертуємо в -1 до +1 (необов'язково)
samples = samples[::2] + 1j*samples[1::2] # конвертувати в IQIQIQ...

Візуальний аналіз радіочастотного файлу

Хоча ми навчилися створювати власні графіки спектрограм у розділі Частотний домен, ніщо не зрівняється з використанням вже створеного програмного забезпечення. Коли справа доходить до аналізу радіочастотних записів без необхідності нічого встановлювати, найкращим сайтом є IQEngine, який є цілим інструментарієм для аналізу, обробки та обміну радіочастотними записами.

Для тих, кому потрібен десктопний додаток, є також inspectrum. Inspectrum - це досить простий, але потужний графічний інструмент для візуального сканування радіочастотного файлу з тонким контролем діапазону кольорової карти і розміру БПФ (масштабу). Ви можете утримувати клавішу Alt і використовувати колесо прокрутки для переміщення в часі. Програма має додаткові курсори для вимірювання дельта-часу між двома сплесками енергії, а також можливість експортувати фрагмент радіочастотного файлу до нового файлу. Для встановлення на платформах на основі Debian, таких як Ubuntu, скористайтеся наступними командами:

sudo apt-get install qt5-default libfftw3-dev cmake pkg-config libliquid-dev
git clone https://github.com/miek/inspectrum.git
cd inspectrum
mkdir build
cd build
cmake ..
зробити
sudo make install
inspectrum
../_images/inspectrum.jpg

Максимальні значення та насиченість

При отриманні семплів з SDR важливо знати максимальне значення семплу. Багато SDR виводять семпли як числа з плаваючою комою з максимальним значенням 1.0 і мінімальним -1.0. Інші SDR надають вам вибірки як цілі числа, зазвичай 16-розрядні, в цьому випадку максимальне і мінімальне значення буде +32767 і -32768 (якщо не вказано інше), і ви можете розділити на 32,768, щоб перетворити їх у значення з плаваючою комою від -1.0 до 1.0. Причина, по якій необхідно знати максимальне значення для вашого SDR, полягає в насиченні: при отриманні дуже гучного сигналу (або якщо коефіцієнт підсилення встановлено занадто високим), приймач “насититься” і обріже високі значення до того, яким би не було максимальне значення дискретизації. АЦП на наших SDR мають обмежену кількість бітів. При створенні SDR-додатків доцільно завжди перевіряти насичення, і коли це відбувається, ви повинні якось позначити це.

Сигнал, який є насиченим, буде виглядати нестабільним у часовій області, як це показано нижче:

Приклад насиченого приймача, де сигнал обрізано

Через різкі зміни в часовій області, спричинені усіченням, частотна область може виглядати розмазаною. Іншими словами, частотна область буде включати помилкові особливості; особливості, які є результатом насичення і насправді не є частиною сигналу, що може збити людей з пантелику при аналізі сигналу.

SigMF та анотування IQ файлів

Оскільки сам IQ-файл не має жодних метаданих, пов’язаних з ним, зазвичай створюють 2-й файл, що містить інформацію про сигнал, з тим самим іменем, але з розширенням .txt або іншим. Він повинен містити, як мінімум, частоту дискретизації, яка використовувалася для збору сигналу, і частоту, на яку було налаштовано SDR. Після аналізу сигналу файл метаданих може містити інформацію про діапазони дискретизації цікавих особливостей, таких як сплески енергії. Індекс вибірки - це просто ціле число, яке починається з 0 і збільшується з кожною складною вибіркою. Якби ви знали, що є енергія від зразка 492342 до 528492, то ви могли б прочитати файл і витягнути цю частину масиву: samples[492342:528493].

На щастя, зараз існує відкритий стандарт, який визначає формат метаданих для опису записів сигналів, відомий як SigMF. Використовуючи відкритий стандарт, такий як SigMF, різні сторони можуть легше обмінюватися записами радіосигналів і використовувати різні інструменти для роботи з тими самими наборами даних, такі як IQEngine. Це також запобігає “бітротству” наборів радіочастотних даних, коли деталі захоплення втрачаються з часом через те, що деталі запису не співпадають із самим записом.

Найпростіший (і мінімальний) спосіб використання стандарту SigMF для опису створеного вами бінарного IQ-файлу - перейменувати файл .iq на .sigmf-data і створити новий файл з тим самим ім’ям, але з розширенням .sigmf-meta, і переконатися, що поле типу даних у метафайлі відповідає бінарному формату вашого файлу даних. Цей метафайл є звичайним текстовим файлом, заповненим json, тому ви можете просто відкрити його за допомогою текстового редактора і заповнити вручну (пізніше ми обговоримо, як зробити це програмно). Ось приклад .sigmf-meta файлу, який ви можете використовувати як шаблон:

{
    "global": {
        "core:datatype": "cf32_le",
        "core:sample_rate": 1000000,
        "core:hw": "PlutoSDR з 915 МГц штирьовою антеною",
        "core:author": "Art Vandelay",
        "core:version": "1.0.0"
    },
    "captures": [
        {
            "core:sample_start": 0,
            "core:frequency": 915000000
        }
    ],
    "annotations": []
}

Зверніть увагу, що core:cf32_le вказує на те, що ваші .sigmf-дані мають тип IQIQIQIQ… з 32-бітними числами з плаваючою комою, тобто np.complex64, як ми використовували раніше. Зверніться до специфікацій інших доступних типів даних, наприклад, якщо ви використовуєте дійсні дані замість комплексних, або використовуєте 16-розрядні цілі числа замість плаваючих для економії місця.

Окрім типу даних, найважливішими рядками для заповнення є core:sample_rate та core:frequency. Належною практикою є також введення інформації про апаратне забезпечення (core:hw), яке було використано для захоплення запису, наприклад, тип SDR та антени, а також опис того, що відомо про сигнал(и) у записі у core:description. Поле core:version - це просто версія стандарту SigMF, яка використовувалася на момент створення файлу метаданих.

Якщо ви записуєте радіосигнал з Python, наприклад, використовуючи API Python для SDR, ви можете уникнути необхідності створювати ці файли метаданих вручну, скориставшись пакетом SigMF Python. Його можна встановити на ОС на базі Ubuntu/Debian наступним чином:

cd ~
git clone https://github.com/gnuradio/SigMF.git
cd SigMF
sudo pip install .

Нижче наведено код Python для написання файлу .sigmf-meta для прикладу на початку цієї глави, куди ми зберегли bpsk_in_noise.iq:

import numpy as np
import datetime as dt
from sigmf import SigMFFile

# <код з прикладу

# r.tofile('bpsk_in_noise.iq')
r.tofile('bpsk_in_noise.sigmf-data') # замінити рядок вище на цей

# створюємо метадані
meta = SigMFFile(
    data_file='example.sigmf-data', # розширення необов'язкове
    global_info = {
        SigMFFile.DATATYPE_KEY: 'cf32_le',
        SigMFFile.SAMPLE_RATE_KEY: 8000000,
        SigMFFile.AUTHOR_KEY: 'Ваше ім'я та/або email',
        SigMFFile.DESCRIPTION_KEY: 'Імітація BPSK з шумом',
        SigMFFile.VERSION_KEY: sigmf.__version__,
    }
)

# створити ключ захоплення з часовим індексом 0
meta.add_capture(0, metadata={
    SigMFFile.FREQUENCY_KEY: 915000000,
    SigMFFile.DATETIME_KEY: dt.datetime.utcnow().isoformat()+'Z',
})

# перевірка на помилки та запис на диск
meta.validate()
meta.tofile('bpsk_in_noise.sigmf-meta') # розширення не обов'язкове

Просто замініть 8000000 та 915000000 на змінні, які ви використовували для зберігання частоти дискретизації та центральної частоти відповідно.

Щоб прочитати запис у форматі SigMF у Python, скористайтеся наступним кодом. У цьому прикладі два SigMF-файли слід назвати bpsk_in_noise.sigmf-meta і bpsk_in_noise.sigmf-data.

from sigmf import SigMFFile, sigmffile

# Завантажити набір даних
filename = 'bpsk_in_noise'
signal = sigmffile.fromfile(filename)
samples = signal.read_samples().view(np.complex64).flatten()
print(samples[0:10]) # виводимо перші 10 зразків

# отримуємо метадані та всі анотації
sample_rate = signal.get_global_field(SigMFFile.SAMPLE_RATE_KEY)
sample_count = signal.sample_count
signal_duration = sample_count / sample_rate

За більш детальною інформацією зверніться до документації SigMF.

Невеликий бонус для тих, хто дочитав до цього місця: логотип SigMF фактично зберігається як сам запис SigMF, і коли сигнал будується у вигляді сузір’я (IQ-діаграма) у часі, він створює наступну анімацію:

Анімація логотипу SigMF

Код на Python, який використовується для зчитування файлу логотипу (розташованого тут) і створення анімованого gif-файлу, показано нижче, для тих, кому цікаво:

import numpy as np
import matplotlib.pyplot as plt
import imageio
from sigmf import SigMFFile, sigmffile

# Завантажуємо набір даних
filename = 'sigmf_logo' # вважаємо, що він знаходиться у тому ж каталозі, що і цей скрипт
signal = sigmffile.fromfile(filename)
samples = signal.read_samples().view(np.complex64).flatten()

# Додаємо нулі в кінці, щоб було зрозуміло, коли анімація повторюється
samples = np.concatenate((samples, np.zeros(50000)))

sample_count = len(samples)
samples_per_frame = 5000
num_frames = int(sample_count/samples_per_frame)
filenames = []
for i in range(num_frames):
    print("frame", i, "out of", num_frames)
    # Побудувати графік кадру
    fig, ax = plt.subplots(figsize=(5, 5))
    samples_frame = samples[i*samples_per_frame:(i+1)*samples_per_frame]
    ax.plot(np.real(samples_frame), np.imag(samples_frame), color="cyan", marker=".", linestyle="None", markersize=1)
    ax.axis([-0.35,0.35,-0.35,0.35]) # зберігаємо вісь постійною
    ax.set_facecolor('black') # колір фону

    # Зберегти графік у файл
    filename = '/tmp/sigmf_logo_' + str(i) + '.png'
    fig.savefig(filename, bbox_inches='tight')
    filenames.append(filename)

# Створюємо анімований gif
images = []
for filename in filenames:
    images.append(imageio.imread(filename))
imageio.mimsave('/tmp/sigmf_logo.gif', images, fps=20)