Q65 — цифровой протокол, разработанный Джо Тейлором (K1JT) и его командой в 2021 году для проведения минимальных связей в условиях сложных трасс прохождения радиосигнала.

В предыдущей части части были рассмотрены общая структура протокола и алгоритмы формирования сигнала. В этой части статьи рассматриваются принципы цифровой обработки сигналов и синхронизации в протоколе Q65.

Статья может быть интересна радиолюбителям и людям, интересующимся темой цифровой обработки сигналов.

Общее представление

Ранее было рассмотрено, что процесс формирования сигнала состоит из этапов вычисления контрольных сумм, формирования QRA (Q-ary Repeat Accumulation), формирования FSK-сигнала с одним выделенным под синхронизацию тоном.

Рисунок 1: Общая схема протокола (абстракция по уровням OSI условная).
Рисунок 1: Общая схема протокола (абстракция по уровням OSI условная).

При получении сигнала, прежде чем решить обратную задачу по демодуляции и декодированию данных (рисунок 1), сигнал предварительно подвергается таким обработкам, как: компенсация эффекта доплера, подавление помех и борьба с быстрыми затуханиями (fast fading), возникающими при многолучевом распространении сигнала.

Анализ сигнала

Анализ сигнала производится в несколько этапов (рисунок 2):

  • выделение символов в спектре;

  • устранение эффекта Доплера;

  • нормализация спектра;

  • синхронизация;

  • извлечение информационных символов;

  • устранение локальных выбросов;

  • применение модели быстрых затуханий.

Рисунок 2: Пайплайн обработки сигнала.
Рисунок 2: Пайплайн обработки сигнала.

Выделение символов в спектре

Так как в протоколе Q65 используется FSK манипуляция, выделение символов осуществляется в частотной и временной областях сигнала; исходный сигнал разделяется на временные интервалы, к которым применяется быстрое преобразование Фурье (БПФ) для получения амплитудных значений в частотной области.

import typing

import numpy as np
import numpy.typing as npt


def symbol_spectra(self, num_bins: int, num_times: int) -> npt.NDArray[np.float64]:
    fac = (1 / np.iinfo(np.int16).max) * 0.01

    sym_spec = np.zeros((num_times, num_bins), dtype=np.float64)
    for i in range(0, num_times, 2):
        f_beg = i * self.sym_steps
        f_end = f_beg + self.sym_samps

        spec = np.fft.fft(self.signal[f_beg:f_end] * fac, n=self.fft_size)[:num_bins]
        sym_spec[i, :] = np.abs(spec) ** 2

        if self.smooth > 1:
            for _ in range(self.smooth):
                sym_spec[i] = smooth_121(sym_spec[i])

        if i >= 2:
            sym_spec[i - 1] = 0.5 * (sym_spec[i - 2] + sym_spec[i])

    return sym_spec

Функция symbol_spectra, являющаяся частью класса Q65Monitor (более подробно будет описан далее), для дискретного сигнала self.signal осуществляет быстрое преобразование Фурье. Аргумент num_bins определяют на сколько частотных бинов будет раскладываться анализируемый сигнал; параметр num_times определяет из скольки временных отрезков сигнал будет разделен.

Квадрат модуля от результатов FFT позволяет получить амплитудные значения частот.

Примечание: анализ с шагом 2 и интерполяция промежуточных значений спектров используется для оптимизации.

В качестве результата функция symbol_spectra возвращает матрицу, содержащую спектры символов протокола в разрезе времени.

На рисунке 3 приведен спектр анализируемого сигнала, полученный как результат функции symbol_spectra. По оси ординат расположены частотные бины (нумерация относительная), по оси абсцисс — временные отсчеты.

Рисунок 3: Визуализация спектра символов.
Рисунок 3: Визуализация спектра символов.

Эффект Доплера

Для снижения шума и флуктуаций спектральной плотности, возникающим вследствие эффекта Доплера, в Q65 применяется сглаживание спектра.

Функция smooth_121 реализует сглаживание с использованием скользящего среднего, весами ¼, ½, ¼.

def smooth_121(a: np.ndarray):
    v = np.array([0.25, 0.5, 0.25])
    convolved = np.convolve(a, v, mode='same')
    return np.concat([a[:1], convolved[1:-1], a[-1:]])

