WSPR — цифровой протокол, разработанный Джо Тейлором (K1JT) в 2008-2009 годах, с целью исследования распространения радиосигналов от коротковолновых передатчиков малой и сверхмалой мощности. В этой статье будут рассмотрены устройство и принципы работы протокола.

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

Введение

Дальняя связь на КВ диапазоне (3 МГц-30 МГц) возможна благодаря использованию эффекта отражения и распространения радиоволн в ионосфере, способной выступать в качестве волновода. Достигая слоя ионизированного газа (от 140 до 1000 км), радиоволна, подобно свету, подвергается преломлению (рисунок 1), — часть энергии уходит выше, за пределы ионосферы, другая часть отражается обратно в сторону поверхности земли, где уже может быть зафиксирована приемными устройствами. При определенных условиях поверхность планеты может выступать в качестве пассивного отражателя и отразить сигнал обратно, в сторону ионосферы, — т.н. "прыжок" или "скачок" (англ. bounce или hop), — что позволяет радиосигналу распространяться на очень большие расстояния, вплоть до того, что сигнал, обогнув планету, может дойти до той точки, из которой он был отправлен.

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

Рисунок 1. Распространение радиоволн в ионосфере.
Рисунок 1. Распространение радиоволн в ионосфере.

Условия прохождения и распространения радиоволн в ионосфере во многом определяется ее состоянием, которое в свою очередь зависит от природных факторов, таких как солнечная активность (в т.ч. магнитные бури), время года, время суток. Так, например, участки из нижней части КВ диапазона обеспечивают связь в темное время суток, — например диапазон 80 м, высокочастотные, такие как 20 м, — днем; диапазон 40 м ночью позволяет проводить дальние связи, днем — ближние; диапазон 10 м при максимальной солнечной активности позволяет осуществлять дальние связи в дневное время суток.

Необходимо также упомянуть особенность КВ-диапазона, которая заключается в том, что на нем не всегда возможна связь на близкие расстояния, т.к. в зоне прямой видимости распространение радиоволн не всегда осуществимо, а угол отражения от ионосферы достаточно острый, что делает невозможным использовать ионосферу в качестве отражателя.

WSPR (созвучно со словом whisper, рус. шепот) — Weak Signal Propagation Reporter, цифровой радиолюбительский протокол радиосвязи, основное назначение которого заключается в автоматизации процесса исследования состояния ионосферы путем анализа прохождения слабых радиосигналов.

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

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

WSPR также может применяться радиолюбителями в качестве инструмента для оценки качества антенн и прогнозирования территорий для проведения любительских связей (QSO).

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

Общие технические характеристики

  • Цикл передачи: 110.59 сек;

  • Тип модуляции: 4-FSK;

  • Механизм коррекции ошибок: сверточный код (½);

  • Ширина полосы: 5.85 Гц:

  • Размер сообщения: 50 бит.

Структура протокола

WSPR реализует достаточно простую структуру, при формировании FSK-символов данные проходят процесс кодирования помехоустойчивым алгоритмом и добавлением синхронизирующего вектора.

Общая схема протокола приведена на рисунке 2. Разделение на уровни модели OSI условное.

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

На рисунке 2 приведена общая схема протокола WSPR при отправке сигнала.

Протокол прикладного уровня

WSPR является радиолюбительским протоколом, но не рассчитан на проведение полноценных сеансов радиосвязи между радиолюбителями (QSO). Для идентификации принимаемых сигналов, в качестве пользовательских данных протокол передает позывной станции, QTH-локатор — квадрат локации, в которой расположен передатчик, аналогично протоколу FT8 рассмотренному в соответствующей статье.

Примечание: протокол FT8 был разработан позднее протокола WSPR и заимствовал из него многие принципы и наработки.

В перечень передаваемых данных также входит значение мощности передатчика, определяемое в dBm (децибел на милливатт).

Участники WSPR делятся на две категории: маяки и репортеры.

Так как время передачи сигнала составляет 110 секунд, а мощности передатчиков и соотношения сигнал/шум могут иметь очень низкие значения, протокол имеет строгую привязку ко времени и временным слотам. В начале каждой четной минуты маяки начинают отправку сигнала в эфир, при этом допустимая погрешность во времени не должна превышать 1 секунды. Сигнал передается в течении 110.59 секунд, в это же время приемники принимают и накапливают все принимаемые в полосе частот сигналы. В оставшиеся ~10 секунд интервала происходит анализ и демодуляция сигнала с последующей отправкой отчета на сервер wsprnet.org.

