Back to Home

WSPR protocol: Python encoding of callsigns

WSPR is a protocol for analyzing the propagation of weak HF signals using a distributed network of beacons. The structure is described: 4-FSK, 50-bit messages, Python implementation of codecs for callsigns and locators. Examples of classes MsgItem and WSPRCallsign.

WSPR on Python: encoding ionospheric beacons
Advertisement 728x90

WSPR Protocol Implementation in Python: Encoding Examples

The WSPR protocol uses 4-FSK modulation to transmit data below the noise floor. Each transmission cycle lasts 110.59 seconds, occupies a 5.85 Hz bandwidth, and packs the message into 50 bits using convolutional coding (rate ½). Transmitters activate at the start of every even-numbered minute—within ±1 second—and randomly select a frequency within a 200 Hz band.

Data includes the station’s callsign, a 4-character QTH locator, and transmit power in dBm. Receivers decode signals, generate reports, and upload them to wsprnet.org during the remaining 10 seconds of the transmission slot.

Physics of HF Propagation

The HF band (3–30 MHz) relies on the ionosphere as a waveguide. Signals refract at altitudes between 140–1000 km: some escape into space, others reflect back to Earth. Multiple “hops” enable global coverage.

Google AdInline article slot

Ionospheric conditions vary with solar activity, time of day, and season:

  • 80 m: best for nighttime communication;
  • 40 m: long-range at night, short-range by day;
  • 20 m: reliable long-distance during daylight;
  • 10 m: opens only near peak solar activity.

Key nuance: direct short-range paths are often impractical due to steep reflection angles.

Protocol Layering Architecture

WSPR structures processing across three layers:

Google AdInline article slot
  • Application layer: callsign, locator, power;
  • Presentation layer: packing into 50 bits using custom character tables;
  • Link layer: 4-FSK modulation with synchronization and error correction.

Transmissions are synchronized to UTC; beacons operate pseudo-randomly—up to 7 slots per hour.

Presentation Layer: Character Tables and Codecs

Data is encoded as indices into predefined tables:

import string

CHAR_TABLE_NUMERIC = string.digits
CHAR_TABLE_LETTERS = string.ascii_uppercase
WSPR_CHAR_TABLE_ALPHANUM = f'{CHAR_TABLE_NUMERIC}{CHAR_TABLE_LETTERS}'
WSPR_CHAR_TABLE_ALPHANUM_SPACE = f'{CHAR_TABLE_NUMERIC}{CHAR_TABLE_LETTERS} '

Conversion helper functions:

Google AdInline article slot
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

Abstract Interface: MsgItem

Base class for message components:

from abc import ABCMeta, abstractmethod
import typing

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

Callsign Encoding

Structure: six positions, each mapped to a distinct alphabet:

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
]

WSPRCallsign class:

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)

Example: R9FEU260587010 (binary: 0b1111100010000011111000000010).

QTH Locator Encoding

The locator is a 4-character string (2 letters + 2 digits), representing an 180×180 km grid square. Its encoding maps to a global geodetic grid.

Key takeaways:

  • WSPR detects signals below the noise floor thanks to 4-FSK and robust error correction;
  • UTC-synchronized 2-minute slots prevent transmission collisions;
  • The 50-bit payload breakdown: callsign (22 bits), locator (15 bits), power (7 bits), message type (1 bit), CRC (4 bits);
  • Beacons and reporters collectively build a real-time global propagation map;
  • A Python implementation enables local, offline testing and validation of encoding logic.

— Editorial Team

Advertisement 728x90

Read Next