Степень сглаживания (self.smooth) определяется шириной сигнала; для сигналов с широкой полосой применяется сглаживание спектра с большей степенью размытия.

В python размытие осуществляется функцией свертки convolve из библиотеки numpy, значения весов задаются массивом v.

Значение self.smooth задается как степень числа 2.

self.smooth = max(1, int(0.5 * self.q65_type ** 2))

Так, для Q65B количество сглаживаний составляет 2, для Q65C — 4 и  т.д..

Нормализация спектра

Для повышения чувствительности и снижения ложных срабатываний при детекции сигналов, в Q65 применяется нормализация спектра по 45 перцентилю. Применение нормализации с таким значением перцентиля позволяет обеспечить подавление шумов и выбросов, при этом обеспечивается устойчивость к импульсным помехам без сильного искажения спектра.

sym_spec = self.symbol_spectra(iz, jz)

for j in range(jz):
    t_s = sym_spec[j, self.i0 - 64:self.i0 - 64 + LL]
    if (base := np.percentile(t_s, 45)) == 0:
        base = 0.000001

    sym_spec[j, :] /= base

Значение переменной jz (более детально о ней речь пойдет далее) определяет спектры символов в разрезе времени, значение t_s частотную область спектра символов FSK, в пределах которой будет рассчитываться перцентиль.

Значение перцентиля для символа определяется с помощью функции percentile из библиотеки numpy.

Выражение sym_spec[j, :] /= base осуществляет нормализацию.

Значения 45 в качестве перцентиля выведено эмпирическим путем, так как порядка 55% амплитуд приходится на шум, в то время как полезный сигнал использует оставшиеся 45% спектра. 

Использование больших значений перцентиля повлияет на снижение чувствительности к небольшим локальным пикам, а меньшие значения могут привести к усилению шума.

Таким образом, использование значения перцентиля в 45 позволяет осуществлять масштабирование спектра минимизируя влияние локальных выбросов.

Нормализованный по 45 перцентилю сигнал проходит этап автоматической регулировки усиления (AGC).

for j in range(jz):
    s1_max = 20.0
    s_max = np.max(sym_spec[j, :])

    if s_max > s1_max:
        sym_spec[j, :] *= s1_max / s_max

Корректировка амплитуд осуществляется относительно значения s1_max. Значение 20.0 (s1_max) подобрано эмпирически.

Рисунок 4: Визуализация нормализованного спектра.
Рисунок 4: Визуализация нормализованного спектра.

Синхронизация

Как было рассмотрено в предыдущей статье, в протоколе Q65 используется 65-FSK, из которых 64 тона для передачи данных и 1 пилот-тон выделен для синхронизации, занимающий 22 (Q65_SYNC, Q65_SYNC_TONES_COUNT) FSK-символа из 85 (Q65_TONES_COUNT) передаваемых.

Q65_SYNC = np.array([1, 9, 12, 13, 15, 22, 23, 26, 27, 33, 35, 38, 46, 50, 55, 60, 62, 66, 69, 74, 76, 85])

Q65_SYNC_TONES_COUNT = 22
Q65_TONES_COUNT = 85
Q65_TONES_CENTER = 43

Номера символов, содержащих пилот-тон и параметры передаваемого пакета данных Q65.

Задача этапа синхронизации — обнаружить в спектрах по двум координатом (времени и частоте) расположение пилот-тона, относительно которого будет осуществляется детекция информационных символов.

Для обнаружения пилот-тона в Q65 применяется кросс-корреляция спектра.

Функция sync_ccf реализует алгоритм кросс-корреляции спектра sym_spec и пилот-тонов Q65_SYNC с учетом частотного дрейфа. Аргумент f0 задает нижнюю границу частот, относительно которой производится анализ.

def sync_ccf(self, sym_spec: npt.NDArray[np.float64], f0: float)-> typing.Tuple[int, int, float, float]:
    jz, iz = sym_spec.shape

    dec_df = 50
    snf_a = f0 - dec_df
    snf_b = f0 + dec_df
    ...

Значение dec_df расширяет границы частот (нижнюю snf_a и верхнюю snf_b) поиска в узком диапазоне (Search Narrow Frequency), относительно частоты f0, с учетом допплеровского дрейфа.

    max_drift = min(100 / self.df, 60) if self.f_max_drift else 0
    ...

