
Q65 — цифровой протокол, разработанный Джо Тейлором (K1JT) и его командой в 2021 году для проведения минимальных связей в условиях сложных трасс прохождения радиосигнала.
В предыдущей части были рассмотрены общая структура протокола и алгоритмы формирования сигнала. В этой части статьи рассматриваются принципы цифровой обработки сигналов и синхронизации в протоколе Q65.
Статья может быть интересна радиолюбителям и людям, интересующимся темой цифровой обработки сигналов.
Общее представление
Ранее было рассмотрено, что процесс формирования сигнала состоит из этапов вычисления контрольных сумм, формирования QRA (Q-ary Repeat Accumulation), формирования FSK-сигнала с одним выделенным под синхронизацию тоном.

При получении сигнала, прежде чем решить обратную задачу по демодуляции и декодированию данных (рисунок 1), сигнал предварительно подвергается таким обработкам, как: компенсация эффекта доплера, подавление помех и борьба с быстрыми затуханиями (fast fading), возникающими при многолучевом распространении сигнала.
Анализ сигнала
Анализ сигнала производится в несколько этапов (рисунок 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. По оси ординат расположены частотные бины (нумерация относительная), по оси абсцисс — временные отсчеты.

Эффект Доплера
Для снижения шума и флуктуаций спектральной плотности, возникающим вследствие эффекта Доплера, в 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) подобрано эмпирически.

Синхронизация
Как было рассмотрено в предыдущей статье, в протоколе 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. Отличительной особенностью является отсутствие пилот-тона синхронизации.

Устранение локальных выбросов в спектре
В 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 приведены демонстрационные графики амплитуд сигнала при быстрых и медленных затуханиях.

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

Протокол 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 приведены графики весов моделей Гаусса и Лоренца.

Подробнее с матрицами весов моделей Гаусса и Лоренца можно ознакомиться здесь и здесь.
Примечание: формулы, по которым были рассчитаны коэффициенты, не приведены и, возможно, их значения были подобраны эмпирически.
Функция 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 приведен спектр сигнала, после применения к нему модели быстрых затуханий; частоты с символами более контрастны на фоне общей шумовой картины.

Структура 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 может представлять интерес с точки зрения цифровой обработки сигналов в условиях Доплеровских искажений и быстрых затуханий.