Помимо этого, диапазон частот, в котором осуществляется прием и передача сигналов, ограничена полосой шириной в 200 Гц; для избежания ситуации наложения сигналов, каждый из передатчиков выбирает случайную центральную частоту из этого диапазона (частота, относительно которой формируется FSK-сигнал). Помимо этого, маяки работающие на постоянной основе, могут выбирать случайным образом тайм-слоты для передачи (например не более 7 сеансов передач в час).

Уровень представления

На уровне представления происходит уплотнение передаваемых данных в 50-битное представление. Алгоритм упаковки во многом похож на FT8, с той лишь разницей, что используются немного другие алфавиты и способ кодирования позывного.

Алфавиты WSPR представляют собой конкатенацию из списка цифр и ASCII-символов.

import string


CHAR_TABLE_NUMERIC = string.digits
CHAR_TABLE_LETTERS = string.ascii_uppercase

WSPR_CHAR_TABLE_NUMERIC = CHAR_TABLE_NUMERIC
WSPR_CHAR_TABLE_LETTERS = CHAR_TABLE_LETTERS
WSPR_CHAR_TABLE_LETTERS_SPACE = f"{WSPR_CHAR_TABLE_LETTERS} "
WSPR_CHAR_TABLE_ALPHANUM = f"{WSPR_CHAR_TABLE_NUMERIC}{WSPR_CHAR_TABLE_LETTERS}"
WSPR_CHAR_TABLE_ALPHANUM_SPACE = f"{WSPR_CHAR_TABLE_NUMERIC}{WSPR_CHAR_TABLE_LETTERS_SPACE}"

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

def nchar(c: str, table: str) -> int:
    return table.find(c)


def charn(c: int, table: str) -> str:
    return table[c]


def ct_decode(ct: str, val: int, l: int) -> str:
    s = ""
    ct_l = len(ct)
    for i in range(l):
        s = charn(val % ct_l, ct) + s
        val //= ct_l
        
    return s


def ct_map_decode(ct_map: typing.List[str], val: int) -> str:
    s = ""
    for ct_len, ct in map(lambda ct: (len(ct), ct), reversed(ct_map)):
        s = charn(val % ct_len, ct) + s
        val //= ct_len
        
    return s

Кодирование сообщения

Аналогично FT8 в WSPR данные сообщения подвергаются процессу уплотнения бит.

from abc import ABCMeta, abstractmethod


class MsgItem(metaclass=ABCMeta):
    __slots__ = ("val_str", "val_int")

    def __init__(self, val: typing.Union[str, int]):
        if not self.validate(val):
            raise ValueError("Validation error")

        if isinstance(val, str):
            self.val_str = val.strip()
            self.val_int = self.to_int()
        elif isinstance(val, int):
            self.val_int = val
            self.val_str = self.to_str()
        else:
            raise TypeError(f"Unsupported data type {type(val)}")

    @classmethod
    @abstractmethod
    def _validate_str(cls, val: str) -> bool:
        ...

    @classmethod
    @abstractmethod
    def _validate_int(cls, val: int) -> bool:
        ...

    @classmethod
    def validate(cls, val: typing.Union[str, int]) -> bool:
        if isinstance(val, str):
            return cls._validate_str(val)
        elif isinstance(val, int):
            return cls._validate_int(val)

        return False

    @abstractmethod
    def to_int(self) -> int:
        ...

    @abstractmethod
    def to_str(self) -> str:
        ...

    @property
    def as_str(self):
        return self.val_str

    @property
    def as_int(self):
        return self.val_int
    ...

Абстрактный MsgItem реализует общий интерфейс кодека для каждой из частей сообщения, где упакованные данные преобразуются в целые числа и наоборот.

Кодек позывного

Кодирование позывного осуществляется по схеме:

WSPR_BASECALL_CHAR_MAP = [
    WSPR_CHAR_TABLE_ALPHANUM_SPACE,
    WSPR_CHAR_TABLE_ALPHANUM,
    WSPR_CHAR_TABLE_NUMERIC,
    WSPR_CHAR_TABLE_LETTERS_SPACE,
    WSPR_CHAR_TABLE_LETTERS_SPACE,
    WSPR_CHAR_TABLE_LETTERS_SPACE
]

До 3-го символа префикс длиной от двух до трех символов, суффикс позывного до трех символов. Символ "пробел" используется для выравнивания, если длина позывного меньше ожидаемой.

Перевод позывного из текстовой формы в упакованную и наоборот, реализуется классом WSPRCallsign:

class WSPRCallsign(MsgItem):
    @classmethod
    def _validate_str(cls, val: str) -> bool:
        return len(val) <= 6

    @classmethod
    def _validate_int(cls, val: int) -> bool:
        return val < 262177560

    def to_int(self) -> int:
        return hash(self)

    def to_str(self) -> str:
        return ct_map_decode(WSPR_BASECALL_CHAR_MAP, self.val_int).strip()

    @staticmethod
    def _normalize_cs(cs: str) -> str:
        return " " * (6 - len(cs)) + cs

    def __hash__(self):
        cs_norm = self._normalize_cs(self.val_str)
        return ct_map_encode(WSPR_BASECALL_CHAR_MAP, cs_norm)

Так, после кодирования, позывной R9FEU будет иметь значение 260587010 (0b1111100010000011111000000010 в двоичном представлении).

Кодек локатора

В отличие от FT8, в WSPR локатор кодируется как номер квадрата на сетке размером 180*180 (по 18 буквенных квадратов в каждом из которых по 10 цифровых квадратов на сторону, рисунки 3 и 4).

Рисунок 3: Распределение буквенных квадратов на карте мира.
Рисунок 3: Распределение буквенных квадратов на карте мира.
Рисунок 4: Распределение числовых квадратов внутри буквенных.
Рисунок 4: Распределение числовых квадратов внутри буквенных.
class WSPRGrid(MsgItem):
    @classmethod
    def _validate_str(cls, val: str) -> bool:
       return len(val) == 4 and ct_validate_map(WSPR_GRID_CHAR_MAP, val)

    @classmethod
    def _validate_int(cls, val: int) -> bool:
        return val < 32400

    def to_int(self) -> int:
        loc = self.val_str

        ct_num = WSPR_CHAR_TABLE_NUMERIC
        ct_char = WSPR_CHAR_TABLE_LETTERS

        val = (179 - 10 * nchar(loc[0], ct_char) - nchar(loc[2], ct_num)) * 180
        val += nchar(loc[1], ct_char) * 10
        val += nchar(loc[3], ct_num)
        return val

    def to_str(self) -> str:
        loc = self.val_int

        latitude = (loc % 180) - 90
        longitude = (loc // 180) * 2 - 178
        longitude = (longitude + 180) % 360 - 180

        lat = int(24.0 * (latitude + 90))
        lon = int(12.0 * (180.0 - longitude))

        lo1, lo2 = divmod(lon, 240)
        la1, la2 = divmod(lat, 240)

        ct_num = WSPR_CHAR_TABLE_NUMERIC
        ct_char = WSPR_CHAR_TABLE_LETTERS

        val = charn(lo1, ct_char)
        val += charn(la1, ct_char)
        val += charn(lo2 // 24, ct_num)
        val += charn(la2 // 24, ct_num)
        return val

Так, квадрат LO87 (квадрат с углом в точке с координатами 57°30'00.0"N 57°00'00.0"E) будет представлен в виде значения 11127.

Кодек мощности

Значение мощности записывается в виде целого числа, соответствующего dBm (децибел на милливатт); так как соотношение мощностей в 2 раза соответствует 3 децибелам, значение мощности в WSPR кратно 3; таким образом значение в 57 dBm соответствует мощности передатчика в 100 ватт, а 60 dBm — 1000 ватт.

В WSPR промежуточные значения округляются в ближайшую к кратной мощности сторону:

@staticmethod
def _normalize_dBm(dBm: int) -> int:
    corr = [0, -1, 1, 0, -1, 2, 1, 0, -1, 1]
    if dBm < 0:
        return 0
    elif dBm > 60:
        return 60

    return dBm + corr[dBm % 10]

Минимальное и максимальное значения мощности передатчика можно задать 1 милливатт (0 dBm) и 1000 ватт (60 dBm) соответственно.

Кодек сообщения

Класс WSPRMessage реализует кодек WSPR-сообщения.

class WSPRMessage:
    __slots__ = ("callsign", "loc", "dBm")

    @staticmethod
    def _normalize_dBm(dBm: int) -> int:
        ...

    def __str__(self):
        return f"{self.callsign} {self.loc} {self.dBm}"

    def encode(self, **kwargs) -> typing.ByteString:
        add = 0
        pref = self.callsign.as_int
        suff = (self.loc.as_int << 7) | (self.dBm + 64 + add)

        items = [
            byte(pref >> 20),
            byte(pref >> 12),
            byte(pref >> 4),
            byte(((pref & 0x0f) << 4) | ((suff >> 18) & 0x0f)),
            byte(suff >> 10),
            byte(suff >> 2),
            byte((suff & 0x03) << 6),
            0,
            0,
            0,
            0,
        ]
        return bytearray(b for b in items)

    @classmethod
    def decode(cls, payload: typing.ByteString, **kwargs) -> AbstractMessage:
        b = payload[0]
        pref = b << 20

        b = payload[1]
        pref += b << 12

        b = payload[2]
        pref += b << 4

        b = payload[3]
        pref += (b >> 4) & 15

        suff = (b & 15) << 18

        b = payload[4]
        suff += b << 10

        b = payload[5]
        suff += b << 2

        b = payload[6]
        suff += (b >> 6) & 3

        cs = WSPRCallsign(pref)
        grid = WSPRGrid(suff >> 7)
        dbm = (suff & 0x7f) - 64

        return cls(cs, grid, dbm)

Функция encode конкатенирует в массив байт числовые представления позывного, локатора и мощности.

msg = WSPRMessage(WSPRCallsign("R9FEU"), WSPRGrid("LO87"), 50)
payload = msg.encode()

Так, сообщение "R9FEU LO87 50" будет представлять собой запись:

R9FEU: 260587010 (0b1111100010000011111000000010)

LO87: 11127 (0b10101101110111)

50: 114 (0b1110010)

Результат: f8 83 e0 25 6e fc 80 00 00 00 00.

Статичный метод decode реализует обратную задачу, возвращая, как результат, объект WSPRMessage с данными сообщения.

Транспортный уровень

На данном уровне данные сообщения подвергаются помехоустойчивому кодированию сверточным кодом и интерливингу получаемых символов.

Сверточный код

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

В отличие от протоколов семейства FTX, в которых применяется LDPC-код, в WSPR используется более простой метод — сверточный код (convolutional code).

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

Математически, сверточный код описывается как линейный код с памятью над конечным полем \text{GF}(q) (где q=p^n, p  — простое число, n — натуральное; в случае с битами q=2), в котором кодовая последовательность v формируется как дискретная свертка информационной последовательности u с набором векторно-значных коэффициентов \mathbf{G} (порождающие полиномы).

\mathbf{v}_t = \sum_{\ell = 0}^{m} \mathbf{G}_\ell \, \mathbf{u}_{t - \ell}

В каждый момент времени t кодовый вектор \mathbf{v}_t выражается линейной комбинацией текущего и m предыдущих информационных векторов \mathbf{u}_{t}, \mathbf{u}_{t-1}, \dotsc, \mathbf{u}_{t-m} с коэффициентами из \text{GF}(q)

Это задает линейные рекуррентные соотношения и переводит код во множество полубесконечных последовательностей, образующих аддитивную подгруппу в \text{GF}(q)^\mathbb{N}. Аддитивная группа \text{GF}(q) относительно операции сложения по модулю p изоморфна конечной абелевой группе \mathbb{Z}_p^n, из чего следует, что вся кодовая последовательность может рассматриваться как элемент бесконечного прямого произведения таких аддитивных групп.

При фиксированной длине последовательности \mathbb{N} код можно рассматривать как линейное пространство в \text{GF}(q)^{n\mathbb{N}}, структуру которого можно интерпретировать как конечную абелеву группу относительно операции сложения по компонентам.

Отношение числа информационных символов на входе кодера к числу кодовых символов на выходе за один такт определяет скорость кода: R = {k}/{n} (где k — число информационных символов, n — число кодовых, при этом 0 < R \le 1), эта величина следует из размера линейного преобразования \mathbf{u}_t \in \text{GF}(q)^k \mapsto \mathbf{v}_t \in \text{GF}(q)^n, и определяет степень  избыточности и предельную плотность информации в кодовой последовательности.

Для двоичных данных конечное поле \text{GF}(2) = \{0, 1\} процесс кодирования заключается в том, что в каждый момент кодирования бита входных данных осуществляется изменение состояния регистров путем сдвига предыдущего значения; значения выходных бит рассчитываются путем суммирования по модулю 2 (оператор XOR) текущего и предыдущего состояний регистров с порождающими полиномами.

Графически описанный алгоритм представлен на рисунке 5, где блоки n, n-1, n-2 — значения в сдвиговых регистрах, а сумматор — сложение по модулю 2 (XOR).

Рисунок 5: Упрощенное представление сверточного кодера со скоростью ½.
Рисунок 5: Упрощенное представление сверточного кодера со скоростью ½.

Примечание: сверточное кодирование использовался NASA для коррекции ошибок в каналах связи с Voyager-1/2.

В протоколе WSPR используется сверточный код со скоростью R=½ и полиномами Лейланда-Лашбо (Layland-Lushbaugh) — 0xf2d05351 и 0xe4613c47.

Примечание: это те самые полиномы, которые применялись в Deep Space Network для обеспечения надежной связи с космическими аппаратами в дальнем космосе, в миссиях Mariner и Pioneer. В миссиях Helios и более поздних Pioneer NASA применяли полиномы со значениями 0xbbef6bb7, 0xbbef6bb5. Они были менее надежнее полиномов Лейланда-Лашбо, но позволяли декодировать сигнал намного быстрее, в том числе в полуручном режиме, что было критически важным фактором при получении данных телеметрии.

WSPR_CONV_SYMBOLS = 176
WSPR_CONV_POLY = [0xf2d05351, 0xe4613c47]


def convolutional_encode(payload: typing.ByteString) -> npt.NDArray:
    k = 0
    state = 0
    symbols = np.zeros(WSPR_CONV_SYMBOLS, dtype=np.uint8)

    for p in payload:
        for i in range(7, -1, -1):
            state = (state << 1) | ((p >> i) & 1)
            for poly in WSPR_CONV_POLY:
                n = state & poly
                even = 0
                while n:
                    even = 1 - even
                    n &= n - 1

                symbols[k] = even
                k += 1

    return symbols

Импульсная характеристика кодера на цифровую дельта-функцию (отклик на единичный отсчет):

convolutional_encode(b"\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00")

Результат: 1, 1, 0, 1, 0, 1, 0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 1, 0, 1, 0, 0, 1, 0, 1, 1, 1, 0, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0

То есть первый бит оказал влияние на результирующие биты в позициях: 1, 3, 5, 8, 12, 13, 16, 18, 21, 23, 24, 25, 27, 28, 33, 40, 43, 44, 45, 46, 50, 53, 56, 58, 59, 60, 61, 62, 63.

Сообщение "R9FEU LO87 50" (8 83 e0 25 6e fc 80 00 00 00 00) после обработки алгоритмом сверточного кодирования принимает вид: 1 1 1 0 1 1 1 1 0 1 1 0 0 0 0 1 0 0 1 0 1 1 0 0 0 1 0 0 0 0 1 1 0 0 1 1 1 1 1 0 0 0 1 1 0 1 0 1 0 1 1 0 1 0 0 0 1 0 0 1 1 0 1 1 0 0 0 0 1 0 1 1 0 1 0 1 0 1 0 0 0 0 0 1 1 0 0 0 1 0 0 1 0 1 1 0 1 0 0 1 1 1 1 1 0 0 1 0 0 1 0 0 0 0 1 0 1 0 0 0 0 0 1 0 1 1 0 1 0 0 1 0 0 1 0 1 0 1 0 0 1 0 1 0 0 1 1 1 1 0 0 0 0 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.

Интерливинг и сигнал синхронизации

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

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

Интерливинг (перемешивание/перемежение) — процесс перераспределения бит данных в сообщении таким образом, что подряд идущие биты данных разносятся на некоторое расстояние друг от друга. На передающей стороне происходит перемешивание бит данных перед отправкой; на принимающей стороне принятые биты подвергаются обратной перестановке — деинтерливингу, восстанавливая исходную последовательность бит. Таким образом, при после деинтерливигна, пачка ошибок в подряд идущих битах будет распределена в виде ошибок в нескольких случайных местах, что позволит кодам коррекции ошибок с большей долей вероятности скорректировать данные.

Примечание: интерливинг также применяется в протоколе FT4.

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

WSPR_ND = 162
WSPR_PR3 = np.array([
    1, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1, 0, 0, 0, 1, 0,
    0, 1, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 1,
    0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1, 0, 0, 0, 1,
    1, 0, 1, 0, 0, 0, 0, 1, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 0, 1,
    0, 0, 1, 0, 1, 1, 0, 0, 0, 1, 1, 0, 1, 0, 1, 0, 0, 0, 1, 0,
    0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 1, 1, 0, 1, 1, 0, 0, 1, 1,
    0, 1, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1, 1,
    0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 1, 1, 0, 0, 0, 1, 1, 0,
    0, 0
])

Вектор синхронизации WSPR_PR3 представляет собой массив однобитных значений, равный по длине количеству тонов пакета данных (WSPR_ND). Синхронизирующий вектор формирует выходную последовательность тонов таким образом, что биты данных, участвующих в синхронизации, всегда будут находиться в одном и том же диапазоне тонов. Так как синхронизирующий вектор одинаков на принимающей и передающей стороне, декодер осуществляет поиск и оценку сигнала, используя вектор синхронизации как область частот для двух тонов, в пределах которой обязательно должен располагаться бит данных.

def wspr_encode(payload: typing.ByteString) -> typing.Iterator[int]:
    symbols = convolutional_encode(payload)

    tones = np.zeros(WSPR_ND, dtype=np.uint8)
    for i in range(WSPR_ND):
        p = -1
        k = 0
        j0 = 0
        while p != i:
            for j in range(8):
                j0 = (((k >> j) & 1) | (j0 << 1)) & 0xff

            if j0 < WSPR_ND:
                p += 1

            k += 1

        tones[j0] = WSPR_PR3[j0] | symbols[i] << 1

    for tone in tones:
        yield tone

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

Так, сообщение "R9FEU LO87 50" будет представлено в виде 162 тонов, индексы которых: 3 1 0 2 2 2 0 2 1 0 0 0 3 1 3 0 2 0 1 2 0 1 0 3 1 1 3 2 2 0 0 2 0 2 1 2 0 1 0 3 0 0 0 2 2 0 1 2 3 1 2 2 1 1 0 1 0 2 2 3 1 0 1 0 2 2 2 3 1 0 3 0 1 2 1 0 1 2 2 3 0 0 1 0 1 3 2 0 2 3 1 0 3 0 1 2 2 0 3 0 2 2 0 2 3 2 0 1 2 2 3 3 3 2 3 1 2 0 3 1 2 3 2 0 0 1 1 1 2 0 2 0 0 3 2 1 0 2 3 3 0 0 0 2 2 2 2 1 1 0 1 2 1 1 0 0 2 1 3 2 2 0.

Канальный уровень

На канальном уровне происходит синтезирование сигнала, на основе данных, сформированных на предыдущем уровне. В результате формируется дискретный сигнал, готовый для передачи по аналоговому (звуковому) каналу связи.

4-FSK манипуляция

Для формирования сигнала, протокол WSPR использует частотную манипуляцию с когерентной фазой, как и в протоколах семейства FTX и Q65.

def synth_fsk(
       tones: npt.NDArray[np.uint8], sample_rate: int,
       samples_per_symbol: int,
       f0: float, bandwidth: float) -> npt.NDArray[np.float64]:
    dt = 1.0 / sample_rate

    phase = 0.0

    signal = np.zeros(tones.shape[0] * samples_per_symbol, dtype=np.float64)
    for i, tone in np.ndenumerate(tones):
        idx, *_ = i

        freq = f0 + bandwidth * tone
        phase_delta = 2 * np.pi * freq * dt

        phases = np.fromiter(
            (np.fmod(phase_delta * i + phase, 2 * np.pi)
             for i in range(samples_per_symbol)),
            dtype=np.float64
        )

        t_start = idx * samples_per_symbol
        t_end = t_start + samples_per_symbol

        signal[t_start:t_end] = np.sin(phases)

        phase = np.fmod(phase_delta * samples_per_symbol + phase, 2 * np.pi)

    return signal

Примечание: функция synth_fsk подрлбно рассматривалась в статье, посвященной Q65. Входные параметры: перечень тонов tones, частота дискретизации итогового сигнала sample_rate, количество сэмплов на символ samples_per_symbol, базовая частота нулевого тона f0 и ширина полосы bandwidth. Результат — numpy массив, содержащий дискретный сигнал со значениями от -1 до 1.

Итоговый код генератора сигнала WSPR, включающий в себя все вышеописанные функции:

import typing

import numpy as np
import numpy.typing as npt

from scipy.io.wavfile import write

from consts.wspr import *
from encoders import wspr_encode
from mod import synth_fsk
from msg.message import *


def gen_signal(tones: npt.NDArray[np.uint8], frequency: int, sample_rate: int) -> npt.NDArray[np.float64]:
    samples_per_symbol = int(sample_rate * WSPR_SYMBOL_PERIOD)

    bandwidth = sample_rate / samples_per_symbol

    signal = np.fromiter(
        synth_fsk(tones, sample_rate, samples_per_symbol, frequency, bandwidth),
        dtype=np.float64
    )

    return signal


def gen_wspr_tones(payload: typing.ByteString) -> npt.NDArray[np.uint8]:
    tones = wspr_encode(payload)
    tones = np.fromiter(tones, dtype=np.uint8)

    return tones


def main():
    sample_rate = 12000

    msg = WSPRMessage(WSPRCallsign("R9FEU"), WSPRGrid("LO87"), 50)
    payload = msg.encode()

    tones = gen_wspr_tones(payload)

    signal = gen_signal(tones, frequency=1500, sample_rate=sample_rate)

    amplitude = np.iinfo(np.int16).max
    data = signal * amplitude
    write("examples/signal.wav", sample_rate, data.astype(np.int16))


if __name__ == '__main__':
    main()

После запуска кода, сформированный и готовый к передаче в эфир, сигнал с данными записывается в wav-файл с именем signal.wav с частотой дискретизации 12 КГц.

Значение bandwidth рассчитывается как отношение частоты дискретизации к количеству семплов на символ, что составляет 1.46484375 Гц, из чего следует, что общая ширина полосы частот WSPR равняется 5.859375 Гц.

На рисунке 6 представлена спектрограмма сформированного сигнала. 

Рисунок 6: Спектр сигнала WSPR.
Рисунок 6: Спектр сигнала WSPR.

Передача сигнала в эфир

Для передачи в эфир использовался передатчик с выходной мощностью 100 Вт, в режиме однополосной модуляции с верхней боковой полосой на частоте 14.0956 МГц. В качестве антенны использовалась дипольная антенна на диапазон 20м. Передача осуществлялась в темное время суток в 18:16 UTC.

После передачи сигнала на интерактивной карте wspr-агрегатора можно посмотреть где и когда переданный сигнал был зафиксирован (рисунок 7).

Рисунок 7: Карта приемников, которые зафиксировали передаваемый сигнал.
Рисунок 7: Карта приемников, которые зафиксировали передаваемый сигнал.
Рисунок 8: Карта Европы более детально.
Рисунок 8: Карта Европы более детально.

Из рисунка 7 видно, что в основном сигнал был принят в Европе (рисунок 8), так как там сосредоточено больше всего онлайн-репортеров, а также по причине направленности антенны в сторону запада. Самые удаленные приемники, принявшие сигнал, находились в австралии, антарктиде и близ острова Мадагаскар.

Дополнительно удалось принять из эфира и декодировать сигналы активных в это время маяков, самый дальний из которых (EB1A) был расположен в испании на расстоянии 4662 км.

Журнал принятых сигналов
------------------------------------------------------------------- 20m
1814  -17   0.8   14.097024    0   G8MCD         IO91     23   3635
1814  -14  -0.2   14.097039    0   <...>         JO23VF   23   3145
1814  -20  -0.2   14.097059    0   PH9K          JO33     27   3056
1814  -24  -0.2   14.097088   -1   PE2NAN        JO21     23   3288
1814  -21  -0.5   14.097095    0   MW0XMG        IO81     23   3750
1814  -17  -0.7   14.097096    0   EB1A          IN53     27   4662
1814  -17  -0.1   14.097101    0   SM4HVP        JP70     37   2327
1814  -26   1.6   14.097135    0   DL6NL         JO50     33   2996
1814  -17  -0.4   14.097166    0   OH6CR         KP12     23   1882
1814  -17  -0.5   14.097198    0   PA2PGU        JO32     23   3112
------------------------------------------------------------------- 20m
1818  -25  -0.0   14.097011    0   PA0PPW        JO21     23   3288
1818  -22  -0.4   14.097019    0   SA0ORR        JO89     23   2241
1818  -14   0.8   14.097024    0   G8MCD         IO91     23   3635
1818  -13  -0.2   14.097042    0   DJ8XD         JO52     37   2879
1818  -26  -0.0   14.097082    0   PD0PF         JO22     23   3229
1818  -23  -0.4   14.097091    0   MI9PYZ        IO65     23   3732
1818  -21   3.1   14.097096    0   GI3VAF        IO74     37   3680
1818  -18   0.4   14.097109   -1   DC6ZK         JO52     30   2879
1818  -25   0.9   14.097118    0   PE1JXI        JO20     10   3349
1818  -20  -0.0   14.097129    0   DC1SAF        JN48     33   3243
1818  -20   1.7   14.097134    0   DL6NL         JO50     33   2996
1818  -20   1.8   14.097158    0   2E0DLC        IO93     23   3514
1818  -15   0.1   14.097177    0   G4HSB         IO94     23   3457
1818  -29  -0.0   14.097187    0   PD1RE         JO22     23   3229
------------------------------------------------------------------- 20m
1822  -12   0.8   14.097024    0   G8MCD         IO91     23   3635
1822  -19  -0.2   14.097050    0   OZ7IT         JO65     37   2610
1822  -21   5.3   14.097075    0   SM7WDL        JO76     23   2452
1822  -14   0.1   14.097092    0   MW0XMG        IO81     23   3750
1822  -17   1.6   14.097114    0   SP9MLI        JO94     30   2306
1822  -22   0.3   14.097119    0   PE1JXI        JO20     10   3349
1822  -21   1.6   14.097134    0   DL6NL         JO50     33   2996
1822  -18  -0.1   14.097157    0   G4GVZ         IO81     37   3750
1822  -25   1.8   14.097159    0   2E0DLC        IO93     23   3514
1822  -18   0.5   14.097175    0   G6WZA         IO80     30   3814
1822  -24  -0.1   14.097189    0   <...>         JN48AO   37   3297
1822  -15  -0.2   14.097195    0   G6GN          IO81     30   3750
------------------------------------------------------------------- 20m
1824  -26  -0.4   14.096999    0   DL3NAA        JN38     23   3361
1824  -22  -0.2   14.097015    0   G4GOC         IO92     23   3574
1824  -16  -0.1   14.097029    0   PA7D          JO23     23   3172
1824  -16  -0.0   14.097048    0   MW0XMG        IO81     23   3750
1824  -23  -0.3   14.097064    0   SM3MTQ        JP81     23   2202
1824  -19   5.1   14.097075    0   SM7WDL        JO76     23   2452
1824  -16   0.5   14.097084    0   PA2W          JO22     37   3229
1824  -18   2.5   14.097089    0   <...>         JO22RD   33   3226
1824  -23  -0.1   14.097101    0   SM4HVP        JP70     37   2327
1824  -20   1.0   14.097119    0   PE1JXI        JO20     10   3349
1824  -24  -0.4   14.097128    0   <...>         JN58UA   23   3119
1824  -15   0.1   14.097129    0   G4HSB         IO94     23   3457
1824  -26   0.1   14.097139    0   PD0PF         JO22     23   3229
1824  -25   1.9   14.097159    0   2E0DLC        IO93     23   3514
1824  -16  -0.1   14.097173    0   G0VOK         IO83     37   3627
1824  -26  -0.4   14.097182    0   G4UGD         IO83     23   3627
1824  -19  -0.1   14.097206    0   M0XDC         JO01     37   3520
------------------------------------------------------------------- 20m
1826  -22   5.1   14.096995    0   SM7WDL        JO76     23   2452
1826  -12  -0.9   14.097019    0   G4GCI         IO90     37   3698
1826  -17  -0.1   14.097022    0   MW0XMG        IO81     23   3750
1826  -11   0.9   14.097024    0   G8MCD         IO91     23   3635
1826  -16  -0.3   14.097029    0   <PA7D>        JO23VF   23   3145
1826  -17  -0.3   14.097032    0   GM8HHC        IO85     23   3513
1826  -24  -0.3   14.097047    0   M8HYX         IO84     23   3569
1826  -24  -0.0   14.097063    0   PA0PPW        JO21     23   3288
1826  -20   0.2   14.097096    0   EB1A          IN53     27   4662
1826  -20   0.4   14.097110   -1   DC6ZK         JO52     30   2879
1826  -22   0.8   14.097119    0   PE1JXI        JO20     10   3349
1826  -27  -0.9   14.097139    0   G2PLX         JO02     20   3459
1826  -19  -0.2   14.097159   -3   OH3HE         KP32     23   1676
1826  -23  -0.5   14.097175    0   PA2PGU        JO32     23   3112
1826  -20  -0.4   14.097185    0   DK3TG         JN58     23   3125
------------------------------------------------------------------- 20m
1828  -18  -0.2   14.097006    0   MW0XMG        IO81     23   3750
1828   -8  -0.2   14.097007    0   OH6CR         KP12     23   1882
1828  -19  -0.0   14.097050    0   DL4BG         JO43     30   2941
1828  -20  -0.3   14.097080    0   SM2OTU        KP16     23   1936
1828  -18  -0.2   14.097087    0   G0IDE         IO83     37   3627
1828  -19   1.5   14.097114    0   SP9MLI        JO94     30   2306
1828  -20   0.3   14.097119    0   PE1JXI        JO20     10   3349
1828  -22  -0.4   14.097131    0   PC4T          JO22     23   3229
1828  -15  -0.3   14.097132    0   DL1FX         JN49     23   3177
1828  -16   1.7   14.097135    0   DL6NL         JO50     33   2996
1828  -23  -0.4   14.097148    0   SA3LEN        JP80     23   2219
1828  -21   5.0   14.097154    0   SM7WDL        JO76     23   2452
1828  -20  -0.1   14.097173    0   G0VOK         IO83     37   3627

Заключение

В статье был проведен обзор, анализ и принцип работы протокола WSPR. Протокол представляет интерес тем, что обладая низкой скоростью и узкой полосой частот, позволяет осуществлять передачи сигналов, используя передатчики с очень малыми мощностями; а также представляет интерес как инструмент изучения состояния ионосферы и прохождения радиосигналов в ней. WSPR-приемники расположены по всему миру, что также позволяет использовать протокол для тестирования радиолюбительских антенн и передатчиков, прогнозировать зоны для проведения радиолюбительских связей.

Ссылки