Переменная max_drift определяет максимальное значение частотного дрейфа, возникающего вследствие эффекта Доплера, для его последующей компенсации при вычислении корреляции. Управление осуществляется флагом self.f_max_drift.

    bin_start = int(max(self.nf_a, 100) / self.df)
    bin_end = int(min(self.nf_b, 4900) / self.df)
    ...

Значения bin_start и bin_end — номера бинов (частотных участков) в спектре, рассчитываемые на значениях частот nf_a и nf_b. Максимальная полоса частот составляет диапазон от 100 Гц до 4900 Гц.

Примечание: нижняя граница частот определена как 100 Гц для исключения низкочастотных помех (в том числе наводки из осветительной сети), возникающих в цепях приема и передачи сигнала от приемника к компьютеру.

Верхняя граница 4900 Гц  — захватывает полосу частот с расположенной в них FSK-тонами, с учетом возможного частотного дрейфа и исключает диапазон частот Найквиста.

    sym_spec_avg = np.sum(sym_spec[:, bin_start:bin_end], axis=0)
    ...

Массив sym_spec_avg определяет общие энергии частот в спектрах sym_spec, вычитаемых из фонового шума при расчете корреляции.

    ccf_best = 0.0
    best = 0
    lag_best = 0
    for i in range(bin_start, bin_end):
        ccf_max_s = 0
        ccf_max_m = 0
        lag_peak_s = 0

        for lag in range(self.lag1, self.lag2 + 1):
            for drift in range(-max_drift, max_drift + 1):
                ...

Циклы i, lag и drift определяют позиционирование окна в разрезе частот, с учетом дрейфа, и временных отсчетов, в рамках которого производится расчет коэффициента кросс-корреляции для пилот-тона.

                ccf_t = 0.0
                for kk in range(Q65_SYNC_TONES_COUNT):
                    k = Q65_SYNC[kk] - 1
                    zz = drift * (k - Q65_TONES_CENTER)
                    ii = i + zz // Q65_TONES_COUNT

                    if ii < 0 or ii >= iz:
                        continue

                    n = NSTEP * k
                    j = n + lag + self.j0
                    if j > -1 and j < jz:
                        ccf_t += sym_spec[j, ii]
                ccf_t -= (Q65_SYNC_TONES_COUNT / jz) * sym_spec_avg[i - bin_start]
                ...

Переменная ccf_t определяет коэффициент кросс-корреляции пилот-тона; накопленное в цикле значение представляет собой сумму амплитуд 22 (Q65_SYNC_TONES_COUNT) тонов, расположенных в спектре на позициях, согласно номерам из Q65_SYNC.

Итоговый коэффициент кросс-корреляции формируется путем вычитания энергий фоновых шумов sym_spec_avg.

                if ccf_t > ccf_max_s:
                    ccf_max_s = ccf_t
                    lag_peak_s = lag

                if ccf_t > ccf_max_m and drift == 0:
                    ccf_max_m = ccf_t

        f = i * self.df
        if ccf_max_s > ccf_best and snf_a <= f <= snf_b:
            ccf_best = ccf_max_s
            best = i
            lag_best = lag_peak_s
        ...

Значения переменных ccf_best и lag_best определяют позицию окна, в рамках которого было достигнуто максимальное значение кросс-корреляции пилот-тона в спектре сигнала, то есть определяет позицию, в которой пилот-тон точнее всего располагается.

    i_peak = best - self.i0
    j_peak = lag_best

    f0 += i_peak * self.df
    dt = j_peak * self.dt_step

    return i_peak, j_peak, f0, dt

Результатом функции sync_ccf является четверка значений с позицией сигнала в разрезе частоты (i_peak), времени (j_peak); также корректируется фактическая базовая частота (f0), относительно которой производился расчет кросс-корреляции пилот-тона; значение dt определяет опережение/отставание сигнала принятого во времени, относительно времени начала анализа.

Извлечение информационных символов

После этапа синхронизации, в Q65 из спектра исключается пилот-тон, оставляя тем самым только информационные символы.

Q65_DATA_TONES_COUNT = Q65_TONES_COUNT - Q65_SYNC_TONES_COUNT

i_peak, j_peak, ccf_freq, time_d = self.sync_ccf(sym_spec, f0)

data_sym = self.get_data_sym(sym_spec, i_peak, j_peak, LL)

Извлечение спектров символов данных осуществляется функцией get_data_sym.

def get_data_sym(self, sym_spec: npt.NDArray[np.float64], i_peak: int, j_peak: int, LL: int) -> npt.NDArray[
    np.float64]:
    jz, iz = sym_spec.shape

    i1 = self.i0 + i_peak + self.q65_type - 64
    i2 = i1 + LL
    i3 = i2 - i1

    data_sym = np.zeros((Q65_DATA_TONES_COUNT, i3), dtype=np.float64)

    if i1 > 0 and i2 < iz:
        j = self.j0 + j_peak - 8
        n = 0

        for k in range(Q65_TONES_COUNT):
            j += 8
            if self.sync[k] > 0.0:
                continue

            if j > 0 and j < jz:
                data_sym[n, :i3] = sym_spec[j, i1:i2]
                n += 1

    bzap(data_sym)

    return data_sym

Функция get_data_sym принимает на вход матрицу-спектр исходного сигнала с метками синхронизации, в качестве результата возвращает уплотненную матрицу-спектр, в котором присутствуют только спектры символов данных.

Фактически, get_data_sym реализует фильтрацию массива. Переменные i1, i2, i3 определяют границы частотных бинов, копируемых в результирующий массив.

На рисунке 5 представлена визуализация матрицы data_sym. Отличительной особенностью является отсутствие пилот-тона синхронизации.

Рисунок 5: Визуализация спектров символов данных.
Рисунок 5: Визуализация спектров символов данных.

Устранение локальных выбросов в спектре

В Q65 для подавления помех, которые на спектре проявляют себя в виде локальных выбросов, применяется статистическая коррекция спектра.

def bzap(data_sym: npt.NDArray[np.float64], threshold: int = 15):
    _, num_bins = data_sym.shape
    hist = np.zeros(num_bins, dtype=np.int64)

    peaks = np.argmax(data_sym, axis=1)
    np.add.at(hist, peaks, 1)

    zap_bins = np.where(hist > threshold)[0]
    data_sym[:, zap_bins] = 1.0

Функция bzap принимает на вход матрицу-спектр, в котором формируется гистограмма hist по позициям максимальной амплитуды в спектре. Относительно значений гистограммы hist происходит эквализация амплитуд частотных бинов до значений уровня 1, статистическая повторяемость которых превышает порог threshold (по умолчанию 15).

Функция модифицирует исходную матрицу data_sym, не возвращая результата.

Замирания

Затухания (fading) или замирания — изменение мощности радиосигнала, возникающее из-за многолучевого распространения, изменениям условий прохождения на трассе или перемещением приемника в пространстве.

В зависимости от скорости изменения и причин возникновения, затухания разделяются на две большие категории: быстрые и медленные.

Быстрые затухания (fast fading) проявляют себя в виде резких флуктуаций (изменений амплитуд и фаз сигнала) на коротких временных интервалах, сравнимых с длительностью одного символа. Они могут быть частотно-селективными, то есть на одних частотах проявляться сильнее, чем на других. Это приводит к искажениям формы импульсов, приводя к межсимвольной интерференции.

Основные причины возникновения — многолучевое распространение радиосигнала, относительно быстрое перемещение приемника, передатчика, объектов, способных отражать сигнал, в том числе доплеровский эффект.

Медленные затухания (slow fading) проявляются как медленное изменение или спад среднего уровня сигнала сравнительно больших интервалах времени. Такой тип затуханий, как правило, не является частотно-селективным.

Причинами возникновения медленных затуханий, в основном, являются изменения свойств прохождения трассы, например при движении облаков, изменении погодных условий, а также из-за эффектов "затенения" сигнала.

На рисунке 6 приведены демонстрационные графики амплитуд сигнала при быстрых и медленных затуханиях.

Рисунок 6: Быстрые и медленные затухания.
Рисунок 6: Быстрые и медленные затухания.

Модель быстрых затуханий в Q65

Так как протокол Q65 рассчитан на работу в условиях достаточно сложных трасс, на которых может проявляться Доплеровский эффект и присущи быстрые затухания, на принимающей стороне спектр сигнал становится "размытым", символы выглядят как область частот, в пределах которой может находится символ (рисунок 7), и, как следствие, снижается вероятность детектирования символа.

Для борьбы с перечисленными эффектами, в Q65 применяется модель быстрых затуханий, основанная на расчете апостериорных вероятностей символов, задача которой заключается в устранении эффекта размытия области частот символов, тем самым обеспечивая более высокую чувствительность детектора при соотношении сигнал/шум (SNR) ниже -20дБ.

Рисунок 7: Спектр сигнала с затуханиями.
Рисунок 7: Спектр сигнала с затуханиями.

Протокол Q65 реализует модели затуханий Гаусса и Лоренца. 

Модель Гаусса достаточно узкополосная и учитывает энергии вблизи центральной частоты и применима в случаях, когда в сигнале присутствуют незначительные искажения; может быть применена для обработки сигналов при тропосферном и дождевом рассеянии.

Модель Лоренца учитывает энергии частот, расположенных намного дальше от центральной частоты и позволяет нивелировать сильные искажения сигнала и эффект Доплера; применим на более сложных трассах, таких как EME.

В основном используется модель быстрых затуханий Лоренца.

Модели затуханий заданы таблично.

lorentz01 = np.array([
    0.0214, 0.9107
])

lorentz02 = np.array([
    0.0244, 0.9030
])
...
lorentz64 = np.array([
    0.0008, 0.0008, 0.0008, 0.0009, 0.0009, 0.0009, 0.0010, 0.0010, 0.0010, 0.0011, 0.0011, 0.0012, 0.0012, 0.0012,
    0.0013, 0.0013, 0.0014, 0.0014, 0.0015, 0.0016, 0.0016, 0.0017, 0.0018, 0.0019, 0.0020, 0.0021, 0.0022, 0.0023,
    0.0024, 0.0025, 0.0027, 0.0028, 0.0030, 0.0031, 0.0033, 0.0035, 0.0038, 0.0040, 0.0043, 0.0046, 0.0049, 0.0052,
    0.0056, 0.0061, 0.0066, 0.0071, 0.0077, 0.0084, 0.0091, 0.0100, 0.0109, 0.0120, 0.0132, 0.0145, 0.0159, 0.0175,
    0.0192, 0.0209, 0.0228, 0.0246, 0.0264, 0.0279, 0.0291, 0.0299, 0.0301
])

fm_tab_lorentz = [
   lorentz01, lorentz02, ..., lorentz64
]
...
gauss01 = np.array([
    0.0296, 0.9101
])

gauss02 = np.array([
    0.0350, 0.8954
])
...
gauss64 = np.array([
    0.0028, 0.0029, 0.0030, 0.0031, 0.0032, 0.0034, 0.0035, 0.0036, 0.0037, 0.0039, 0.0040, 0.0041, 0.0043, 0.0044,
    0.0046, 0.0047, 0.0048, 0.0050, 0.0051, 0.0053, 0.0054, 0.0056, 0.0057, 0.0059, 0.0060, 0.0062, 0.0063, 0.0065,
    0.0066, 0.0068, 0.0069, 0.0071, 0.0072, 0.0074, 0.0075, 0.0077, 0.0078, 0.0079, 0.0081, 0.0082, 0.0083, 0.0084,
    0.0086, 0.0087, 0.0088, 0.0089, 0.0090, 0.0091, 0.0092, 0.0093, 0.0094, 0.0094, 0.0095, 0.0096, 0.0097, 0.0097,
    0.0098, 0.0098, 0.0099, 0.0099, 0.0099, 0.0099, 0.0100, 0.0100, 0.0100
])

fm_tab_gauss = [
   gauss01, gauss02, ..., gauss64
]

На рисунке 8 приведены графики весов моделей Гаусса и Лоренца.

Рисунок 8: Графики весов моделей Гаусса и Лоренца.
Рисунок 8: Графики весов моделей Гаусса и Лоренца.

Подробнее с матрицами весов моделей Гаусса и Лоренца можно ознакомиться здесь и здесь.

Примечание: формулы, по которым были рассчитаны коэффициенты, не приведены и, возможно, их значения были подобраны эмпирически.

Функция q65_intrinsics_fastfading (синоним q65_intrinsics_ff далее) реализует вычисление апостериорных вероятностей символов на основе модели быстрых затуханий.

class FadingModel(Enum):
    Gaussian = 0
    Lorentzian = 1


TS_QRA64 = 0.576


def q65_intrinsics_fastfading(
        codec: Q65Codec,
        input_energies: npt.NDArray[np.float64],
        sub_mode: int,
        B90Ts: float,
        fading_model: FadingModel
) -> npt.NDArray[np.float64]:
...

Функция принимает на вход input_energies — матрица спектров символов. Параметр sub_mode определяет режим протокола Q65 (0 — A, 1 — B, …, 4 — E); B90Ts — полоса пропускания сигнала 90% энергии (большие значения соответствуют большему растеканию энергии сигнала).

    B90 = B90Ts / TS_QRA64

    h_idx = int(np.log(B90) / np.log(1.09) - 0.499)
    h_idx = min(63, max(0, h_idx))

    if fading_model == FadingModel.Gaussian:
        weights = fm_tab_gauss[h_idx]
    elif fading_model == FadingModel.Lorentzian:
        weights = fm_tab_lorentz[h_idx]

    weights_count = len(weights)
    ...

На основе значений параметров B90Ts и fading_model определяются весовые коэффициенты моделей.

    EsNo_deg = 8.0 * np.log(B90) / np.log(240.0)
    EsNo_metric = codec.decoderEsNoMetric * np.pow(10.0, EsNo_deg / 10.0)
    ...

Корректировка оптимальной Es/No (отношение энергии на символ к спектральной плотности шума) характеристики декодера (EsNo_metric) при частотном дрейфе, исходя из предположения, что порог декодирования 50% равен Es/No(dB) = Es/No(AWGN)(dB) + 8*log(B90)/log(240)(dB).

При максимальном частотном дрейфе 240 Гц, вызываемым эффектом Доплера, наблюдается ухудшение Es/No приблизительно на 8 дБ (EsNo_deg) при измерениях на аддитивном Гауссовском шуме (AWGN).

    M = codec.qra_code.alphabet_size
    N = codec.qra_code.codeword_length

    tone_span = 1 << sub_mode
    sym_span = M * (2 + tone_span)

    noise_var = np.mean(input_energies)
    noise_var = noise_var / (1.0 + EsNo_metric / sym_span)
    ...

Определение шумовой картины (noise variance).

    codec.NoiseVar = noise_var
    codec.EsNoMetric = EsNo_metric
    codec.BinsPerTone = tone_span
    codec.BinsPerSymbol = sym_span
    codec.WeightsCount = weights_count

    EsNo_gain = EsNo_metric * weights
    weight = EsNo_gain / (EsNo_gain + 1) / noise_var
    codec.FastFadingWeights = weight

    sym_probs = np.zeros((N, M), dtype=np.float64)

    tap_half = weights_count - 1
    tap_center = 2 * tap_half
    for n in range(N):
        sym_start = M - weights_count + 1

        for k in range(M):
            log_prob = 0
            for j in range(tap_half):
                log_prob += weight[j] * (
                        input_energies[n, sym_start + j] + input_energies[n, sym_start + tap_center - j]
                )

            log_prob += weight[tap_half] * input_energies[n, sym_start + tap_half]
            sym_probs[n, k] = log_prob

            sym_start += tone_span
          ...

Для каждого частотного бина в символе вычисляется логарифм вероятности (log_prob) с учетом весов модели затуханий.

        log_prob_max = np.max(sym_probs[n])

        sum_prob = 0
        for k in range(M):
            rel_log = sym_probs[n, k] - log_prob_max
            rel_log = min(85.0, max(-85.0, rel_log))

            rel_prob = np.exp(rel_log)
            sym_probs[n, k] = rel_prob
            sum_prob += rel_prob

        sym_probs[n, :] *= 1.0 / sum_prob

    return sym_probs

Логарифмические значения вероятностей нормализуются. В качестве результата функция q65_intrinsics_fastfading возвращает матрицу апостериорных вероятностей для каждого символа Q65.

На рисунке 9 приведен спектр сигнала, после применения к нему модели быстрых затуханий; частоты с символами более контрастны на фоне общей шумовой картины.

Рисунок 9: Спектр сигнала после применения модели быстрых затуханий.
Рисунок 9: Спектр сигнала после применения модели быстрых затуханий.

Структура Q65Codec представляет собой датакласс с параметрами QRA-декодера (в данной статье не рассматривается. В q65_intrinsics_fastfading происходит заполнение полей для последующего декодирования результатов вычисления апостериорных вероятностей.

Монитор

Часть вышеописанных функций объединена в класс Q65Monitor, задача которого предоставить интерфейс загрузки данных и декодирования сообщений.

Q65_TONES = 64


class Q65Monitor(AbstractMonitor):
    __slots__ = ["sample_rate", "signal"]

    CCF_OFFSET_R = 70
    CCF_OFFSET_C = 7000

    def __init__(self, eme_delay: bool, q65_type: typing.Literal[1, 2, 3, 4],
                 period: typing.Optional[typing.Literal[15, 30, 60, 120, 300]] = None):
        self.signal = np.zeros(0, dtype=np.float64)

        self.q65_type = q65_type

        if period == 30:
            self.sym_samps = 3600
        elif period == 60:
            self.sym_samps = 7200
        elif period == 120:
            self.sym_samps = 16000
        else:
            self.sym_samps = 3600
    ...

Определение количества семплов на символ, в зависимости от периода протокола Q65.

        self.fft_size = self.sym_samps
        self.df = 12000.0 / self.fft_size

        self.smooth = max(1, int(0.5 * self.q65_type ** 2))
    ...

Параметры расчета БПФ; self.smooth — фактор сглаживания спектра, для снижения эффекта Доплера, определяется шириной полосы сигнала.

        self.sym_steps = self.sym_samps // NSTEP
        self.dt_step = self.sym_samps / (NSTEP * 12000.0)

        self.lag1 = int(-1.0 / self.dt_step)
        self.lag2 = int((5.5 if self.sym_samps >= 3600 and eme_delay else 1) / self.dt_step + 0.9999)
    ...

Определение количество отсчетов на один символ и определение временных лагов для сигналов подверженных сильному Доплеровскому влиянию.

        self.i0 = 0
        self.j0 = int((1 if self.sym_samps >= 7200 else 0.5) / self.dt_step)
        
        self.NN = 63
    ...

Количество информационных символов в сигнале, определяет размер матриц сигнала.

        self.max_iters = 100

        self.nf_a = 214.333328
        self.nf_b = 2000.000000
    ...

Значения полей self.nf_a, self.nf_b определяют полосу частот, в которой будет производиться декодирование сигнала.

        self.bw_a = 1
        self.bw_b = 11
    ...

Границы рассчета B90Ts — ширины спектра с 90% энергии, для расчета в модели быстрых замираний.

        self.f_max_drift = False

        self.q65_codec = q65_init()

        self.sync = np.full(Q65_TONES_COUNT, -22.0 / 63.0, dtype=np.float64)
        self.sync[Q65_SYNC - 1] = 1
    ...

В self.sync записывается пилот-тон используемый в расчете кросс-корреляции при синхронизации.

    def symbol_spectra(self, num_bins: int, num_times: int) -> npt.NDArray[np.float64]:
    ...

    def sync_ccf(self, sym_spec: npt.NDArray[np.float64], f0: float) -> typing.Tuple[int, int, float, float]:
    ...

    def get_data_sym(self, sym_spec: npt.NDArray[np.float64], i_peak: int, j_peak: int, LL: int) -> npt.NDArray[
        np.float64]:
    ...

    def decode_2(self, s3: npt.NDArray[np.float64], sub_mode: int, b90ts: float) -> typing.Optional[
        typing.Tuple[float, npt.NDArray]
    ]:
        s3prob = q65_intrinsics_ff(self.q65_codec, s3, sub_mode, b90ts, fading_model=FadingModel.Lorentzian)
    ...

Метод decode_2 формирует матрицу вероятностей символов на основе модели быстрых затуханий, для ее последующей передачи в QRA-декодер (в рамках текущей статье не рассматривается).

    def decode_q012(self, s3: npt.NDArray[np.float64]) -> typing.Optional[typing.Tuple[float, npt.NDArray]]:
        if self.q65_type == 2:
            sub_mode = 1
        elif self.q65_type == 4:
            sub_mode = 2
        elif self.q65_type == 8:
            sub_mode = 3
        else:
            sub_mode = 0

        baud = 12000.0 / self.sym_samps
        for bw in range(self.bw_a, self.bw_b + 1):
            b90 = 1.72 ** bw
            b90ts = b90 / baud

            if (decoded := self.decode_2(s3, sub_mode, b90ts)) is not None:
                EsNo_dB, payload = decoded

                snr = EsNo_dB - dB(2500.0 / baud) + 3.0
                return snr, payload
        return None
    ...

Метод decode_q012 реализует декодирование Q65 для режимов q1, q2 и q3. В цикле bw подбираются значения B90Ts для модели быстрых затуханий до тех пор, пока декодирование сигнала не даст положительный результат.

    def decode_0(self, f0: float) -> typing.Tuple[float, float, npt.NDArray[np.int64], float]:
        LL = Q65_TONES * (2 + self.q65_type)

        txt = Q65_TONES * self.sym_samps / 12000.0 + (2 if self.sym_samps >= 6912 else 1)
        iz = int(5000.0 / self.df)
        jz = int(txt * 12000.0 / self.sym_steps)
    ...

LL — число частотных бинов в спектре; txt — размер временного окна анализа сигнала; iz — общее количество частотных бинов в спектре с верхней границей в 5 КГц; jz — количество временных отсчетов в окне длительностью txt.

        self.i0 = min(max(int(f0 / self.df), Q65_TONES), iz + Q65_TONES - LL)
    ...

Значение self.i0 определяет центральную частоту, относительно которой будет производиться нормализация и выделение пилот-тона.

        sym_spec = self.symbol_spectra(iz, jz)

        for j in range(jz):
            t_s = sym_spec[j, self.i0 - Q65_TONES:self.i0 - Q65_TONES + LL]
            if (base := np.percentile(t_s, 45)) == 0:
                base = 0.000001

            sym_spec[j, :] /= base

        for j in range(jz):
            s1_max = 20.0
            s_max = np.max(sym_spec[j, :])

            if s_max > s1_max:
                sym_spec[j, :] *= s1_max / s_max

        i_peak, j_peak, ccf_freq, time_d = self.sync_ccf(sym_spec, f0)
        data_sym = self.get_data_sym(sym_spec, i_peak, j_peak, LL)

        snr, data = self.decode_q012(data_sym)

        return time_d, ccf_freq, data, snr
    ...

Метод decode_0 реализует верхнеуровневый интерфейс декодирования Q65, ранее рассмотренные построение спектра символов, рассчет 45 перцентиля, выделение символов из спектра и передачу его в декодер q1-q2-q3.

    def decode(self, f0: int, **kwargs) -> typing.Generator[LogItem, None, None]:
        dT, ccf_freq, payload, snr = self.decode_0(f0=f0)

        yield LogItem(snr, dT, ccf_freq - f0, payload.tobytes(), 0)

    def monitor_process(self, frame: npt.NDArray[np.int16]) -> None:
        self.signal = np.concat([self.signal, frame])

Заключение

В продолжении цикла статей, посвященных протоколу Q65, в этой статье были рассмотрены механизмы цифровой обработки сигналов, устранение влияния эффекта Доплера, механизм синхронизации пилот-тона, нормализация и фильтрация выбросов в спектре, определение вероятностей символов в условиях быстрых затуханий.

В  статью не вошел алгоритм декодирования QRA, по причине достаточно большой темы Q-ary Repeat Accumulation кодирования, объем которой сопоставим с отдельной статьей.

Декодер Q65 может представлять интерес с точки зрения цифровой обработки сигналов в условиях Доплеровских искажений и быстрых затуханий.

Ссылки

  1. Первая часть о протоколе Q65

  2. Общий репозиторий с реализацией FTX, MSKX, Q65 Python

  3. Исходный код демодулятора Q65

  4. Статья из журнала QEX с описание протокола (перевод 1, перевод 2)

  5. Презентация IV3NWV о QRA

  6. Программа WSJT, реализующая протокол Q65

  7. Программа MSHV, реализующая протокол Q65

Комментарии (0)