Listen to FT8: fifteen seconds of everybody at once

A new section, alongside the aircraft, the weather sensors and APRS.

FT8 is the odd one out among the things this program listens to, and the
reason is worth stating because it shapes everything below.  Every station on
the band transmits in the same quarter-minute slots, on the same dial
frequency, fifty hertz wide each, stacked across three kilohertz of audio.
One receiver parked on one frequency therefore hears the whole band's worth of
stations at once -- and hears most of them well below the noise, because half
of what is sent is error-correcting code.  That is the entire trick: a rate of
about one half buys a mode that decodes twenty-odd decibels under what an
operator can hear.  A receiver that took the loudest tone of each symbol and
hoped would decode almost nothing, which is why the tone detector reports how
confident it is bit by bit rather than what it thinks it heard.

Written from first principles except for two tables.  The checksum, the
belief propagation over the sparse graph, the Costas sync search, the
waterfall, the soft-bit metric, and the seventy-seven bits that hold two
callsigns and a grid square are all here.  The generator and the parity-check
matrix are not: they cannot be derived, being the code itself rather than
consequences of anything, so they are taken from ft8_lib under its MIT licence
with the attribution it asks for, and said so in the readme, the manual and
the file.  No decoding logic came with them.  That the two agree -- and they
are not derivable from one another, the generator's parity half running to
fifty-odd bits a row against the sparse matrix's six or seven -- is a test
rather than an assumption.

Tested against the air, not against itself.  Eleven off-air recordings with
published decodes: ninety-seven of a hundred and fifty messages, no false
decodes, timing within a hundredth of a second, frequency within a hertz,
signal reports within half a decibel on average.  The third not decoded are
the weakest in each slot; a mature decoder subtracts what it has decoded and
looks again in the remainder, and does ordered-statistics decoding where
belief propagation fails, and neither is built here.  What is here decodes
nothing that other receivers did not also hear, which is the property that
matters in a log.  Ten whole codewords lifted off the air are in the tests as
a permanent fixture, so the recordings can go missing and the regression
cannot.

Three things that looked like bugs and were not, and three that were.  The
half-second timing discrepancy was the convention: a transmission is 12.64
seconds in a slot of fifteen and everybody starts half a second in, so
lateness is reported against that.  Synthetic signals at known offsets proved
the clock self-consistent before anything was changed.  The signal reports
were twenty-one decibels optimistic because those recordings have a receiver
passband above three kilohertz, putting a whole-band median twelve to sixteen
decibels below the real noise floor -- so noise is now measured beside the
signal, and in the tone that was actually sent rather than the loudest of
eight, the largest of eight noisy numbers being well above their mean even
with no signal at all.  And the test transmitter was thirteen decibels
pessimistic, scaling its noise into a fifty-hertz reference instead of the
sampled bandwidth, which made the decoder look deaf when it was the test
signal that had been quietly attenuated.

The real bug the simulator caught was a one-block timestamp error: samples
were dated a block earlier than they were taken, which slid every slot slice a
second late and cut the first half-second -- three symbols, part of the
opening Costas array -- off every transmission on the band.  One decode a slot
became six.

Reachable both ways, as everything here is.  Twenty-one options, every one of
them a command-line flag and a line in the menu, both built from one table so
they cannot disagree -- and a test that says so, since an option in no group
would be settable from the command line and invisible in the menu.  The band
list says which channels a plain dongle can reach and which need an
upconverter, because almost all the activity is on shortwave and finding that
out by listening to silence for ten minutes is the wrong way to learn it.  The
default is two metres, which a plain dongle can hear.

--grid turns decodes into geography: every CQ says where it is, so each gets a
distance and a bearing and the furthest is named.  --adif writes the log in
the form every amateur logging program imports, marked as heard rather than
worked, because nothing here transmits and an ADIF that let a logging program
treat these as contacts would put claims into somebody's log that they cannot
make.

One bug shipped and found by being used rather than by being tested: the
line that opens the receiver called a function this program has never had.
Every test reached it through the simulator, which takes the other branch, so
the one line that matters to somebody with an aerial was the one line never
run.  There is now a check that every name these modules import actually
exists -- it names the missing one rather than failing somewhere downstream --
and two that say a receiver which cannot be opened is reported rather than
raised, and that nothing claims to be listening before there is one.  It had
been announcing the frequency first, so a dongle that would not open read as
listening that had gone wrong.

Ninety-six new tests.  Full suite 2781 passed.  Built as 2026-09-21_04.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
This commit is contained in:
The Dust Council 2026-09-21 16:09:57 -07:00
parent e203b3e581
commit 93120b80a6
14 changed files with 4247 additions and 3 deletions

105
README.md
View file

@ -2630,6 +2630,93 @@ dongles ship with; the stock telescopic one extended properly does well.
`--packets` shows each frame as it arrives, which is the thing to watch while `--packets` shows each frame as it arrives, which is the thing to watch while
moving an aerial about. moving an aerial about.
## FT8
Fifteen seconds of everybody at once.
```
bandsaunter ft8 --band 20m --grid IO91
```
Every station on the band transmits in the same quarter-minute slots, on the
same dial frequency, fifty hertz wide each, stacked across three kilohertz of
audio. So one receiver parked on one frequency hears the whole band's worth
of stations simultaneously — and hears most of them **well below the noise**.
```
FT8 20m — 14.074 MHz 12 slots 187 decodes 63 stations 15.6 a slot 3:00 from IO91
last slot — 16 decoded
snr dt hz message
-19 -0.5 309 G4CUS SP4FCA RRR
-6 0.1 528 VK3EVE SQ3MZM RR73
18 0.1 1110 9A9TT IK4LZH -10
6 0.0 2535 CQ IZ3XJM JN55
heard so far — 63 stations
station grid away n best hz last
IZ3XJM JN55 1180 km 4 6 2535 2s
IK4LZH JN54 1140 km 7 18 1110 2s
```
Half of what is transmitted is error-correcting code, and that is the whole
trick: it is what buys a mode that decodes twenty-odd decibels under what an
operator can hear. A receiver that took the loudest tone of each symbol and
hoped would decode almost nothing.
### What it needs
**The clock has to be right**, to a second or two. The slots are
quarter-minutes of UTC and every station on earth agrees about which one it
is. A receiver a second out still decodes; one a slot out hears every
transmission split across two captures and decodes none of them. This is the
one failure that looks exactly like a dead band, so the display and the
report both say so when nothing arrives.
**Almost all the activity is on shortwave**, which a plain dongle cannot
reach. The default here is therefore the two-metre channel at 144.174 MHz,
which it can. All thirteen channels are in the list and the shortwave ones
work perfectly well through an upconverter or a receiver in direct-sampling
mode — the menu says which is which rather than leaving you to find out by
listening to silence.
### What comes out
`--grid IO91` is what turns decodes into geography: every station calling CQ
says where it is, so with your own square filled in the report gives each one
a distance and a bearing, and names the furthest heard. On shortwave that is
the entire interest of the thing.
Three tables afterwards: **stations heard** with grid, distance, count and
best and worst report; **calling CQ**, which is who is available; and **who
was working whom**, which is the band as a social event rather than a list.
`--adif` writes the log again in the form every amateur logging program
imports — marked as *heard*, not worked. Nothing here transmits, so nothing
here is a contact, and an ADIF that let a logging program treat these as
worked would put claims into somebody's log they cannot make.
### How well it works
Checked against eleven off-air recordings with published decodes, which is
the only honest way to test a decoder — an encoder tested against its own
decoder agrees with it about anything they are both wrong about, a lesson
this project learned expensively on 433 MHz.
| | |
|---|---|
| messages decoded | 97 of 150 (65%), **no false decodes** |
| timing | +0.00 s, spread 0.04 |
| frequency | +0.1 Hz, spread 1.1 |
| signal report | −0.4 dB, spread 4.8 |
| weakest decoded | −24 dB on this program's own scale |
The 35% not decoded are the weakest signals in each slot. A mature decoder
subtracts each signal it decodes and looks again in what is left, and does
ordered-statistics decoding when belief propagation fails; neither is built
here. What *is* here decodes nothing that other receivers did not also hear,
which is the property that matters in a log.
## Meters on 900 MHz ## Meters on 900 MHz
A scan of 902–928 MHz that turns up a burst gets it named rather than reported A scan of 902–928 MHz that turns up a burst gets it named rather than reported
@ -3439,3 +3526,21 @@ by the Free Software Foundation. It is distributed in the hope that it will be
useful, but with no warranty whatsoever — not even the implied warranty of useful, but with no warranty whatsoever — not even the implied warranty of
merchantability or fitness for a particular purpose. The full text is in merchantability or fitness for a particular purpose. The full text is in
[LICENSE](LICENSE), and at <https://www.gnu.org/licenses/>. [LICENSE](LICENSE), and at <https://www.gnu.org/licenses/>.
### Borrowed material
One file is not original work. `bandsaunter/ft8tables.py` holds the two fixed
tables that *define* the FT8 error-correcting code — the generator and the
sparse parity-check matrix — taken from
[ft8_lib](https://github.com/kgoba/ft8_lib), MIT licensed, copyright © 2018
Kārlis Goba, which took them in turn from WSJT-X. They are reproduced under
the MIT terms, which permit it, and that file carries the attribution they
ask for.
They are there because they cannot be derived. Everything else about FT8 in
this program is worked out from first principles — the tones, the sync, the
parity arithmetic, the belief propagation, the way a callsign is squeezed
into twenty-eight bits — but those two tables are not derived from anything.
They are the code itself, chosen once by its designers and published, and a
receiver that guessed at them would be speaking a different protocol. No
decoding logic was taken.

View file

@ -9,7 +9,7 @@ and transcribing speech.
# 2026-08-21_02 is the second build made on the 21st. The revision is padded # 2026-08-21_02 is the second build made on the 21st. The revision is padded
# to two digits so versions sort as text. # to two digits so versions sort as text.
VERSION_DATE = "2026-09-21" VERSION_DATE = "2026-09-21"
VERSION_REVISION = 3 VERSION_REVISION = 4
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}" __version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"

View file

@ -26,6 +26,7 @@ from .device import RtlSdrError, list_devices, set_driver_messages
from .librtlsdr import load_error from .librtlsdr import load_error
from . import settings as st from . import settings as st
from .ax25 import APRS_CHANNELS as _APRS_CHANNELS from .ax25 import APRS_CHANNELS as _APRS_CHANNELS
from .ft8 import BANDS as _FT8_BANDS
from .ranges import (RangeError, ScanRange, build_plan, parse_range_list) from .ranges import (RangeError, ScanRange, build_plan, parse_range_list)
from .scanner import Scanner, ScannerCallbacks from .scanner import Scanner, ScannerCallbacks
from .tui import TUIAbort, first_run_setup, run_tui, settings_menu from .tui import TUIAbort, first_run_setup, run_tui, settings_menu
@ -639,6 +640,74 @@ examples:
a.add_argument("--freq", type=float, default=0.0, a.add_argument("--freq", type=float, default=0.0,
help="centre frequency in Hz, for band-aware naming") help="centre frequency in Hz, for band-aware naming")
a.add_argument("--morse", action="store_true", help="force a CW decode") a.add_argument("--morse", action="store_true", help="force a CW decode")
# -- ft8 ------------------------------------------------------------------
f8 = sub.add_parser("ft8",
help="listen to FT8: fifteen-second slots, forty "
"stations at once, most of them under the noise")
f8.add_argument("--device", type=int, default=None, help="which receiver")
f8.add_argument("--gain", default=None, help="tuner gain in dB, or auto")
f8.add_argument("--rate", type=float, default=None,
help="sample rate in Hz; 96 kS/s is the least that holds "
"three kilohertz of audio")
f8.add_argument("--band", default=None,
choices=[key for key, _hz, _n in _FT8_BANDS],
help="which FT8 channel to listen on; sets the frequency")
f8.add_argument("--frequency", "--freq", dest="frequency", type=float,
default=None, metavar="HZ",
help="the dial frequency, for an upconverter or a "
"channel the band list does not have")
f8.add_argument("--simulate", dest="simulate", action="store_true",
default=None, help="invent a band instead of using a "
"receiver")
f8.add_argument("--no-simulate", dest="simulate", action="store_false",
help="use the receiver (the default)")
f8.add_argument("--seconds", type=float, default=None,
help="stop after this long (default: until interrupted)")
f8.add_argument("--slots", type=int, default=None, metavar="N",
help="stop after this many fifteen-second slots")
f8.add_argument("--log", dest="log", action="store_true", default=None,
help="write a log of every decode (the default)")
f8.add_argument("--no-log", dest="log", action="store_false",
help="do not write a log")
f8.add_argument("--decodes-seen", dest="decodes_seen",
action="store_true", default=None,
help="print a line per decode instead of a live table")
f8.add_argument("--no-decodes-seen", dest="decodes_seen",
action="store_false", help="show the live table")
f8.add_argument("--hold", type=float, default=None, metavar="SECONDS",
help="how long a station stays on the display")
f8.add_argument("--lowest", type=float, default=None, metavar="HZ",
help="the lowest audio frequency to search")
f8.add_argument("--highest", type=float, default=None, metavar="HZ",
help="the highest audio frequency to search")
f8.add_argument("--most", type=int, default=None, metavar="N",
help="how many candidate transmissions to try per slot")
f8.add_argument("--rounds", type=int, default=None, metavar="N",
help="how many error-correction passes before giving up")
f8.add_argument("--grid", default=None, metavar="SQUARE",
help="your own grid square, so distances can be worked "
"out (four characters, like IO91)")
f8.add_argument("--units", default=None, choices=("metric", "imperial"),
help="which units to show distances in")
f8.add_argument("--calls-only", dest="calls_only", action="store_true",
default=None, help="leave out free text and telemetry")
f8.add_argument("--no-calls-only", dest="calls_only",
action="store_false", help="show everything decoded")
f8.add_argument("--report", dest="report", action="store_true",
default=None, help="print the tables at the end")
f8.add_argument("--no-report", dest="report", action="store_false",
help="do not print the tables")
f8.add_argument("--csv", dest="csv", action="store_true", default=None,
help="also write a spreadsheet of every decode")
f8.add_argument("--no-csv", dest="csv", action="store_false",
help="do not write a spreadsheet")
f8.add_argument("--adif", dest="adif", action="store_true", default=None,
help="also write an ADIF of stations heard, for a "
"logging program")
f8.add_argument("--no-adif", dest="adif", action="store_false",
help="do not write an ADIF")
return p return p
@ -2151,6 +2220,7 @@ def main(argv=None) -> int:
"flights": cmd_flights, "weather": cmd_weather, "flights": cmd_flights, "weather": cmd_weather,
"readings": cmd_readings, "sensors": cmd_sensors, "readings": cmd_readings, "sensors": cmd_sensors,
"aprs": cmd_aprs, "packets": cmd_packets, "aprs": cmd_aprs, "packets": cmd_packets,
"ft8": cmd_ft8,
} }
try: try:
return handlers[args.command](args) return handlers[args.command](args)
@ -2161,5 +2231,42 @@ def main(argv=None) -> int:
return 0 return 0
def cmd_ft8(args) -> int:
"""Park on an FT8 channel and decode every slot.
Fifteen seconds of everybody at once: one dial frequency carries the
whole band's worth of stations, fifty hertz apart across three
kilohertz of audio, and most of them arrive below the noise.
"""
from . import ft8
cfg, _ = load_default()
options = ft8.load_options()
for flag, key in (("device", "device"), ("gain", "gain"),
("rate", "rate"), ("band", "band"),
("frequency", "frequency"), ("simulate", "simulate"),
("seconds", "seconds"), ("slots", "slots"),
("log", "log"), ("decodes_seen", "decodes_seen"),
("hold", "hold"), ("lowest", "lowest"),
("highest", "highest"), ("most", "most"),
("rounds", "rounds"), ("grid", "grid"),
("units", "units"), ("calls_only", "calls_only"),
("report", "report"), ("csv", "csv"),
("adif", "adif")):
value = getattr(args, flag, None)
if value is not None:
setattr(options, key, value)
# The band picks the frequency unless the frequency was given outright,
# which is the one order that lets both flags mean what they say.
if getattr(args, "band", None) and getattr(args, "frequency", None) is None:
ft8.use_band(options, options.band)
errs = options.validate()
if errs:
for e in errs:
console.print(f"[red]{e}[/red]")
return 2
ft8.listen(console, options, cfg.output_dir)
return 0
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(main()) sys.exit(main())

919
bandsaunter/ft8.py Normal file
View file

@ -0,0 +1,919 @@
"""Listening to FT8, from either front end.
Fifteen seconds of everybody at once. Every station on the band transmits
in the same quarter-minute slots, on the same dial frequency, fifty hertz
wide each, stacked across three kilohertz of audio -- so one receiver
parked on one frequency hears the whole band's worth of stations at the
same time, and hears most of them well below the noise.
That is why this is a section rather than something the scanner stops on.
A sweep catches whatever was keyed up as it went past; FT8 is forty
stations transmitting simultaneously for twelve and a half seconds out of
every fifteen, and the interesting thing is all of them.
The band plan is mostly shortwave, which a plain receiver of this kind
cannot reach without help -- so the default here is the two-metre channel
at 144.174 MHz, which it can. The shortwave channels are all in the list
and work perfectly well through an upconverter or a receiver in direct
sampling mode; they are simply not what you get by not choosing.
Nothing here transmits. This listens, decodes and writes down.
"""
from __future__ import annotations
import math
import time
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
import numpy as np
import yaml
from . import ft8code as code
from . import ft8wave as wave
from .settings import Setting, format_value
__all__ = ["Ft8Options", "OPTIONS", "OPTION_GROUPS", "defaults", "in_group",
"by_key", "format_option", "describe", "summarise",
"Station", "Band", "Heard", "listen", "open_device", "open_log",
"pump", "finish", "report", "load_options", "save_options",
"options_path", "logs_in", "BANDS", "band_named", "band_text",
"use_band", "slot_start", "next_slot", "grid_at", "grid_away",
"SLOT", "audio_from"]
# ---------------------------------------------------------------------------
# Where FT8 lives
# ---------------------------------------------------------------------------
# name, hertz, what it needs. The dial frequency, in the usual convention:
# signals sit from about 200 to 3000 Hz above it.
BANDS: tuple[tuple[str, float, str], ...] = (
("160m", 1_840_000.0, "shortwave"),
("80m", 3_573_000.0, "shortwave"),
("60m", 5_357_000.0, "shortwave"),
("40m", 7_074_000.0, "shortwave"),
("30m", 10_136_000.0, "shortwave"),
("20m", 14_074_000.0, "shortwave"),
("17m", 18_100_000.0, "shortwave"),
("15m", 21_074_000.0, "shortwave"),
("12m", 24_915_000.0, "shortwave"),
("10m", 28_074_000.0, "shortwave"),
("6m", 50_313_000.0, "direct"),
("2m", 144_174_000.0, "direct"),
("70cm", 432_174_000.0, "direct"),
)
SLOT = code.SLOT_S # fifteen seconds, and everybody keeps to it
def band_named(name: str) -> float:
"""The dial frequency for a band, or the two-metre one."""
wanted = (name or "").strip().lower()
for key, hz, _needs in BANDS:
if wanted == key:
return hz
return 144_174_000.0
def band_needs(name: str) -> str:
wanted = (name or "").strip().lower()
for key, _hz, needs in BANDS:
if wanted == key:
return needs
return "direct"
def use_band(options: "Ft8Options", name: str) -> None:
"""Point the receiver at a band, frequency and all."""
options.band = (name or "").strip().lower()
options.frequency = band_named(options.band)
def band_text(options: "Ft8Options") -> str:
where = f"{options.frequency / 1e6:g} MHz"
needs = band_needs(options.band)
if needs == "shortwave":
return f"{options.band} — {where}, needs an upconverter"
return f"{options.band} — {where}"
# ---------------------------------------------------------------------------
# Slots
# ---------------------------------------------------------------------------
def slot_start(when: float | None = None) -> float:
"""The top of the fifteen-second slot that ``when`` falls in.
Against UTC, not against the local clock and not against when this
program happened to start: the whole protocol is built on every station
on earth agreeing about which quarter-minute it is. A receiver whose
clock is a second out still decodes -- the search covers a few seconds
either way -- but one that is a slot out hears every transmission
split across two captures and decodes none of them.
"""
when = time.time() if when is None else when
return math.floor(when / SLOT) * SLOT
def next_slot(when: float | None = None) -> float:
return slot_start(when) + SLOT
def slot_name(at: float) -> str:
"""The slot, as the six digits every FT8 program labels it with."""
return datetime.fromtimestamp(at, timezone.utc).strftime("%H%M%S")
# ---------------------------------------------------------------------------
# Grid squares
# ---------------------------------------------------------------------------
def grid_at(grid: str):
"""The middle of a Maidenhead square, as latitude and longitude.
Four characters is about seventy miles by a hundred, which is all the
position FT8 carries and quite enough to say which continent somebody
is on. Six characters is finer and turns up in free text rather than
in the position field.
"""
grid = (grid or "").strip().upper()
if len(grid) < 4 or not grid[:2].isalpha() or not grid[2:4].isdigit():
return None
if not ("A" <= grid[0] <= "R" and "A" <= grid[1] <= "R"):
return None
lon = (ord(grid[0]) - 65) * 20.0 + int(grid[2]) * 2.0
lat = (ord(grid[1]) - 65) * 10.0 + int(grid[3]) * 1.0
if len(grid) >= 6 and grid[4].isalpha() and grid[5].isalpha():
lon += (ord(grid[4]) - 65) * (2.0 / 24.0) + (1.0 / 24.0)
lat += (ord(grid[5]) - 65) * (1.0 / 24.0) + (0.5 / 24.0)
else:
lon += 1.0
lat += 0.5
return lat - 90.0, lon - 180.0
def grid_away(here: str, there: str):
"""How far apart two grid squares are, in kilometres, and on what bearing.
Great-circle, from the middles of the squares -- so it is good to the
size of a square and no better, which for FT8 is the honest answer and
is why nothing here prints a distance to the nearest kilometre.
"""
a, b = grid_at(here), grid_at(there)
if a is None or b is None:
return None
lat1, lon1 = math.radians(a[0]), math.radians(a[1])
lat2, lon2 = math.radians(b[0]), math.radians(b[1])
d = 2 * math.asin(math.sqrt(
math.sin((lat2 - lat1) / 2) ** 2
+ math.cos(lat1) * math.cos(lat2) * math.sin((lon2 - lon1) / 2) ** 2))
y = math.sin(lon2 - lon1) * math.cos(lat2)
x = (math.cos(lat1) * math.sin(lat2)
- math.sin(lat1) * math.cos(lat2) * math.cos(lon2 - lon1))
return 6371.0088 * d, (math.degrees(math.atan2(y, x)) + 360.0) % 360.0
# ---------------------------------------------------------------------------
# The options
# ---------------------------------------------------------------------------
@dataclass
class Ft8Options:
"""Everything the FT8 mode can be told, in one place."""
# -- receiver -------------------------------------------------------
device: int = 0
gain: str = "auto"
rate: float = 240_000.0
band: str = "2m"
frequency: float = 144_174_000.0
simulate: bool = False
# -- listening ------------------------------------------------------
seconds: float = 0.0
slots: int = 0 # or stop after this many slots
log: bool = True
decodes_seen: bool = False # a line per decode rather than a table
hold: float = 3600.0
# -- decoding -------------------------------------------------------
lowest: float = 200.0
highest: float = 3000.0
most: int = 300 # candidates examined per slot
rounds: int = 30 # how hard to try to repair one
# -- showing --------------------------------------------------------
grid: str = "" # where the aerial is, as a grid square
units: str = "metric"
calls_only: bool = False # leave out free text and telemetry
# -- afterwards -----------------------------------------------------
report: bool = True
csv: bool = False
adif: bool = False
@property
def imperial(self) -> bool:
return str(self.units).lower().startswith("imp")
def validate(self) -> list[str]:
out = []
if self.rate < 96_000:
out.append("the sample rate must be at least 96 kS/s to hold "
"three kilohertz of audio with room to filter it")
if self.seconds < 0 or self.slots < 0:
out.append("times cannot be negative")
if self.hold <= 0:
out.append("a station must stay on the display for some time")
if not 1e5 < self.frequency < 2e9:
out.append("that is not a frequency a receiver can be tuned to")
if self.highest <= self.lowest:
out.append("the top of the search must be above the bottom")
if self.highest - self.lowest < 100.0:
out.append("a search narrower than 100 Hz would miss almost "
"everything: one signal is 50 Hz wide and they are "
"spread across three kilohertz")
if self.most < 1:
out.append("at least one candidate has to be looked at")
if self.rounds < 1:
out.append("the error correction needs at least one pass")
if self.grid and grid_at(self.grid) is None:
out.append(f"{self.grid!r} is not a grid square — four "
"characters like IO91 or FN31")
return out
def to_dict(self) -> dict:
return asdict(self)
def defaults() -> Ft8Options:
return Ft8Options()
O = Setting
_BANDS = tuple(key for key, _hz, _needs in BANDS)
OPTIONS: tuple[Setting, ...] = (
# -- receiver -------------------------------------------------------
O("device", "Receiver", "Receiver", "int",
"which receiver to use, when more than one is plugged in",
"The index shown by `bandsaunter devices`. Zero unless you have "
"several dongles.",
minimum=0, flags=("--device",), example="0"),
O("gain", "Gain", "Receiver", "gain",
"tuner gain in dB, or automatic",
"FT8 is forty signals at once spanning fifty decibels of strength, "
"and what matters is that none of them clips rather than that any of "
"them is loud. Automatic copes. A fixed gain makes the signal "
"reports comparable between one evening and the next, which is the "
"whole point of them if you are judging an aerial or a band opening.",
flags=("--gain",), example="auto"),
O("rate", "Sample rate", "Receiver", "float",
"how fast to sample; 96 kS/s is the least that holds the channel",
"Only three kilohertz of it is used, so this is about having room to "
"filter cleanly rather than about bandwidth. The default costs "
"little and decimates to the twelve kilohertz the decoding wants by "
"a whole number.",
unit="Hz", minimum=96_000.0, flags=("--rate",), example="240000"),
O("band", "Band", "Receiver", "choice",
"which FT8 channel to listen on",
"Every band has one agreed dial frequency and everybody uses it. "
"Two metres is the default because a plain receiver of this kind can "
"reach it; the shortwave bands, which is where almost all the "
"activity is, need an upconverter or a receiver in direct sampling "
"mode. Choosing a band sets the frequency with it.",
choices=_BANDS, flags=("--band",), example="20m"),
O("frequency", "Dial frequency", "Receiver", "float",
"the dial frequency, if you want one the band list does not have",
"Signals sit from about two hundred to three thousand hertz above "
"this, which is the upper-sideband convention the whole mode is "
"built on. Setting a band sets this; setting this directly leaves "
"the band label alone, so use it for an upconverter's offset or a "
"channel that is not in the list.",
unit="Hz", minimum=1e5, maximum=2e9, flags=("--frequency", "--freq"),
example="14074000"),
O("simulate", "Simulate", "Receiver", "bool",
"invent a band instead of using a receiver",
"A made-up band of stations, on a real clock, for trying the display "
"and the report without an aerial. What it cannot tell you is "
"whether your receiver hears anything.",
flags=("--simulate",), off_flags=("--no-simulate",)),
# -- listening ------------------------------------------------------
O("seconds", "Listen for", "Listening", "float",
"how long to listen; zero means until stopped",
"Rounded up to a whole slot, because half a transmission decodes to "
"nothing at all.",
unit="s", minimum=0.0, flags=("--seconds",), example="600"),
O("slots", "Or this many slots", "Listening", "int",
"stop after this many fifteen-second slots; zero means no limit",
"The same thing as the time limit, counted the way the band counts "
"it. Whichever of the two is reached first stops the run.",
minimum=0, flags=("--slots",), example="40"),
O("log", "Write a log", "Listening", "bool",
"keep every decode in a file",
"One line per decode, with the slot, the frequency, how late it was "
"and how strongly it came in. The report afterwards is built from "
"this, and so is anything you want to do with it later.",
flags=("--log",), off_flags=("--no-log",)),
O("decodes_seen", "Print each decode", "Listening", "bool",
"a line per decode instead of a table that refreshes",
"What to use when the output is going into a pipe or a file. The "
"table is easier to watch; the lines are easier to grep.",
flags=("--decodes-seen",), off_flags=("--no-decodes-seen",)),
O("hold", "Keep on display for", "Listening", "float",
"how long a station stays on the table after its last decode",
"A station transmits every other slot at most, and many call once "
"and go away. Long enough that the table is a picture of the "
"evening rather than of the last thirty seconds.",
unit="s", minimum=1.0, flags=("--hold",), example="3600"),
# -- decoding -------------------------------------------------------
O("lowest", "Search from", "Decoding", "float",
"the lowest audio frequency to look for signals at",
"Below a couple of hundred hertz there is nothing but the "
"receiver's own rumble, and searching it costs time for nothing.",
unit="Hz", minimum=0.0, flags=("--lowest",), example="200"),
O("highest", "Search to", "Decoding", "float",
"the highest audio frequency to look for signals at",
"Three kilohertz is where a normal sideband receiver stops. Going "
"higher finds nothing unless your receiver is wider than that, and "
"costs time in proportion.",
unit="Hz", minimum=100.0, flags=("--highest",), example="3000"),
O("most", "Candidates per slot", "Decoding", "int",
"how many possible transmissions to try to decode each slot",
"The sync search ranks every place a transmission could be and this "
"is how far down the list to go. A busy shortwave band puts forty "
"real signals in a slot and several hundred plausible-looking "
"places; more than a few hundred has been measured to add nothing "
"but time.",
minimum=1, flags=("--most",), example="300"),
O("rounds", "Repair passes", "Decoding", "int",
"how many passes of error correction to make before giving up",
"The code either converges in twenty or so passes or it does not "
"converge at all -- raising this has been measured to find nothing "
"further, and it is here so that can be checked rather than taken "
"on trust.",
minimum=1, flags=("--rounds",), example="30"),
# -- showing --------------------------------------------------------
O("grid", "Aerial at", "Showing", "text",
"your own grid square, so distances can be worked out",
"Four characters, like IO91 or FN31. Every FT8 station that calls "
"CQ says where it is this way, so with your own square filled in "
"the report can say how far each of them is and on what bearing — "
"which on shortwave is the whole interest of the thing. Left blank "
"the decodes are still recorded; there is simply nothing to measure "
"them from.",
flags=("--grid",), example="IO91", metavar="SQUARE"),
O("units", "Show readings in", "Showing", "choice",
"metric or imperial, for the display and the export",
"Distances only. Signal reports are in decibels either way, that "
"being what the mode measures in and what gets sent back on the "
"air.",
choices=("metric", "imperial"), flags=("--units",), example="metric"),
O("calls_only", "Callsigns only", "Showing", "bool",
"leave out free text and telemetry",
"Most of what passes on FT8 is two callsigns and a report. The rest "
"is free text -- thirteen characters, no callsign structure, so "
"nothing can be said about who sent it -- and telemetry, which is "
"eighteen hex digits. Turn this on to see only the exchanges.",
flags=("--calls-only",), off_flags=("--no-calls-only",)),
# -- afterwards -----------------------------------------------------
O("report", "Report at the end", "Afterwards", "bool",
"print the tables when the listening stops",
"Who was heard, how far off, and what was exchanged.",
flags=("--report",), off_flags=("--no-report",)),
O("csv", "Also write a CSV", "Afterwards", "bool",
"a spreadsheet of every decode beside the log",
"The same rows as the log, in the form a spreadsheet opens without "
"being told anything.",
flags=("--csv",), off_flags=("--no-csv",)),
O("adif", "Also write an ADIF", "Afterwards", "bool",
"the log again, in the form logging programs read",
"ADIF is what every amateur logging program imports. What is "
"written is what was heard rather than a contact made -- nothing "
"here transmits, so nothing here is a QSO — so the records are "
"marked as received-only and are for feeding a propagation map or a "
"spotting tool rather than for claiming anything.",
flags=("--adif",), off_flags=("--no-adif",)),
)
OPTION_GROUPS = ("Receiver", "Listening", "Decoding", "Showing", "Afterwards")
def in_group(group: str) -> tuple[Setting, ...]:
return tuple(o for o in OPTIONS if o.group == group)
def by_key(key: str):
for option in OPTIONS:
if option.key == key:
return option
return None
# Options where nothing set means something, rather than nothing.
_ZERO_MEANS = {
"seconds": "until stopped",
"slots": "no limit",
"grid": "not set, so no distances",
}
def format_option(option: Setting, value) -> str:
"""Render an option the way the menu and the manual should show it."""
if not value and option.key in _ZERO_MEANS:
return _ZERO_MEANS[option.key]
return format_value(option, value)
def describe(options: Ft8Options) -> str:
"""The one-line summary the menu shows beside Listen."""
bits = [band_text(options)]
if options.seconds:
bits.append(f"{options.seconds:g} s")
elif options.slots:
bits.append(f"{options.slots} slots")
if options.grid:
bits.append(f"from {options.grid.upper()}")
if options.simulate:
bits.append("simulated")
return ", ".join(bits)
def summarise(options: Ft8Options) -> str:
return describe(options)
def options_path(directory=None) -> Path:
from .config import DEFAULT_CONFIG_DIR
return Path(directory or DEFAULT_CONFIG_DIR) / "ft8.yaml"
def load_options(directory=None) -> Ft8Options:
"""The saved options, or the defaults. A broken file is not an error."""
options = Ft8Options()
try:
body = yaml.safe_load(options_path(directory).read_text()) or {}
except (OSError, ValueError, yaml.YAMLError):
return options
if not isinstance(body, dict):
return options
known = set(options.__dict__)
for key, value in body.items():
if key in known and value is not None:
try:
setattr(options, key, type(getattr(options, key))(value))
except (TypeError, ValueError):
pass
return options
def save_options(options: Ft8Options, directory=None) -> Path:
path = options_path(directory)
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w") as fh:
yaml.safe_dump(options.to_dict(), fh, sort_keys=False,
default_flow_style=False)
return path
def logs_in(directory) -> list[Path]:
try:
return sorted(Path(directory).glob("ft8-*.txt"))
except OSError:
return []
# ---------------------------------------------------------------------------
# Who was heard
# ---------------------------------------------------------------------------
@dataclass
class Station:
"""One callsign, and everything heard from it."""
call: str = ""
grid: str = ""
first: float = 0.0
last: float = 0.0
decodes: int = 0
best_snr: float = -99.0
worst_snr: float = 99.0
hertz: float = 0.0
calling: int = 0 # how many times it called CQ
worked: set = field(default_factory=set) # who it was talking to
said: list = field(default_factory=list) # the lines, newest last
def away(self, here: str):
"""How far off and on what bearing, if both squares are known."""
if not here or not self.grid:
return None
return grid_away(here, self.grid)
@dataclass
class Band:
"""Every station heard, and every exchange between them.
Kept apart the way the APRS section keeps messages apart from stations,
and for the same reason: a decode is about a moment and a station is
about an evening, and a table that tried to be both would be neither.
"""
stations: dict = field(default_factory=dict)
decodes: list = field(default_factory=list)
book: code.CallBook = field(default_factory=code.CallBook)
slots: int = 0
def add(self, found) -> None:
self.decodes.append(found)
# The caller is the second callsign in a standard exchange -- the
# first is who is being answered. A CQ has no first, so the one
# station named is the one transmitting.
sender = found.calls[1] if len(found.calls) > 1 else (
found.calls[0] if found.calls else "")
if not sender or sender == "<...>":
return
station = self.stations.get(sender)
if station is None:
station = Station(call=sender, first=found.at)
self.stations[sender] = station
station.last = found.at
station.decodes += 1
station.hertz = found.hertz
station.best_snr = max(station.best_snr, found.snr_db)
station.worst_snr = min(station.worst_snr, found.snr_db)
if found.grid:
station.grid = found.grid
if found.calling:
station.calling += 1
elif len(found.calls) > 1 and found.calls[0] != "<...>":
station.worked.add(found.calls[0])
station.said.append(found.text)
if len(station.said) > 32:
del station.said[0]
def all(self) -> list:
return list(self.stations.values())
@dataclass
class Heard:
"""What the front ends share: the running totals of one run."""
band: Band = field(default_factory=Band)
decodes: int = 0
slots: int = 0
started: float = 0.0
latest: list = field(default_factory=list)
# ---------------------------------------------------------------------------
# The receiver
# ---------------------------------------------------------------------------
AUDIO_RATE = 12_000 # what the decoding wants, and a whole division
def open_device(console, options: Ft8Options):
"""The receiver, or a made-up band, or None if neither can be had."""
from rich.panel import Panel
from rich.text import Text
from .device import RtlSdrDevice, RtlSdrError
if options.simulate:
console.print("[yellow]simulated: these stations are not there."
"[/yellow]")
from .ft8sim import SimulatedBand
return SimulatedBand(rate=options.rate, realtime=True)
try:
device = RtlSdrDevice(index=options.device,
sample_rate=int(options.rate),
gain=options.gain,
agc=options.gain == "auto",
# Which is what makes the shortwave bands
# reachable at all on a dongle that supports
# it: below 24 MHz the tuner is bypassed and
# the sampler is fed straight off the aerial.
direct_sampling="auto")
device.open()
except RtlSdrError as exc:
console.print(Panel(Text(str(exc)),
title="[red]cannot open the receiver",
border_style="red"))
return None
return device
def audio_from(samples, options: Ft8Options, demod=None):
"""Upper-sideband audio at twelve kilohertz, from the receiver's samples.
Upper sideband because that is the convention the whole mode is built
on: the dial frequency is the bottom edge and every signal sits above
it, which is why two stations three hundred hertz apart are two
different signals rather than the same one heard twice.
"""
from .demod import make_demodulator
if demod is None:
demod = make_demodulator("usb", sample_rate=int(options.rate),
bandwidth=3200.0, audio_rate=AUDIO_RATE)
audio, _iq = demod.step(samples)
return audio, demod
def pump(device, options: Ft8Options, heard: Heard, log, started: float,
on_slot=None, on_decode=None, stopping=None, clock=time.time) -> int:
"""Read the receiver, a slot at a time, until it stops or is told to.
The one loop both front ends are driven from, so that what is written
to the log cannot depend on which one you happened to be looking at.
Audio is collected continuously and cut on the slot boundaries rather
than being captured slot by slot. A receiver that started reading when
the slot began would miss however long it took to start reading, and
everything on the band begins in the first half-second.
"""
demod = None
audio = np.zeros(0, dtype=np.float32)
audio_at = 0.0 # wall time of audio[0]
want = next_slot(clock()) # the first whole slot we can get
total = 0
device.tune(options.frequency)
block = int(options.rate) # a second of samples at a time
while stopping is None or not stopping():
at = clock()
samples = device.read_samples(block)
if samples is None or samples.size == 0:
break
fresh, demod = audio_from(samples, options, demod)
if fresh.size:
if audio.size == 0:
# The clock was read before asking for the samples, and the
# read returns once they have arrived -- so they cover the
# block that *starts* here. Dating them a block earlier
# instead slides every slice a second late, which cuts the
# first half-second off every transmission: three symbols,
# part of the opening Costas array, and most of the
# decodes with it.
audio_at = at
audio = np.concatenate([audio, fresh])
# Every whole slot now sitting in the buffer.
while audio.size and audio_at <= want and \
audio_at + audio.size / AUDIO_RATE >= want + SLOT:
first = int(round((want - audio_at) * AUDIO_RATE))
piece = audio[first:first + int(round(SLOT * AUDIO_RATE))]
found = wave.listen_to(piece, AUDIO_RATE, book=heard.band.book,
most=options.most, rounds=options.rounds,
at=want)
found = [d for d in found
if options.lowest <= d.hertz <= options.highest]
if options.calls_only:
found = [d for d in found if d.kind in ("standard",
"non-standard")]
heard.slots += 1
heard.band.slots += 1
heard.latest = found
for d in found:
heard.band.add(d)
heard.decodes += 1
total += 1
if log is not None:
log.append(d)
if on_decode is not None:
on_decode(d)
if on_slot is not None:
on_slot(want, found)
want += SLOT
# Keep a little before the next slot: a transmission can start
# before the slot it belongs to.
keep = int(round(max(0.0, want - 3.0 - audio_at) * AUDIO_RATE))
if keep > 0:
audio = audio[keep:]
audio_at += keep / AUDIO_RATE
if options.seconds and clock() - started >= options.seconds:
break
if options.slots and heard.slots >= options.slots:
break
return total
def open_log(console, options: Ft8Options, output_dir, started: float):
from . import ft8log
if not options.log:
return None
log = ft8log.open_log(output_dir, started, options.frequency,
options.band)
if log is None:
console.print("[yellow]could not open a log; carrying on without "
"one[/yellow]")
return log
def listen(console, options: Ft8Options, output_dir: str,
heard: Heard | None = None, stopping=None) -> int:
"""Listen on one channel and write down everything decoded."""
from .ui import Ft8Display
started = time.time()
heard = heard if heard is not None else Heard()
heard.started = started
# The receiver first. Announcing that it is listening and then failing
# to open a dongle reads as though the listening went wrong, when what
# went wrong happened before any of it started.
device = open_device(console, options)
if device is None:
return 0
log = open_log(console, options, output_dir, started)
_say_where(console, options)
console.print(f"[grey62]listening on {band_text(options)} at "
f"{options.rate / 1e6:g} MS/s — control-C to stop"
"[/grey62]")
if not options.decodes_seen:
console.print("[grey62]the first decodes appear when the slot after "
"next ends, which is up to thirty seconds away"
"[/grey62]")
display = None
live = None
if not options.decodes_seen and getattr(console, "is_terminal", False):
from rich.live import Live
display = Ft8Display(console, hold=options.hold,
imperial=options.imperial, grid=options.grid,
band=band_text(options))
display.started = started
live = Live(display.render(), console=console, refresh_per_second=2,
screen=False, transient=False, vertical_overflow="crop")
live.start()
def show_slot(at, found):
if display is not None:
display.update(heard, at)
live.update(display.render())
def show_decode(d):
if options.decodes_seen:
console.print(f"{slot_name(d.at)} {d.snr_db:>4.0f} "
f"{d.offset:>5.1f} {d.hertz:>7.1f} ~ {d.text}")
try:
pump(device, options, heard, log, started, on_slot=show_slot,
on_decode=show_decode, stopping=stopping)
except KeyboardInterrupt:
console.print("\n[grey62]stopped[/grey62]")
finally:
if live is not None:
live.stop()
try:
device.close()
except Exception:
pass
return finish(console, options, output_dir, heard, log)
def _say_where(console, options: Ft8Options) -> None:
"""Whether distances can be worked out, and what it costs if not."""
if options.grid:
where = grid_at(options.grid)
console.print(f"[grey62]aerial at {options.grid.upper()} "
f"({where[0]:.2f},{where[1]:.2f}) — distances and "
f"bearings will be worked out[/grey62]")
return
console.print("[grey62]no grid square set, so no distances and no "
"bearings — `--grid IO91` gives both[/grey62]")
if band_needs(options.band) == "shortwave":
console.print("[grey62]this band is shortwave: a plain receiver "
"needs an upconverter or direct sampling to hear it"
"[/grey62]")
def finish(console, options: Ft8Options, output_dir: str, heard: Heard,
log=None) -> int:
"""Close the log, write the exports, and print the report."""
from . import ft8log
if log is not None:
log.close()
console.print(f"[grey62]{log.lines} decodes written to "
f"{log.path}[/grey62]")
if options.csv:
where = ft8log.write_csv(log.path.with_suffix(".csv"),
heard.band.decodes)
if where is not None:
console.print(f"[grey62]and as a spreadsheet: {where}"
"[/grey62]")
if options.adif:
where = ft8log.write_adif(log.path.with_suffix(".adi"),
heard.band.decodes, band=options.band,
frequency=options.frequency,
my_grid=options.grid)
if where is not None:
console.print(f"[grey62]and as ADIF, marked as heard rather "
f"than worked: {where}[/grey62]")
if options.report:
report(console, heard.band, options)
return heard.decodes
def _away_text(station: Station, options: Ft8Options) -> str:
away = station.away(options.grid)
if away is None:
return ""
km, bearing = away
if options.imperial:
return f"{km * 0.621371:.0f} mi {bearing:.0f}°"
return f"{km:.0f} km {bearing:.0f}°"
def report(console, band: Band, options: Ft8Options) -> None:
"""Three tables, because they answer three different questions."""
from rich.table import Table
stations = sorted(band.all(), key=lambda s: (-s.decodes, s.call))
if not stations and not band.decodes:
console.print("[yellow]nothing decoded[/yellow]")
if band.slots:
console.print("[grey62]the band was quiet, the aerial is not "
"hearing it, or the clock is out: FT8 needs the "
"clock right to a second or two[/grey62]")
return
console.print()
console.print(f"[bold]Stations heard[/bold] — {len(stations)} in "
f"{band.slots} slots")
t = Table(box=None, header_style="bold", pad_edge=False)
t.add_column("station")
t.add_column("grid")
if options.grid:
t.add_column("away")
t.add_column("decodes", justify="right")
t.add_column("best", justify="right")
t.add_column("worst", justify="right")
t.add_column("audio", justify="right")
t.add_column("CQ", justify="right")
for s in stations[:60]:
row = [s.call, s.grid or "—"]
if options.grid:
row.append(_away_text(s, options) or "—")
row += [str(s.decodes), f"{s.best_snr:.0f}", f"{s.worst_snr:.0f}",
f"{s.hertz:.0f} Hz", str(s.calling) if s.calling else ""]
t.add_row(*row)
console.print(t)
if len(stations) > 60:
console.print(f"[grey62]…and {len(stations) - 60} more[/grey62]")
calling = [s for s in stations if s.calling]
if calling:
console.print()
console.print(f"[bold]Calling CQ[/bold] — {len(calling)} stations "
"looking for a contact")
t = Table(box=None, header_style="bold", pad_edge=False)
t.add_column("station")
t.add_column("grid")
if options.grid:
t.add_column("away")
t.add_column("times", justify="right")
t.add_column("best", justify="right")
for s in sorted(calling, key=lambda s: -s.calling)[:30]:
row = [s.call, s.grid or "—"]
if options.grid:
row.append(_away_text(s, options) or "—")
row += [str(s.calling), f"{s.best_snr:.0f}"]
t.add_row(*row)
console.print(t)
worked = [s for s in stations if s.worked]
if worked:
console.print()
console.print("[bold]Who was working whom[/bold] — the exchanges "
"that passed while this was listening")
t = Table(box=None, header_style="bold", pad_edge=False)
t.add_column("station")
t.add_column("answered")
for s in sorted(worked, key=lambda s: -len(s.worked))[:30]:
t.add_row(s.call, ", ".join(sorted(s.worked)[:8]))
console.print(t)
if options.grid:
far = [(s, s.away(options.grid)) for s in stations if s.grid]
far = [(s, a) for s, a in far if a is not None]
if far:
s, (km, bearing) = max(far, key=lambda x: x[1][0])
unit = (f"{km * 0.621371:.0f} miles" if options.imperial
else f"{km:.0f} km")
console.print()
console.print(f"[grey62]furthest heard: {s.call} in {s.grid}, "
f"{unit} on {bearing:.0f}°[/grey62]")

712
bandsaunter/ft8code.py Normal file
View file

@ -0,0 +1,712 @@
"""What an FT8 transmission carries, and how it is wrapped.
No radio in here at all. This is the part between a string of one hundred
and seventy-four bits and a line somebody can read: the checksum, the
error-correcting code that makes FT8 work at signal levels where the
operator hears nothing, the Gray mapping onto eight tones, and the
seventy-seven bits that hold two callsigns and a grid square.
The shape of it, outward from the message:
77 bits what was said: two callsigns and a report, or free text
+ 14 bits a CRC, so a wrong answer is caught rather than printed
= 91 bits
+ 83 bits LDPC parity, so a wrong answer is usually *repaired*
= 174 bits
/ 3 three bits to a tone, in Gray order
= 58 tones
+ 21 tones three Costas arrays, at the start, the middle and the end
= 79 tones at 6.25 Hz apart and 6.25 to the second: 12.64 seconds
The error correction is the whole trick. Fifty-eight tones of payload
carried in one hundred and seventy-four bits is a rate of about one half:
half of what is transmitted is redundancy, and that is what buys a protocol
that decodes at twenty-odd decibels below the noise in the same bandwidth.
A receiver that only took the strongest tone in each symbol and hoped would
decode almost nothing -- which is why the tone detector below reports how
confident it is, bit by bit, rather than just what it thinks it heard.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import numpy as np
from .ft8tables import (BITS, CHECKS, COSTAS, CRC_BITS, CRC_POLYNOMIAL,
GENERATOR, GRAY, PARITY, PAYLOAD, TONES)
__all__ = ["crc14", "with_crc", "crc_holds", "encode", "repair",
"parity_holds", "tones_of",
"bits_of", "unpack", "pack", "CallBook", "Message", "MESSAGE_BITS",
"SYMBOL_HZ", "SYMBOL_S", "SLOT_S", "SENDING_S", "COSTAS", "TONES",
"hash_of", "free_text", "is_standard_call"]
MESSAGE_BITS = 77 # what is actually said, before the checksum
# The channel, in numbers. Six and a quarter of everything: the tones are
# 6.25 Hz apart and there are 6.25 of them a second, which makes each tone
# exactly one cycle of separation long and the whole thing orthogonal.
SYMBOL_HZ = 6.25
SYMBOL_S = 1.0 / SYMBOL_HZ
SENDING_S = TONES * SYMBOL_S # 12.64 s of transmission
SLOT_S = 15.0 # in a slot of fifteen
# ---------------------------------------------------------------------------
# The checksum
# ---------------------------------------------------------------------------
def crc14(bits) -> int:
"""CRC-14 over a sequence of bits, most significant first.
The one thing standing between a repaired codeword and a confident lie.
The error correction below will happily converge on *a* valid codeword
from noise; whether it is the codeword that was sent is what this
answers, and one in sixteen thousand wrong answers gets through, which
over an evening is a handful of lines nobody should trust. Every
decoder prints them anyway, and so does this one -- but only after the
CRC has agreed, which is the difference between a handful and a flood.
"""
top = 1 << (CRC_BITS - 1)
mask = (top << 1) - 1
remainder = 0
for i, bit in enumerate(bits):
if i % 8 == 0:
# A byte at a time into the top of the register, exactly as the
# specification frames it: the message is a byte sequence and
# the odd bits at the end are zeros.
byte = 0
for k in range(8):
byte = (byte << 1) | (bits[i + k] if i + k < len(bits) else 0)
remainder ^= byte << (CRC_BITS - 8)
remainder = ((remainder << 1) ^ CRC_POLYNOMIAL if remainder & top
else remainder << 1)
return remainder & mask
def with_crc(payload) -> np.ndarray:
"""The seventy-seven bits said, plus the fourteen that check them.
The checksum covers eighty-two bits rather than seventy-seven: the
message zero-extended by five, which is what the specification says and
is not the same answer as checksumming seventy-seven.
"""
payload = np.asarray(payload, dtype=np.uint8)
if payload.size != MESSAGE_BITS:
raise ValueError(f"a message is {MESSAGE_BITS} bits, not "
f"{payload.size}")
extended = np.concatenate([payload, np.zeros(5, dtype=np.uint8)])
check = crc14(extended.tolist())
tail = np.array([(check >> (CRC_BITS - 1 - k)) & 1
for k in range(CRC_BITS)], dtype=np.uint8)
return np.concatenate([payload, tail])
def crc_holds(bits91) -> bool:
"""Whether the checksum on a decoded block agrees with its message."""
bits91 = np.asarray(bits91, dtype=np.uint8)
if bits91.size != PAYLOAD:
return False
return bool((with_crc(bits91[:MESSAGE_BITS]) == bits91).all())
# ---------------------------------------------------------------------------
# The error-correcting code
# ---------------------------------------------------------------------------
def _generator() -> np.ndarray:
"""The parity half of the generator, unpacked to bits."""
rows = np.zeros((PARITY, PAYLOAD), dtype=np.uint8)
for m, row in enumerate(GENERATOR):
packed = bytes.fromhex(row)
for k in range(PAYLOAD):
rows[m, k] = (packed[k // 8] >> (7 - k % 8)) & 1
return rows
GEN = _generator()
def encode(bits91) -> np.ndarray:
"""Ninety-one bits in, one hundred and seventy-four out.
Systematic: the message comes out unchanged at the front and the parity
is appended, which is why the decoder can read the message straight off
a repaired codeword without undoing anything.
"""
bits91 = np.asarray(bits91, dtype=np.uint8)
if bits91.size != PAYLOAD:
raise ValueError(f"expected {PAYLOAD} bits, got {bits91.size}")
parity = (GEN @ bits91) & 1
return np.concatenate([bits91, parity.astype(np.uint8)])
def _sparse():
"""The parity-check matrix as flat slots, for the message passing.
Every codeword bit sits in exactly three checks and every check covers
six or seven bits, so the whole graph fits in two small index arrays and
the decoding never needs a scatter-add. The short checks are padded to
seven with a slot pointing at a dummy bit that is held at no opinion.
"""
width = max(len(c) for c in CHECKS)
bit_of_slot = np.full(PARITY * width, BITS, dtype=np.int64) # BITS = dummy
live = np.zeros(PARITY * width, dtype=bool)
for m, check in enumerate(CHECKS):
for j, one_based in enumerate(check):
bit_of_slot[m * width + j] = one_based - 1
live[m * width + j] = True
# And the other way: for each bit, the slots that talk about it.
per_bit = [[] for _ in range(BITS)]
for slot in np.flatnonzero(live):
per_bit[bit_of_slot[slot]].append(slot)
degree = max(len(s) for s in per_bit)
slots_of_bit = np.zeros((BITS, degree), dtype=np.int64)
for n, slots in enumerate(per_bit):
slots_of_bit[n] = slots + [slots[-1]] * (degree - len(slots))
return width, bit_of_slot, live, slots_of_bit
WIDTH, BIT_OF_SLOT, LIVE, SLOTS_OF_BIT = _sparse()
def parity_holds(bits174) -> bool:
"""Whether every one of the eighty-three checks comes out even.
Here rather than left to callers because the padding convention -- short
checks point their spare slots at a dummy bit past the end -- is an
implementation detail of the message passing, and anything outside this
module that had to know about it would be a bug waiting to be written.
"""
bits174 = np.asarray(bits174, dtype=np.uint8)
if bits174.size != BITS:
return False
padded = np.concatenate([bits174, np.zeros(1, dtype=np.uint8)])
sums = padded[BIT_OF_SLOT].copy()
sums[~LIVE] = 0
return bool((sums.reshape(PARITY, WIDTH).sum(axis=1) % 2 == 0).all())
def repair(llr, rounds: int = 30, alpha: float = 0.75):
"""Belief propagation over one or many candidate codewords.
``llr`` is how strongly each bit is believed to be a zero: positive for
zero, negative for one, and the size of it is the confidence. Shape
(174,) for one candidate or (n, 174) for a batch, which is how it is
actually used -- a busy slot throws up hundreds of candidates and doing
them one at a time is most of the decoding time.
Normalised min-sum rather than the exact sum-product: it is within a
few tenths of a decibel of it, and it is multiplication-free in the
inner loop, which at this size matters more than the tenths.
Returns the repaired bits, or None where the checks never came out
even. An answer here still has to pass the CRC before it is believed:
this finds *a* codeword, and noise has valid codewords in it too.
"""
single = np.ndim(llr) == 1
belief = np.atleast_2d(np.asarray(llr, dtype=np.float32))
n = belief.shape[0]
if belief.shape[1] != BITS:
raise ValueError(f"a codeword is {BITS} bits, not {belief.shape[1]}")
messages = np.zeros((n, PARITY * WIDTH), dtype=np.float32)
dead = ~LIVE
dead_grid = dead.reshape(PARITY, WIDTH)
out = np.zeros((n, BITS), dtype=np.uint8)
done = np.zeros(n, dtype=bool)
def totals(msgs):
"""What every bit is believed to be, once its three checks are in.
A gather and a sum rather than a scatter-add: every bit sits in
exactly three checks, so the three places to look are known in
advance and np.add.at -- which is where an earlier version of this
spent most of its time -- is not needed at all.
"""
return belief + msgs[:, SLOTS_OF_BIT].sum(axis=2)
for _ in range(rounds):
total = totals(messages)
# What each check hears about a bit, less what it said itself.
wide = np.concatenate([total, np.zeros((n, 1), dtype=np.float32)],
axis=1)
heard = (wide[:, BIT_OF_SLOT] - messages).reshape(n, PARITY, WIDTH)
size = np.abs(heard)
size[:, dead_grid] = np.inf
# The smallest and the next smallest in each check, so "the
# smallest of the others" is a lookup rather than a loop.
order = np.argsort(size, axis=2)
smallest = np.take_along_axis(size, order[:, :, :1], axis=2)
next_up = np.take_along_axis(size, order[:, :, 1:2], axis=2)
others = np.where(size == smallest, next_up, smallest)
odd = (heard < 0).sum(axis=2, keepdims=True) % 2 == 1
mine = np.where(heard < 0, -1.0, 1.0).astype(np.float32)
whole = np.where(odd, -1.0, 1.0).astype(np.float32)
messages = (alpha * whole * mine
* np.minimum(others, 1e4)).astype(np.float32)
messages = messages.reshape(n, -1)
messages[:, dead] = 0.0
hard = (totals(messages) < 0).astype(np.uint8)
wide_hard = np.concatenate([hard, np.zeros((n, 1), dtype=np.uint8)],
axis=1)
sums = wide_hard[:, BIT_OF_SLOT].reshape(n, PARITY, WIDTH).copy()
sums[:, dead_grid] = 0
even = (sums.sum(axis=2) % 2 == 0).all(axis=1)
fresh = even & ~done
if fresh.any():
out[fresh] = hard[fresh]
done |= fresh
if done.all():
break
if single:
return out[0] if done[0] else None
return out, done
# ---------------------------------------------------------------------------
# Tones
# ---------------------------------------------------------------------------
UNGRAY = np.zeros(8, dtype=np.uint8)
for _value, _tone in enumerate(GRAY):
UNGRAY[_tone] = _value
SYNC_AT = (0, 36, 72) # where the three Costas arrays sit
def tones_of(bits174) -> np.ndarray:
"""The seventy-nine tones that carry these bits, sync included."""
bits174 = np.asarray(bits174, dtype=np.uint8)
if bits174.size != BITS:
raise ValueError(f"a codeword is {BITS} bits, not {bits174.size}")
trips = bits174.reshape(-1, 3)
values = trips[:, 0] * 4 + trips[:, 1] * 2 + trips[:, 2]
data = np.array([GRAY[v] for v in values], dtype=np.uint8)
out = np.zeros(TONES, dtype=np.uint8)
costas = np.array(COSTAS, dtype=np.uint8)
taken = 0
for i in range(TONES):
which = i // 36 if i % 36 < 7 else -1
if i in range(0, 7) or i in range(36, 43) or i in range(72, 79):
out[i] = costas[(i - (0 if i < 7 else 36 if i < 43 else 72))]
else:
out[i] = data[taken]
taken += 1
return out
def bits_of(tones) -> np.ndarray:
"""The bits those tones carry, sync thrown away."""
tones = np.asarray(tones, dtype=np.uint8)
if tones.size != TONES:
raise ValueError(f"a transmission is {TONES} tones, not {tones.size}")
data = [tones[i] for i in range(TONES)
if not (i < 7 or 36 <= i < 43 or 72 <= i)]
out = np.zeros(BITS, dtype=np.uint8)
for k, tone in enumerate(data):
value = UNGRAY[tone]
out[3 * k] = (value >> 2) & 1
out[3 * k + 1] = (value >> 1) & 1
out[3 * k + 2] = value & 1
return out
# ---------------------------------------------------------------------------
# Callsigns
# ---------------------------------------------------------------------------
ALPHANUM_SPACE = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" # 37
ALPHANUM = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" # 36
NUMERIC = "0123456789" # 10
LETTERS_SPACE = " ABCDEFGHIJKLMNOPQRSTUVWXYZ" # 27
FULL = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-./?" # 42
WITH_SLASH = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ/" # 38
TOKENS = 2_063_592 # DE, QRZ, CQ and the CQ-with-an-argument forms
MAX22 = 4_194_304 # room for a hashed callsign
MAX_GRID = 32_400 # four-character grids, before the report forms
def hash_of(call: str, width: int = 22) -> int:
"""The short hash a compound callsign is carried as.
A callsign that will not fit in twenty-eight bits -- anything with a
slash in it, and anything long -- travels as a hash, and the receiver
is expected to have heard the full call earlier in the exchange and to
remember it. That is why a fresh receiver prints <...>: not a decoding
failure, but a station whose full name has not been said yet in
anything this receiver heard.
"""
packed = 0
padded = (call.upper() + " ")[:11]
for ch in padded:
packed = packed * 38 + (WITH_SLASH.index(ch) if ch in WITH_SLASH else 0)
return int((47_055_833_459 * packed) >> (64 - width)) & ((1 << width) - 1)
def is_standard_call(call: str) -> bool:
"""Whether a callsign fits the twenty-eight bit form.
One or two characters of prefix, a digit, and up to three letters. Most
callsigns on earth; not the ones with a slash in them.
"""
return _pack_standard(call) is not None
def _pack_standard(call: str):
"""A plain callsign as its number, or None if it is not a plain one.
The form is one or two characters of prefix, exactly one digit, and up
to three letters -- and the twenty-eight bit packing wants the digit in
the third place. Which character is *the* digit cannot be found by
looking at the second one: Z33Z has a digit there and it belongs to the
prefix. It is the last character that is not one of the trailing
letters, so that is how it is found.
"""
call = (call or "").strip().upper()
if not call or "/" in call or len(call) > 6:
return None
at = len(call) - 1
while at >= 0 and call[at].isalpha():
at -= 1
if at < 0 or at > 2 or not call[at].isdigit():
return None
call = " " * (2 - at) + call
if len(call) > 6:
return None
call = (call + " ")[:6]
if (call[0] not in ALPHANUM_SPACE or call[1] not in ALPHANUM
or call[2] not in NUMERIC):
return None
if any(c not in LETTERS_SPACE for c in call[3:6]):
return None
n = ALPHANUM_SPACE.index(call[0])
n = n * 36 + ALPHANUM.index(call[1])
n = n * 10 + NUMERIC.index(call[2])
n = n * 27 + LETTERS_SPACE.index(call[3])
n = n * 27 + LETTERS_SPACE.index(call[4])
n = n * 27 + LETTERS_SPACE.index(call[5])
return n
def _unpack_standard(n: int) -> str:
out = [""] * 6
out[5] = LETTERS_SPACE[n % 27]
n //= 27
out[4] = LETTERS_SPACE[n % 27]
n //= 27
out[3] = LETTERS_SPACE[n % 27]
n //= 27
out[2] = NUMERIC[n % 10]
n //= 10
out[1] = ALPHANUM[n % 36]
n //= 36
out[0] = ALPHANUM_SPACE[n % 37]
return "".join(out).strip()
@dataclass
class CallBook:
"""Full callsigns heard earlier, so a hash can be turned back into one.
A compound callsign travels as a hash and is expected to be remembered
from earlier in the same exchange. This is that memory. It is only
ever added to by callsigns that arrived in full and passed their CRC,
so a station that was never heard properly stays <...> rather than being
guessed at -- a wrong callsign in a log is worse than a missing one.
"""
by_hash: dict = field(default_factory=dict)
def remember(self, call: str) -> None:
call = (call or "").strip().upper()
if len(call) < 3 or call in ("CQ", "DE", "QRZ"):
return
for width in (10, 12, 22):
self.by_hash[(width, hash_of(call, width))] = call
def look_up(self, value: int, width: int = 22) -> str:
return self.by_hash.get((width, value), "<...>")
def _unpack28(n: int, book: CallBook) -> str:
"""One of the two callsign fields of a standard message."""
if n < TOKENS:
if n == 0:
return "DE"
if n == 1:
return "QRZ"
if n == 2:
return "CQ"
if n <= 1002:
return f"CQ {n - 3:03d}"
if n <= 532_443:
rest = n - 1003
letters = [""] * 4
for i in (3, 2, 1, 0):
letters[i] = LETTERS_SPACE[rest % 27]
rest //= 27
return "CQ " + "".join(letters).strip()
return "<...>"
n -= TOKENS
if n < MAX22:
return book.look_up(n, 22)
return _unpack_standard(n - MAX22)
def _unpack_grid(value: int, rover: int) -> str:
"""The fifteen-bit field: a grid square, a signal report, or a sign-off."""
if value <= MAX_GRID:
n = value
out = ["", "", "", ""]
out[3] = NUMERIC[n % 10]
n //= 10
out[2] = NUMERIC[n % 10]
n //= 10
out[1] = chr(ord("A") + n % 18)
n //= 18
out[0] = chr(ord("A") + n % 18)
grid = "".join(out)
return ("R " + grid) if rover else grid
report = value - MAX_GRID
if report == 1:
return ""
if report == 2:
return "RRR"
if report == 3:
return "RR73"
if report == 4:
return "73"
decibels = report - 35
return f"{'R' if rover else ''}{decibels:+03d}"
def _pack_grid(text: str):
"""The inverse: a grid, a report or a token as fifteen bits and a flag."""
text = (text or "").strip().upper()
if not text:
return MAX_GRID + 1, 0
if text == "RRR":
return MAX_GRID + 2, 0
if text == "RR73":
return MAX_GRID + 3, 0
if text == "73":
return MAX_GRID + 4, 0
rover = 0
if text.startswith("R ") or (text.startswith("R") and len(text) > 1
and text[1] in "+-"):
rover, text = 1, text[2:].strip() if text[1] == " " else text[1:]
if (len(text) == 4 and "A" <= text[0] <= "R" and "A" <= text[1] <= "R"
and text[2].isdigit() and text[3].isdigit()):
value = ((ord(text[0]) - 65) * 18 + (ord(text[1]) - 65)) * 10
value = (value + int(text[2])) * 10 + int(text[3])
return value, rover
try:
return MAX_GRID + 35 + int(text), rover
except ValueError:
return None
# ---------------------------------------------------------------------------
# Messages
# ---------------------------------------------------------------------------
@dataclass
class Message:
"""One decoded line, and what could be picked out of it."""
text: str = ""
kind: str = "" # standard, free text, telemetry, ...
calls: tuple = () # the callsigns in it, in order
grid: str = "" # a four-character grid, where there was one
report: str = "" # a signal report, where there was one
calling: bool = False # whether this is a CQ
def _number(bits, start: int, count: int) -> int:
value = 0
for k in range(count):
value = (value << 1) | int(bits[start + k])
return value
def _put(bits, start: int, count: int, value: int) -> None:
for k in range(count):
bits[start + k] = (value >> (count - 1 - k)) & 1
def free_text(bits77) -> str:
"""The thirteen-character form: anything at all, at a price.
Base forty-two over seventy-one bits, which is thirteen characters and
no callsign structure -- so a receiver cannot tell who sent it from the
message alone, and nor can the network that reports these things.
"""
n = _number(bits77, 0, 71)
out = [""] * 13
for i in range(12, -1, -1):
out[i] = FULL[n % 42]
n //= 42
return "".join(out).strip()
def unpack(bits77, book: CallBook | None = None) -> Message:
"""Seventy-seven bits as something to read.
The type is the last three bits, which is an odd place for it until you
remember that everything before it is a different length in each form.
"""
bits77 = np.asarray(bits77, dtype=np.uint8)
book = book or CallBook()
i3 = _number(bits77, 74, 3)
if i3 == 0:
n3 = _number(bits77, 71, 3)
if n3 == 0:
return Message(text=free_text(bits77), kind="free text")
if n3 == 5:
value = _number(bits77, 0, 71)
return Message(text=f"{value:018X}", kind="telemetry")
return Message(text=free_text(bits77), kind="free text")
if i3 in (1, 2):
a = _number(bits77, 0, 28)
a_rover = _number(bits77, 28, 1)
b = _number(bits77, 29, 28)
b_rover = _number(bits77, 57, 1)
rover = _number(bits77, 58, 1)
grid = _number(bits77, 59, 15)
one = _unpack28(a, book)
two = _unpack28(b, book)
if a_rover:
one += "/R"
if b_rover:
two += "/R"
extra = _unpack_grid(grid, rover)
text = " ".join(x for x in (one, two, extra) if x)
looks_like_grid = (len(extra) == 4 and extra[:2].isalpha()
and extra[2:].isdigit())
return Message(text=text, kind="standard",
calls=tuple(c for c in (one, two)
if c not in ("CQ", "DE", "QRZ")
and not c.startswith("CQ ")),
grid=extra if looks_like_grid else "",
report="" if looks_like_grid else extra,
calling=one.startswith("CQ") or one == "QRZ")
if i3 == 4:
# A compound callsign: one hashed, one carried in full as eleven
# characters of base thirty-eight.
short = _number(bits77, 0, 12)
n58 = _number(bits77, 12, 58)
flip = _number(bits77, 70, 1)
kind = _number(bits77, 71, 2)
rest = n58
letters = [""] * 11
for i in range(10, -1, -1):
letters[i] = WITH_SLASH[rest % 38]
rest //= 38
full = "".join(letters).strip()
other = book.look_up(short, 12)
one, two = (full, other) if flip else (other, full)
tail = {1: "RRR", 2: "RR73", 3: "73"}.get(kind, "")
text = " ".join(x for x in (one, two, tail) if x)
return Message(text=text, kind="non-standard",
calls=tuple(c for c in (one, two) if c != "<...>"),
report=tail, calling=one.startswith("CQ"))
return Message(text=free_text(bits77), kind=f"type {i3}")
def pack(text: str, book: CallBook | None = None):
"""The inverse, for the standard and free-text forms.
Here so the decoder can be tested against something that is not itself.
A receiver never needs to build a message -- this one does not transmit
-- but a test that encodes with the same misunderstanding it decodes
with agrees with itself about anything they are both wrong about.
"""
text = (text or "").strip().upper()
bits = np.zeros(MESSAGE_BITS, dtype=np.uint8)
words = text.split()
if len(words) in (2, 3) or (len(words) == 4 and words[0] == "CQ"):
made = _pack_standard_message(words, bits)
if made is not None:
return made
return _pack_free_text(text, bits)
def _pack_standard_message(words, bits):
if words[0] == "CQ" and len(words) >= 3 and len(words[1]) <= 4 \
and not _is_call(words[1]):
one, rest = f"CQ {words[1]}", words[2:]
else:
one, rest = words[0], words[1:]
if not rest:
return None
two, extra = rest[0], (rest[1] if len(rest) > 1 else "")
a = _pack28(one)
b = _pack28(two)
if a is None or b is None:
return None
grid = _pack_grid(extra)
if grid is None:
return None
value, rover = grid
_put(bits, 0, 28, a)
_put(bits, 28, 1, 0)
_put(bits, 29, 28, b)
_put(bits, 57, 1, 0)
_put(bits, 58, 1, rover)
_put(bits, 59, 15, value)
_put(bits, 74, 3, 1)
return bits
def _is_call(word: str) -> bool:
return any(c.isdigit() for c in word) and _pack_standard(word) is not None
def _pack28(call: str):
call = call.strip().upper()
if call == "DE":
return 0
if call == "QRZ":
return 1
if call == "CQ":
return 2
if call.startswith("CQ "):
rest = call[3:].strip()
if rest.isdigit() and len(rest) == 3:
return 3 + int(rest)
if 1 <= len(rest) <= 4 and all(c in LETTERS_SPACE for c in rest):
n = 0
for ch in (rest + " ")[:4]:
n = n * 27 + LETTERS_SPACE.index(ch)
return 1003 + n
return None
plain = _pack_standard(call)
if plain is not None:
return TOKENS + MAX22 + plain
# Deliberately not a hash. A hash always succeeds, and a packer that
# always succeeds would send "HELLO WORLD" as two hashed callsigns
# rather than as the free text it plainly is.
return None
def _pack_free_text(text: str, bits):
text = "".join(c if c in FULL else " " for c in text)[:13]
text = text.rjust(13)
n = 0
for ch in text:
n = n * 42 + FULL.index(ch)
_put(bits, 0, 71, n)
_put(bits, 71, 3, 0)
_put(bits, 74, 3, 0)
return bits

177
bandsaunter/ft8log.py Normal file
View file

@ -0,0 +1,177 @@
"""Writing FT8 decodes down.
Three forms, because three different things want to read them. The log is
for a person and for this program's own report; the CSV is for a
spreadsheet; the ADIF is for the logging and spotting programs every
amateur already has.
The ADIF is the one worth a note. It is the format for recording contacts,
and nothing here makes a contact -- this receiver does not transmit. So
what is written is a record of a station *heard*, marked as such, which is
what a propagation map or a reverse-beacon feed wants. Passing it off as a
worked contact would put claims into somebody's log that they cannot make.
"""
from __future__ import annotations
import csv
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from . import __version__
__all__ = ["Ft8Log", "open_log", "write_csv", "write_adif", "read_log",
"LOG_VERSION"]
LOG_VERSION = 1
@dataclass
class Ft8Log:
"""A file of decodes, one to a line, written as they arrive."""
path: Path
handle: object = None
lines: int = 0
def append(self, found) -> None:
if self.handle is None:
return
when = datetime.fromtimestamp(found.at, timezone.utc)
self.handle.write(
f"{when.strftime('%Y-%m-%d %H:%M:%S')} "
f"{found.snr_db:>4.0f} {found.offset:>5.1f} "
f"{found.hertz:>7.1f} ~ {found.text}\n")
self.handle.flush()
self.lines += 1
def close(self) -> None:
if self.handle is not None:
self.handle.close()
self.handle = None
def open_log(directory, started: float, frequency: float,
band: str = "") -> Ft8Log | None:
"""A new log for this run, or nothing if it cannot be opened."""
when = datetime.fromtimestamp(started, timezone.utc)
path = Path(directory) / f"ft8-{when.strftime('%Y%m%d-%H%M%S')}.txt"
try:
path.parent.mkdir(parents=True, exist_ok=True)
handle = open(path, "w")
except OSError:
return None
handle.write(f"# bandsaunter {__version__} FT8 log v{LOG_VERSION}\n")
handle.write(f"# {frequency / 1e6:g} MHz"
f"{(' ' + band) if band else ''}, times are UTC\n")
handle.write("# when snr dt hz ~ message\n")
handle.flush()
return Ft8Log(path=path, handle=handle)
def read_log(path) -> list[dict]:
"""A log back off the disk, for reading an evening again.
Deliberately forgiving: a line that cannot be parsed is skipped rather
than raising, because a log is often read while it is still being
written and the last line may be half there.
"""
out = []
try:
text = Path(path).read_text()
except OSError:
return out
for line in text.splitlines():
if line.startswith("#") or "~" not in line:
continue
head, message = line.split("~", 1)
parts = head.split()
if len(parts) < 5:
continue
try:
when = datetime.strptime(f"{parts[0]} {parts[1]}",
"%Y-%m-%d %H:%M:%S")
out.append({"at": when.replace(tzinfo=timezone.utc).timestamp(),
"snr_db": float(parts[2]), "offset": float(parts[3]),
"hertz": float(parts[4]), "text": message.strip()})
except ValueError:
continue
return out
def write_csv(path, decodes) -> Path | None:
try:
with open(path, "w", newline="") as fh:
writer = csv.writer(fh)
writer.writerow(["utc", "snr_db", "offset_s", "hertz", "kind",
"calls", "grid", "report", "cq", "message"])
for d in decodes:
when = datetime.fromtimestamp(d.at, timezone.utc)
writer.writerow([when.strftime("%Y-%m-%d %H:%M:%S"),
f"{d.snr_db:.0f}", f"{d.offset:.1f}",
f"{d.hertz:.1f}", d.kind,
" ".join(d.calls), d.grid, d.report,
"yes" if d.calling else "", d.text])
except OSError:
return None
return Path(path)
def _adif(field: str, value: str) -> str:
value = str(value)
return f"<{field}:{len(value)}>{value} "
def write_adif(path, decodes, band: str = "", frequency: float = 0.0,
my_grid: str = "") -> Path | None:
"""The decodes as ADIF records of stations heard.
One record per station rather than per decode: a logging program that
was handed forty identical records for one evening's CQ calls would be
right to complain. The strongest report is the one kept, that being
the one worth passing on.
"""
best: dict = {}
for d in decodes:
sender = d.calls[1] if len(d.calls) > 1 else (
d.calls[0] if d.calls else "")
if not sender or sender == "<...>":
continue
was = best.get(sender)
if was is None or d.snr_db > was.snr_db:
best[sender] = d
if d.grid and not getattr(best[sender], "grid", ""):
best[sender].grid = d.grid
try:
with open(path, "w") as fh:
fh.write(f"bandsaunter {__version__} — FT8 stations heard.\n")
fh.write("These are receptions, not contacts: this program "
"does not transmit.\n")
fh.write(_adif("ADIF_VER", "3.1.4") + "\n")
fh.write(_adif("PROGRAMID", "bandsaunter") + "\n")
fh.write(_adif("PROGRAMVERSION", __version__) + "\n")
fh.write("<EOH>\n")
for call, d in sorted(best.items()):
when = datetime.fromtimestamp(d.at, timezone.utc)
row = (_adif("CALL", call)
+ _adif("QSO_DATE", when.strftime("%Y%m%d"))
+ _adif("TIME_ON", when.strftime("%H%M%S"))
+ _adif("MODE", "FT8"))
if band:
row += _adif("BAND", band)
if frequency:
row += _adif("FREQ", f"{frequency / 1e6:.6f}")
if d.grid:
row += _adif("GRIDSQUARE", d.grid)
if my_grid:
row += _adif("MY_GRIDSQUARE", my_grid.upper())
row += _adif("RST_RCVD", f"{d.snr_db:.0f}")
# Said plainly in the record itself, so that a log this is
# imported into cannot quietly become a claim.
row += _adif("COMMENT", "heard only, not worked")
row += _adif("QSO_RANDOM", "Y")
fh.write(row + "<EOR>\n")
except OSError:
return None
return Path(path)

172
bandsaunter/ft8sim.py Normal file
View file

@ -0,0 +1,172 @@
"""A made-up FT8 band, on a real clock.
For trying the display, the report and the exports without an aerial, and
for testing the whole path from samples to tables. It stands in for a
receiver: same `tune`, `read_samples` and `close`, same sample rate, same
complex samples.
What it cannot tell you is whether your receiver hears anything, which is
the one question a simulator is never allowed to answer. It is also the
lesson this project learned expensively on another band: an encoder tested
against its own decoder agrees with it about anything they are both wrong
about. So the decoding is tested against real off-air recordings; this is
here for the parts above the decoding.
"""
from __future__ import annotations
import math
import time
import numpy as np
from . import ft8code as code
from . import ft8wave as wave
__all__ = ["SimulatedBand", "CALLS", "GRIDS"]
# A plausible spread: a few continents, a few strengths, a few habits.
CALLS = ("G4UJS", "SP4FCA", "IK4LZH", "EA1ABT", "DL1UDO", "OH8GDU",
"JA2GQT", "VK4BLE", "RA6ABO", "F4FSY", "K1ABC", "W2XYZ",
"PA3EPP", "SM0ABC", "LZ1LZ", "9A9TT", "ZS6ABC", "LU1AAA")
GRIDS = ("IO83", "KO03", "JN54", "IN73", "JO31", "KP24",
"PM85", "QG62", "KN96", "JN25", "FN42", "FN20",
"JO21", "JO99", "KN12", "JN75", "KG33", "GF05")
class SimulatedBand:
"""A receiver that invents a band of FT8 stations.
Paced against the real clock, because everything about FT8 is: the
slots are quarter-minutes of UTC, and a simulator that produced fifteen
seconds of audio instantly would make the section look as though it
worked while testing none of the timing that makes it work.
"""
def __init__(self, rate: float = 240_000.0, seed: int = 0,
stations: int = 12, realtime: bool = True,
clock=time.time, sleep=time.sleep):
self.rate = float(rate)
self.frequency = 0.0
self.rng = np.random.default_rng(seed)
self.stations = max(1, int(stations))
self.realtime = realtime
self.clock = clock
self.sleep = sleep
self.closed = False
self._audio_rate = 12_000
self._made: dict[float, np.ndarray] = {}
self._one_sided: dict[float, np.ndarray] = {}
self._at = None
# -- the receiver's shape -------------------------------------------
def tune(self, hz: float) -> None:
self.frequency = float(hz)
def close(self) -> None:
self.closed = True
def read_samples(self, count: int):
"""A block of samples, paced to take as long as it would on the air."""
if self.closed:
return None
now = self.clock()
if self._at is None:
self._at = now
wanted = count / self.rate
if self.realtime:
behind = self._at + wanted - self.clock()
if behind > 0:
self.sleep(behind)
block = self._samples_for(self._at, count)
self._at += wanted
return block
# -- making the band ------------------------------------------------
def _slot_audio(self, slot_at: float) -> np.ndarray:
"""One slot of the band, at twelve kilohertz, made once and kept."""
have = self._made.get(slot_at)
if have is not None:
return have
rng = np.random.default_rng(int(slot_at) & 0xFFFFFFFF)
total = int(self._audio_rate * code.SLOT_S)
audio = rng.standard_normal(total).astype(np.float32) * 0.02
# Stations pick a frequency and keep it, mostly, and take turns.
even = int(slot_at / code.SLOT_S) % 2 == 0
for i in range(self.stations):
if (i % 2 == 0) != even:
continue
call = CALLS[(i + int(slot_at / code.SLOT_S) // 2) % len(CALLS)]
grid = GRIDS[CALLS.index(call)]
other = CALLS[(i * 7 + 3) % len(CALLS)]
pick = rng.integers(0, 3)
if pick == 0:
text = f"CQ {call} {grid}"
elif pick == 1:
text = f"{other} {call} {grid}"
else:
text = f"{other} {call} {rng.integers(-24, 5):+03d}"
hertz = 300.0 + i * 190.0 + float(rng.integers(-30, 30))
strength = float(rng.uniform(-18.0, 10.0))
one = wave.transmit(text, rate=float(self._audio_rate),
hertz=hertz,
offset=0.5 + float(rng.uniform(-0.3, 0.6)),
seconds=code.SLOT_S)
scale = 10.0 ** (strength / 20.0) * 0.05
audio[:one.size] += (one * scale).astype(np.float32)
if len(self._made) > 2:
self._made.clear()
self._made[slot_at] = audio
return audio
def _samples_for(self, at: float, count: int):
"""Complex samples covering ``count`` samples from ``at``.
The band is made as audio and then put back on a carrier, which is
the reverse of what the section does to it. One-sided, so that
what comes out of the demodulator is what went in: a real audio
tone at 1000 Hz has to arrive as a complex tone at +1000 Hz, or
upper sideband and lower sideband would be the same thing.
Held at the audio rate and stepped through, rather than properly
resampled. The images that leaves sit at multiples of twelve
kilohertz and the demodulator's own decimation filter removes them
-- which is worth saying out loud, because it means this simulator
leans on the thing it is being used to test. That is tolerable for
the display and the report, and is exactly why the decoding itself
is tested against real recordings instead.
"""
out = np.zeros(count, dtype=np.complex64)
step = 1.0 / self.rate
ratio = self._audio_rate / self.rate
for k in range(0, count, 4096):
piece = min(4096, count - k)
when = at + k * step
slot_at = math.floor(when / code.SLOT_S) * code.SLOT_S
audio = self._analytic_slot(slot_at)
into = (when - slot_at) * self._audio_rate
idx = np.clip((into + np.arange(piece) * ratio).astype(np.int64),
0, audio.size - 1)
out[k:k + piece] = audio[idx].astype(np.complex64)
return out
def _analytic_slot(self, slot_at: float) -> np.ndarray:
have = self._one_sided.get(slot_at)
if have is None:
have = _analytic(self._slot_audio(slot_at))
# Two slots kept: a block of samples commonly straddles a
# boundary, and rebuilding a slot for every such block is most
# of the simulator's time.
if len(self._one_sided) > 2:
self._one_sided.clear()
self._one_sided[slot_at] = have
return have
def _analytic(real_audio: np.ndarray) -> np.ndarray:
"""The one-sided version of a real signal: no energy below zero."""
spectrum = np.fft.fft(real_audio)
n = spectrum.size
spectrum[n // 2 + 1:] = 0.0
spectrum[1:(n + 1) // 2] *= 2.0
return np.fft.ifft(spectrum)

219
bandsaunter/ft8tables.py Normal file
View file

@ -0,0 +1,219 @@
"""The two fixed tables the FT8 code is defined by.
Everything else in this section is worked out from first principles -- the
tones, the sync, the parity arithmetic, the way a callsign is squeezed into
twenty-eight bits. These two cannot be, because they are not derived from
anything. They are the code, chosen once by its designers and published, and
a receiver that guessed at them would be speaking a different protocol.
Taken from ft8_lib (https://github.com/kgoba/ft8_lib), MIT licensed,
Copyright (c) 2018 K\u0101rlis Goba, which took them in turn from WSJT-X.
Reproduced here under that licence; the MIT terms permit it and this file
carries the attribution they ask for. Nothing else in bandsaunter comes from
there: the decoder below is its own.
GENERATOR is the parity half of the systematic generator: row m gives the
message bits that are exclusive-ored to make parity bit m, as ninety-one bits
in twelve bytes, most significant bit first.
CHECKS is the sparse parity-check matrix, as the bit positions each of the
eighty-three checks covers, one-based as published. It is *not* derivable
from GENERATOR even though both describe the same code: the generator's
parity half runs to a weight of fifty-odd bits per row, and belief
propagation on a matrix that dense says nothing useful. The sparse one is a
different basis for the same dual space, six or seven bits to a check, and it
is the one the decoding works on. That they agree is checked by a test
rather than assumed -- encode at random, and every sparse check must come out
even.
"""
__all__ = ["GENERATOR", "CHECKS", "COSTAS", "GRAY", "BITS", "PAYLOAD",
"PARITY", "TONES", "CRC_POLYNOMIAL", "CRC_BITS"]
# The 7x7 Costas array that starts, middles and ends every transmission.
COSTAS = (3, 1, 4, 0, 6, 5, 2)
# Three bits to a tone, in Gray order, so that mistaking a tone for its
# neighbour costs one bit rather than three.
GRAY = (0, 1, 3, 2, 5, 6, 4, 7)
BITS = 174 # bits in a codeword
PAYLOAD = 91 # of which are message and checksum
PARITY = BITS - PAYLOAD
TONES = 79 # channel symbols, sync included
# CRC-14, without the leading bit.
CRC_POLYNOMIAL = 0x2757
CRC_BITS = 14
GENERATOR: tuple[str, ...] = (
"8329CE11BF31EAF509F27FC0",
"761C264E25C2593354931320",
"DC265902FB277C6410A1BDC0",
"1B3F417858CD2DD33EC7F620",
"09FDA4FEE04195FD034783A0",
"077CCCC11B8873ED5C3D48A0",
"29B62AFE3CA036F4FE1A9DA0",
"6054FAF5F35D96D3B0C8C3E0",
"E20798E4310EED27884AE900",
"775C9C08E80E26DDAE563180",
"B0B811028C2BF997213487C0",
"18A0C9231FC60ADF5C5EA320",
"76471E8302A0721E01B12B80",
"FFBCCB80CA8341FAFB47B2E0",
"66A72A158F9325A2BF671700",
"C4243689FE85B1C51363A180",
"0DFF739414D1A1B34B1C2700",
"15B48830636C8B99894972E0",
"29A89C0D3DE81D665489B0E0",
"4F126F37FA51CBE61BD6B940",
"99C47239D0D97D3C84E09400",
"1919B75119765621BB4F1E80",
"09DB12D731FAEE0B86DF6B80",
"488FC33DF43FBDEEA4EAFB40",
"827423EE40B675F756EB5FE0",
"ABE197C484CB74757144A9A0",
"2B500E4BC0EC5A6D2BDBDD00",
"C474AA53D702187616693600",
"8EBA1A13DB3390BD6718CEC0",
"753844673A27782CC42012E0",
"06FF83A145C37035A5C12680",
"3B37417858CC2DD33EC3F620",
"9A4A5A28EE17CA9C324842C0",
"BC29F465309C977E89610A40",
"2663AE6DDF8B5CE2BB294880",
"46F231EFE457034C18144180",
"3FB2CE85ABE9B0C72E06FBE0",
"DE87481F282C153971A0A2E0",
"FCD7CCF23C69FA99BBA14120",
"F0261447E9490CA8E474CEC0",
"4410115818196F95CDD70120",
"088FC31DF4BFBDE2A4EAFB40",
"B8FEF1B6307729FB0A078C00",
"5AFEA7ACCCB77BBC9D99A900",
"49A7016AC653F65ECDC90760",
"1944D085BE4E7DA8D6CC7D00",
"251F62ADC4032F0EE7140020",
"56471F8702A0721E00B12B80",
"2B8E4923F2DD51E2D537FA00",
"6B550A40A66F4755DE95C260",
"A18AD28D4E27FE92A4F6C840",
"10C2E586388CB82A3D807580",
"EF34A41817EE02133DB2EB00",
"7E9C0C54325A9C15836E0000",
"3693E572D1FDE4CDF079E860",
"BFB2CEC5ABE1B0C72E07FBE0",
"7EE18230C583CCCC57D4B080",
"A066CB2FEDAFC9F526641260",
"BB23725ABC47CC5F4CC4CD20",
"DED9DBA3BEE40C59B5609B40",
"D9A7016AC653E6DECDC90360",
"9AD46AED5F707F280AB5FC40",
"E5921C77822587316D7D3C20",
"4F14DA8242A8B86DCA733520",
"8B8B507AD467D4441DF770E0",
"22831C9CF1169467AD04B680",
"213B838FE2AE54C38EE71800",
"5D926B6DD71F085181A4E120",
"66AB79D4B29EE6E69509E560",
"958148682D748A38DD68BAA0",
"B8CE020CF069C32A723AB140",
"F4331D6D461607E957527460",
"6DA23BA424B9596133CF9C80",
"A636BCBC7B30C5FBEAE67FE0",
"5CB0D86A07DF654A9089A200",
"F11F106848780FC9ECDD80A0",
"1FBB5364FB8D2C9D730D5BA0",
"FCB86BC70A50C9D02A5D0340",
"A534433029EAC15F322E34C0",
"C989D9C7C3D3B8C55D751300",
"7BB38B2F0186D46643AE9620",
"2644EBADEB44B9467D1F42C0",
"608CC857594BFBB55D696000",
)
CHECKS: tuple[tuple[int, ...], ...] = (
(4, 31, 59, 91, 92, 96, 153),
(5, 32, 60, 93, 115, 146),
(6, 24, 61, 94, 122, 151),
(7, 33, 62, 95, 96, 143),
(8, 25, 63, 83, 93, 96, 148),
(6, 32, 64, 97, 126, 138),
(5, 34, 65, 78, 98, 107, 154),
(9, 35, 66, 99, 139, 146),
(10, 36, 67, 100, 107, 126),
(11, 37, 67, 87, 101, 139, 158),
(12, 38, 68, 102, 105, 155),
(13, 39, 69, 103, 149, 162),
(8, 40, 70, 82, 104, 114, 145),
(14, 41, 71, 88, 102, 123, 156),
(15, 42, 59, 106, 123, 159),
(1, 33, 72, 106, 107, 157),
(16, 43, 73, 108, 141, 160),
(17, 37, 74, 81, 109, 131, 154),
(11, 44, 75, 110, 121, 166),
(45, 55, 64, 111, 130, 161, 173),
(8, 46, 71, 112, 119, 166),
(18, 36, 76, 89, 113, 114, 143),
(19, 38, 77, 104, 116, 163),
(20, 47, 70, 92, 138, 165),
(2, 48, 74, 113, 128, 160),
(21, 45, 78, 83, 117, 121, 151),
(22, 47, 58, 118, 127, 164),
(16, 39, 62, 112, 134, 158),
(23, 43, 79, 120, 131, 145),
(19, 35, 59, 73, 110, 125, 161),
(20, 36, 63, 94, 136, 161),
(14, 31, 79, 98, 132, 164),
(3, 44, 80, 124, 127, 169),
(19, 46, 81, 117, 135, 167),
(7, 49, 58, 90, 100, 105, 168),
(12, 50, 61, 118, 119, 144),
(13, 51, 64, 114, 118, 157),
(24, 52, 76, 129, 148, 149),
(25, 53, 69, 90, 101, 130, 156),
(20, 46, 65, 80, 120, 140, 170),
(21, 54, 77, 100, 140, 171),
(35, 82, 133, 142, 171, 174),
(14, 30, 83, 113, 125, 170),
(4, 29, 68, 120, 134, 173),
(1, 4, 52, 57, 86, 136, 152),
(26, 51, 56, 91, 122, 137, 168),
(52, 84, 110, 115, 145, 168),
(7, 50, 81, 99, 132, 173),
(23, 55, 67, 95, 172, 174),
(26, 41, 77, 109, 141, 148),
(2, 27, 41, 61, 62, 115, 133),
(27, 40, 56, 124, 125, 126),
(18, 49, 55, 124, 141, 167),
(6, 33, 85, 108, 116, 156),
(28, 48, 70, 85, 105, 129, 158),
(9, 54, 63, 131, 147, 155),
(22, 53, 68, 109, 121, 174),
(3, 13, 48, 78, 95, 123),
(31, 69, 133, 150, 155, 169),
(12, 43, 66, 89, 97, 135, 159),
(5, 39, 75, 102, 136, 167),
(2, 54, 86, 101, 135, 164),
(15, 56, 87, 108, 119, 171),
(10, 44, 82, 91, 111, 144, 149),
(23, 34, 71, 94, 127, 153),
(11, 49, 88, 92, 142, 157),
(29, 34, 87, 97, 147, 162),
(30, 50, 60, 86, 137, 142, 162),
(10, 53, 66, 84, 112, 128, 165),
(22, 57, 85, 93, 140, 159),
(28, 32, 72, 103, 132, 166),
(28, 29, 84, 88, 117, 143, 150),
(1, 26, 45, 80, 128, 147),
(17, 27, 89, 103, 116, 153),
(51, 57, 98, 163, 165, 172),
(21, 37, 73, 138, 152, 169),
(16, 47, 76, 130, 137, 154),
(3, 24, 30, 72, 104, 139),
(9, 40, 90, 106, 134, 151),
(15, 58, 60, 74, 111, 150, 163),
(18, 42, 79, 144, 146, 152),
(25, 38, 65, 99, 122, 160),
(17, 42, 75, 129, 170, 172),
)

411
bandsaunter/ft8wave.py Normal file
View file

@ -0,0 +1,411 @@
"""Finding FT8 in fifteen seconds of audio.
The coding is in ft8code; this is the part that has to deal with the air.
A slot of audio three kilohertz wide holds anything from nothing to forty
transmissions, each fifty hertz wide, each starting when its operator's
clock said to rather than when ours did, and most of them below the noise.
The shape of the search:
1. a waterfall -- the whole slot as power against time and frequency, at
half a symbol and half a tone, so nothing falls between two stools;
2. the sync search -- every place a Costas array could be, scored;
3. for each candidate, the eight tone powers of each of seventy-nine
symbols, turned into how strongly each bit is believed;
4. the error correction, which either converges or does not;
5. the checksum, which decides whether to believe it.
Steps four and five are why this works at all. Nothing up to step three is
clever enough to read a signal that is twenty decibels under the noise; what
it produces is a wash of barely-tilted opinions, and the code turns a
hundred and seventy-four of those into ninety-one bits of certainty -- or
into nothing, which is the other important answer.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
import numpy as np
from . import ft8code as code
from .ft8code import COSTAS, SYMBOL_S, TONES
__all__ = ["Waterfall", "Candidate", "Decode", "listen_to", "waterfall",
"candidates", "soft_bits", "TIME_STEPS", "FREQ_STEPS"]
# Half a symbol and half a tone. A transmission that started a quarter of a
# symbol late, or sits a quarter of a tone off, is still caught: with steps
# a whole symbol wide the worst case loses most of the signal to the gap
# between two bins, and the worst case is common because nobody's clock and
# nobody's dial agree with ours.
TIME_STEPS = 2
FREQ_STEPS = 2
SYNC_AT = (0, 36, 72) # where the three Costas arrays sit, in symbols
# A perfectly-timed station does not start at the top of the slot. The
# transmission is 12.64 seconds long in a slot of fifteen, and the
# convention every FT8 program follows is to begin half a second in and to
# report lateness against that -- so a station whose clock is right reports
# as dt 0.0 rather than dt 0.5. Followed here so the figures mean the same
# thing as everybody else's.
NOMINAL_START = 0.5
@dataclass
class Waterfall:
"""The slot as power against time and frequency."""
power: np.ndarray # (times, bins), already in decibels
rate: float
hz_per_bin: float
seconds_per_step: float
@property
def times(self) -> int:
return self.power.shape[0]
@property
def bins(self) -> int:
return self.power.shape[1]
@dataclass
class Candidate:
"""Somewhere a transmission might start, and how much it looks like one."""
step: int # in half-symbols from the top of the slot
bin: int # in half-tones up the waterfall
score: float
def seconds(self, fall: Waterfall) -> float:
"""How late this started, against the nominal start of sending."""
return self.step * fall.seconds_per_step - NOMINAL_START
def hertz(self, fall: Waterfall) -> float:
return self.bin * fall.hz_per_bin
@dataclass
class Decode:
"""One transmission, read."""
text: str = ""
kind: str = ""
calls: tuple = ()
grid: str = ""
report: str = ""
calling: bool = False
hertz: float = 0.0
offset: float = 0.0 # how late it started, in seconds
snr_db: float = 0.0
score: float = 0.0
at: float = 0.0 # when the slot began, as a clock time
def waterfall(audio, rate: float) -> Waterfall:
"""The slot, as power against time and frequency.
One symbol of samples to a row, stepped half a symbol at a time, and
zero-padded to twice its length before the transform so the bins come
out half a tone apart. Padding rather than a longer window on purpose:
a longer window would average two symbols together, and the thing being
looked for changes every symbol.
"""
audio = np.asarray(audio, dtype=np.float32)
per_symbol = int(round(rate * SYMBOL_S))
if per_symbol < 8:
raise ValueError("the audio rate is too low to hold FT8 tones")
step = per_symbol // TIME_STEPS
size = per_symbol * FREQ_STEPS
window = np.hanning(per_symbol).astype(np.float32)
rows = 1 + max(0, (audio.size - per_symbol) // step)
if rows < 1:
raise ValueError("not enough audio for a single symbol")
# One strided view of the whole slot, transformed in a single call:
# a loop over two hundred rows in Python is most of the decode time.
frames = np.lib.stride_tricks.sliding_window_view(
audio, per_symbol)[::step][:rows]
spectrum = np.fft.rfft(frames * window, n=size, axis=1)
power = np.abs(spectrum).astype(np.float32)
# Decibels, because everything downstream adds and subtracts these and
# the useful comparisons are all ratios.
floor = max(float(np.median(power)) * 1e-4, 1e-12)
power = 20.0 * np.log10(np.maximum(power, floor))
return Waterfall(power=power, rate=float(rate),
hz_per_bin=rate / size,
seconds_per_step=step / rate)
def _sync_offsets():
"""Where the twenty-one sync tones sit, relative to a candidate."""
steps, bins = [], []
for block in SYNC_AT:
for k, tone in enumerate(COSTAS):
steps.append((block + k) * TIME_STEPS)
bins.append(tone * FREQ_STEPS)
return np.array(steps), np.array(bins)
SYNC_STEP, SYNC_BIN = _sync_offsets()
def candidates(fall: Waterfall, most: int = 300, lowest: float = 200.0,
highest: float = 3000.0, earliest: float = -2.5,
latest: float = 3.5) -> list[Candidate]:
"""Every place a transmission plausibly starts, best first.
Scored as how far the twenty-one sync tones stand above the other seven
tones at the same instants. Not above the noise floor of the band --
that would rank a strong signal's neighbours above a weak signal, and
the weak ones are the whole point of the protocol.
Only local peaks are kept. A real transmission lights up every
neighbouring offset as well, and without this one loud station would
fill the list and crowd out forty quiet ones.
"""
power = fall.power
span = TONES * TIME_STEPS
first = int(math.floor(earliest / fall.seconds_per_step))
last = int(math.ceil(latest / fall.seconds_per_step))
starts = np.arange(first, last + 1)
starts = starts[(starts >= 0) | (starts + SYNC_STEP.min() >= 0)]
starts = starts[starts + span <= power.shape[0]]
starts = starts[starts >= 0]
low = max(0, int(lowest / fall.hz_per_bin))
high = min(power.shape[1] - 8 * FREQ_STEPS,
int(highest / fall.hz_per_bin))
if starts.size == 0 or high <= low:
return []
tops = np.arange(low, high)
# The sync tones, and all eight tones at the same instants, for every
# (start, frequency) at once.
times = starts[:, None] + SYNC_STEP[None, :] # (S, 21)
here = power[times] # (S, 21, bins)
want = tops[None, None, :] + SYNC_BIN[None, :, None]
sync = np.take_along_axis(here, want, axis=2).mean(axis=1) # (S, tops)
whole = np.zeros_like(sync)
for tone in range(8):
idx = tops[None, None, :] + tone * FREQ_STEPS
whole += np.take_along_axis(here, idx, axis=2).mean(axis=1)
score = sync - whole / 8.0
# Local peaks only, in both directions.
keep = np.ones_like(score, dtype=bool)
keep[1:, :] &= score[1:, :] >= score[:-1, :]
keep[:-1, :] &= score[:-1, :] >= score[1:, :]
keep[:, 1:] &= score[:, 1:] >= score[:, :-1]
keep[:, :-1] &= score[:, :-1] >= score[:, 1:]
rows, cols = np.nonzero(keep)
values = score[rows, cols]
order = np.argsort(values)[::-1][:most]
return [Candidate(step=int(starts[rows[i]]), bin=int(tops[cols[i]]),
score=float(values[i])) for i in order]
def soft_bits(fall: Waterfall, spot: Candidate) -> np.ndarray:
"""How strongly each of the hundred and seventy-four bits is a zero.
Every symbol gives eight tone powers. A bit is a zero in four of the
eight tones and a one in the other four, so its evidence is the best of
the four against the best of the other four -- in decibels, which is
already a log-likelihood up to a scale factor, and the scale does not
matter to the min-sum decoding downstream.
Taking only the loudest tone and calling that three bits, which is the
obvious thing to do, throws away exactly the information the error
correction runs on, and decodes almost nothing.
"""
power = fall.power
steps = spot.step + np.arange(TONES) * TIME_STEPS
bins = spot.bin + np.arange(8) * FREQ_STEPS
if steps[-1] >= power.shape[0] or bins[-1] >= power.shape[1]:
return np.zeros(code.BITS, dtype=np.float32)
grid = power[np.ix_(steps, bins)] # (79, 8)
# Sync symbols carry nothing; drop them and put the tones back in the
# order the bits were in before the Gray mapping.
data = np.array([i for i in range(TONES)
if not (i < 7 or 36 <= i < 43 or 72 <= i)])
values = grid[data][:, list(code.GRAY)] # (58, 8) by value
out = np.zeros(code.BITS, dtype=np.float32)
for b in range(3):
mask = (np.arange(8) >> (2 - b)) & 1
zero = values[:, mask == 0].max(axis=1)
one = values[:, mask == 1].max(axis=1)
out[b::3] = zero - one
return out
# Worked out by measuring against transmissions of known strength and
# against off-air recordings with published readings, not derived. Turning
# a ratio of bins into the decibels-in-2500-Hz everybody quotes depends on
# the window, the padding and the shape of the noise, and calibrating it is
# both easier and more honest than deriving it and hoping.
SNR_TRIM = -37.0
# Making the transmitter above label its signals the way the reading below
# reports them. The reading is the one tied to reality -- it matches the
# published figures for the off-air recordings to within half a decibel
# across a hundred of them -- and a bare ratio of signal power to noise in
# 2500 Hz comes out eight decibels away from it, consistently enough that
# the difference is a definition rather than an error. Whose definition is
# "right" is not a question this can settle; what matters is that a signal
# built here at -15 reads as -15, so a test of sensitivity means what it
# says.
SNR_MATCHES = 10.0 ** (8.0 / 10.0)
SNR_NEAR_HZ = 200.0 # how far either side to look for the noise
SNR_PERCENTILE = 30 # low enough to ignore the neighbours
def _snr(fall: Waterfall, spot: Candidate, tones) -> float:
"""A signal-to-noise in the usual 2500 Hz reference bandwidth.
Measured in the tone that was actually sent, which is known by the time
this is called because the message decoded. Taking the loudest of the
eight instead looks like the same thing and is not: the largest of
eight noisy numbers is well above their mean even when there is no
signal at all, so weak transmissions all read as though they were
stronger than they are, and the scale compresses where it matters.
The noise is measured beside the signal rather than across the band. A
receiver with a three-kilohertz passband has nothing above it but the
noise floor of the sound card, and a median taken over the whole
spectrum sits twelve to sixteen decibels below the real one -- which is
a receiver flattering every station it hears by that much.
Good to a few decibels, honestly: the readings here track the published
ones for the same recordings with a correlation of about 0.83 and no
systematic bias between -25 and +10, which is the range that matters.
Very strong signals read a little low, as they do everywhere.
"""
power = (10.0 ** (fall.power / 20.0)) ** 2
steps = spot.step + np.arange(TONES) * TIME_STEPS
live = (steps >= 0) & (steps < power.shape[0])
steps, sent = steps[live], np.asarray(tones)[live]
bins = spot.bin + np.arange(8) * FREQ_STEPS
if steps.size == 0 or bins[-1] >= power.shape[1]:
return -99.0
near = int(SNR_NEAR_HZ / fall.hz_per_bin)
lo = max(0, spot.bin - near)
high = min(power.shape[1], bins[-1] + near)
columns = np.arange(lo, high)
# A guard either side of the signal: its own skirts are not noise.
beside = ((columns < spot.bin - 2 * FREQ_STEPS)
| (columns > bins[-1] + 2 * FREQ_STEPS))
if beside.sum() < 8:
return -99.0
noise = float(np.percentile(power[np.ix_(steps, columns[beside])],
SNR_PERCENTILE))
grid = power[np.ix_(steps, bins)]
here = float(grid[np.arange(grid.shape[0]), sent].mean())
if noise <= 0 or here <= noise:
return -99.0
return round(10.0 * math.log10((here - noise) / noise) + SNR_TRIM, 0)
def listen_to(audio, rate: float, book=None, most: int = 300,
rounds: int = 30, at: float = 0.0) -> list[Decode]:
"""Everything decodable in one slot of audio.
Candidates are decoded in one batch rather than one at a time: a busy
slot throws up two or three hundred of them, the error correction is
most of the work, and doing them together is the difference between a
slot that decodes in a second and one that does not keep up with the
air.
"""
book = book if book is not None else code.CallBook()
fall = waterfall(audio, rate)
spots = candidates(fall, most=most)
if not spots:
return []
beliefs = np.stack([soft_bits(fall, spot) for spot in spots])
repaired, worked = code.repair(beliefs, rounds=rounds)
out: list[Decode] = []
seen: dict[str, Decode] = {}
for i, spot in enumerate(spots):
if not worked[i]:
continue
bits = repaired[i]
if not code.crc_holds(bits[:code.PAYLOAD]):
continue
message = code.unpack(bits[:code.MESSAGE_BITS], book)
if not message.text:
continue
found = Decode(text=message.text, kind=message.kind,
calls=message.calls, grid=message.grid,
report=message.report, calling=message.calling,
hertz=round(spot.hertz(fall), 1),
offset=round(spot.seconds(fall), 2),
snr_db=_snr(fall, spot, code.tones_of(bits)),
score=spot.score, at=at)
# The same transmission is found at several neighbouring offsets.
# Keep the one with the best sync, which is the one nearest the
# truth about where and when it actually was.
was = seen.get(found.text)
if was is None or found.score > was.score:
seen[found.text] = found
out = sorted(seen.values(), key=lambda d: d.hertz)
for found in out:
for call in found.calls:
book.remember(call)
return out
def transmit(text: str, rate: float = 12000.0, hertz: float = 1000.0,
offset: float = 0.5, seconds: float = code.SLOT_S,
snr_db: float | None = None, seed: int = 0,
book=None) -> np.ndarray:
"""A slot of audio with one transmission in it.
Here rather than in a test because the tests need it, the simulator
needs it, and something that generates the signal this module claims to
understand is the only way to ask a question of the decoder whose answer
is known in advance -- where a transmission started, to the sample.
It does not transmit anything anywhere. It builds samples.
"""
payload = code.pack(text, book)
tones = code.tones_of(code.encode(code.with_crc(payload)))
per_symbol = int(round(rate * SYMBOL_S))
total = int(round(rate * seconds))
audio = np.zeros(total, dtype=np.float64)
# Continuous phase across the whole transmission. A tone that restarted
# its phase every symbol would splatter across the band, which is both
# unneighbourly on the air and not what a decoder should be tested with.
phase = 0.0
start = int(round(offset * rate))
for i, tone in enumerate(tones):
freq = hertz + float(tone) * code.SYMBOL_HZ
at = start + i * per_symbol
if at >= total or at + per_symbol <= 0:
phase += 2 * math.pi * freq * per_symbol / rate
continue
step = 2 * math.pi * freq / rate
angles = phase + step * np.arange(per_symbol)
phase = angles[-1] + step
first = max(0, at)
last = min(total, at + per_symbol)
audio[first:last] += np.sin(angles[first - at:last - at])
if snr_db is not None:
# The usual reference: the signal's own power against the noise in
# 2500 Hz, which is how every FT8 program quotes a report. The
# noise added here is white across the whole sampled band, so the
# part of it that lands in the reference 2500 Hz is that fraction
# of the total -- which is the step it is easy to get wrong, and
# getting it wrong makes a decoder look deaf when it is the test
# signal that was quietly attenuated.
rng = np.random.default_rng(seed)
signal = float(np.mean(audio[audio != 0] ** 2)) if audio.any() else 1.0
bandwidth = rate / 2.0
whole = (signal / (10.0 ** (snr_db / 10.0))
* (bandwidth / 2500.0) / SNR_MATCHES)
audio = audio + rng.standard_normal(total) * math.sqrt(whole)
return audio.astype(np.float32)

View file

@ -24,7 +24,8 @@ from .config import (DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH,
from .ranges import RangeError, ScanRange, parse_frequency from .ranges import RangeError, ScanRange, parse_frequency
__all__ = ["run_tui", "show_ranges", "settings_menu", "help_screen", __all__ = ["run_tui", "show_ranges", "settings_menu", "help_screen",
"aircraft_menu", "weather_menu", "aprs_menu", "first_run_setup", "aircraft_menu", "weather_menu", "aprs_menu", "ft8_menu",
"first_run_setup",
"TUIAbort"] "TUIAbort"]
_BACK = ("", "b", "back", "q", "quit", "x") _BACK = ("", "b", "back", "q", "quit", "x")
@ -1676,6 +1677,9 @@ def _main_loop(console: Console, cfg: ScanConfig) -> ScanConfig | None:
f" [cyan]7[/cyan] APRS (144 MHz packet) " f" [cyan]7[/cyan] APRS (144 MHz packet) "
f"[grey62]positions, weather and messages from amateurs" f"[grey62]positions, weather and messages from amateurs"
f"[/grey62]\n" f"[/grey62]\n"
f" [cyan]8[/cyan] FT8 "
f"[grey62]fifteen-second slots, whole bands at once, mostly "
f"under the noise[/grey62]\n"
f" [cyan]h[/cyan] Help\n" f" [cyan]h[/cyan] Help\n"
f" [cyan]s[/cyan] [bold green]Start scanning[/bold green]\n" f" [cyan]s[/cyan] [bold green]Start scanning[/bold green]\n"
f" [cyan]q[/cyan] Quit\n") f" [cyan]q[/cyan] Quit\n")
@ -1695,6 +1699,8 @@ def _main_loop(console: Console, cfg: ScanConfig) -> ScanConfig | None:
weather_menu(console, cfg) weather_menu(console, cfg)
elif choice == "7": elif choice == "7":
aprs_menu(console, cfg) aprs_menu(console, cfg)
elif choice == "8":
ft8_menu(console, cfg)
elif choice in ("h", "?", "help"): elif choice in ("h", "?", "help"):
help_screen(console) help_screen(console)
elif choice in ("s", "start", "go"): elif choice in ("s", "start", "go"):
@ -1706,3 +1712,195 @@ def _main_loop(console: Console, cfg: ScanConfig) -> ScanConfig | None:
return cfg return cfg
elif choice in ("q", "quit", "exit"): elif choice in ("q", "quit", "exit"):
return None return None
_FT8_INTRO = (
"[bold]FT8[/bold] is fifteen seconds of everybody at once. Every station "
"on the band transmits in the same quarter-minute slots, on the same "
"dial frequency, fifty hertz wide each, stacked across three kilohertz "
"of audio — so one receiver parked on one frequency hears the whole "
"band's worth of stations at the same time.\n\n"
"Most of them arrive [bold]below the noise[/bold], and decode anyway: "
"half of what is sent is error-correcting code, which is what buys a "
"mode that works twenty decibels under what an operator can hear.\n\n"
"Two things it needs. The [bold]clock[/bold] has to be right to a second "
"or two, because the slots are quarter-minutes of UTC and every station "
"on earth agrees about which one it is. And almost all the activity is "
"on [bold]shortwave[/bold], which a plain receiver of this kind cannot "
"reach without an upconverter or direct sampling — so the default here "
"is the two-metre channel, which it can.\n\n"
"Nothing here transmits. It listens, decodes and writes down."
)
def ft8_menu(console: Console, cfg: ScanConfig) -> None:
"""Listen to FT8, without a command line."""
from . import ft8
options = ft8.load_options()
while True:
_rule(console, "FT8")
console.print(Panel(Text.from_markup(_FT8_INTRO),
border_style="blue", padding=(0, 1)))
_option_groups(console, options, ft8)
logs = ft8.logs_in(cfg.output_dir)
kept = "no logs yet" if not logs else \
f"{len(logs)} log{'s' if len(logs) != 1 else ''}"
console.print(
f"\n [cyan]l[/cyan] [bold green]Listen[/bold green]"
f" [grey62]{ft8.describe(options)}[/grey62]\n"
# The band is the setting that decides whether anything is heard
# at all, and whether the receiver can reach it, so it sits here
# rather than a level down among the gain and the sample rate.
f" [cyan]c[/cyan] Band / channel "
f"[bold]{ft8.band_text(options)}[/bold]\n"
f" [cyan]g[/cyan] Where you are "
f"[grey62]{options.grid.upper() or 'not set — no distances'}"
f"[/grey62]\n"
f" [cyan]r[/cyan] Read a log back [grey62]{kept} in "
f"{cfg.output_dir}[/grey62]\n"
f" [cyan]N[/cyan] open group N "
f"[grey62]or type part of an option's name to find it[/grey62]\n"
f" [cyan]s[/cyan] Save these as default "
f"[grey62]kept in {ft8.options_path()}[/grey62]\n"
f" [cyan]d[/cyan] Reset them\n"
f" [cyan]b[/cyan] Back\n")
answer = _ask(console, " choice", "l").strip().lower()
if answer in _BACK:
return
if answer in ("l", "listen", "p"):
_ft8_listen(console, cfg, options)
elif answer in ("c", "channel", "band"):
_ft8_band(console, options)
elif answer in ("g", "grid", "where"):
_ft8_grid(console, options)
elif answer in ("r", "read", "m"):
_ft8_read(console, cfg, options, logs)
elif answer == "s":
try:
where = ft8.save_options(options)
console.print(f" [green]saved to {where}[/green]")
except OSError as exc:
console.print(f" [red]could not save: {exc}[/red]")
elif answer == "d":
if _confirm(" reset every FT8 option"):
options = ft8.Ft8Options()
console.print(" [green]reset[/green]")
elif answer.isdigit() and 1 <= int(answer) <= len(ft8.OPTION_GROUPS):
_option_group_menu(console, options,
ft8.OPTION_GROUPS[int(answer) - 1], ft8)
elif answer.lstrip("?").strip().isdigit():
_edit_option(console, options, answer, ft8)
elif answer:
found = _find_options(answer, ft8)
if not found:
console.print(f" [yellow]nothing matches {answer!r} — "
f"enter a group number, or l, c, g, r, s, d "
f"or b[/yellow]")
elif len(found) == 1:
_edit_option(console, options,
str(ft8.OPTIONS.index(found[0]) + 1), ft8)
else:
_option_list(console, options, found, f"matching {answer!r}",
ft8)
_pick_option(console, options, ft8)
def _ft8_listen(console: Console, cfg: ScanConfig, options) -> None:
from . import ft8
errs = options.validate()
if errs:
for e in errs:
console.print(f" [red]{e}[/red]")
return
try:
ft8.listen(console, options, cfg.output_dir)
except Exception as exc: # a menu must survive it
console.print(f" [red]{exc}[/red]")
def _ft8_band(console: Console, options) -> None:
"""Choose the band, which sets the dial frequency with it.
Which band is also the question of whether the receiver can hear it at
all, so the list says so rather than leaving somebody to find out by
listening to silence for ten minutes.
"""
from . import ft8
console.print("\n [bold]Which band[/bold]\n")
for i, (name, hz, needs) in enumerate(ft8.BANDS, 1):
note = ("[yellow]needs an upconverter or direct sampling[/yellow]"
if needs == "shortwave"
else "[green]a plain receiver reaches this[/green]")
here = " [green]<- now[/green]" if options.band == name else ""
console.print(f" [cyan]{i:>2}[/cyan] {name:<5} "
f"{hz / 1e6:>10.3f} MHz {note}{here}")
console.print("\n [grey62]Almost all the activity is on shortwave, and "
"20m is the busiest band on earth. Two metres is quiet by "
"comparison and is what a plain dongle can reach.[/grey62]")
answer = _ask(console, "\n band (number, name, or blank to keep)",
"").strip().lower()
if not answer:
return
if answer.isdigit() and 1 <= int(answer) <= len(ft8.BANDS):
ft8.use_band(options, ft8.BANDS[int(answer) - 1][0])
elif any(answer == name for name, _hz, _n in ft8.BANDS):
ft8.use_band(options, answer)
else:
console.print(f" [yellow]no band called {answer!r}[/yellow]")
return
console.print(f" [green]{ft8.band_text(options)}[/green]")
def _ft8_grid(console: Console, options) -> None:
"""Set the grid square, which is what distances are measured from."""
from . import ft8
console.print("\n [grey62]Your Maidenhead grid square: four characters "
"like IO91 or FN31. Every station calling CQ says where it "
"is this way, so with yours filled in the report can say "
"how far each of them is and on what bearing — which on "
"shortwave is the whole interest of the thing.[/grey62]")
answer = _ask(console, "\n grid square (blank to clear)",
options.grid).strip()
if not answer:
options.grid = ""
console.print(" [green]cleared — no distances[/green]")
return
if ft8.grid_at(answer) is None:
console.print(f" [yellow]{answer!r} is not a grid square[/yellow]")
return
options.grid = answer.upper()
lat, lon = ft8.grid_at(options.grid)
console.print(f" [green]{options.grid} — {lat:.2f},{lon:.2f}[/green]")
def _ft8_read(console: Console, cfg: ScanConfig, options, logs) -> None:
"""Read an evening's decodes back off the disk."""
from . import ft8, ft8log
if not logs:
console.print(" [yellow]no FT8 logs yet — listen first[/yellow]")
return
console.print("\n [bold]Which log[/bold]\n")
for i, path in enumerate(logs[-20:], 1):
console.print(f" [cyan]{i:>2}[/cyan] {path.name}")
answer = _ask(console, "\n log (number, or blank to go back)",
"").strip()
if not answer.isdigit():
return
picked = logs[-20:][int(answer) - 1] if 1 <= int(answer) <= len(logs[-20:]) \
else None
if picked is None:
return
rows = ft8log.read_log(picked)
console.print(f"\n [grey62]{len(rows)} decodes in {picked.name}"
f"[/grey62]\n")
for row in rows[:40]:
console.print(f" {row['snr_db']:>4.0f} {row['offset']:>5.1f} "
f"{row['hertz']:>7.1f} ~ {row['text']}")
if len(rows) > 40:
console.print(f" [grey62]…and {len(rows) - 40} more[/grey62]")

View file

@ -1071,3 +1071,118 @@ class AprsDisplay:
if not out.plain: if not out.plain:
out.append(station.kind, style="grey62") out.append(station.kind, style="grey62")
return out return out
class Ft8Display:
"""The last slot's decodes, above a table of who has been heard.
Two things at once because FT8 is two things at once. A slot is an
event -- forty stations transmitted, here is what they said -- and the
evening is an accumulation. A display that showed only the latest slot
would throw away the band; one that showed only the running table would
never show the thing that just happened.
"""
def __init__(self, console: Console, hold: float = 3600.0,
imperial: bool = False, grid: str = "", band: str = ""):
self.console = console
self.hold = hold
self.imperial = imperial
self.grid = grid
self.band = band
self.started = time.time()
self.heard = None
self.slot_at = 0.0
def update(self, heard, slot_at: float = 0.0) -> None:
self.heard = heard
if slot_at:
self.slot_at = slot_at
def showing(self, now: float | None = None) -> list:
if self.heard is None:
return []
now = time.time() if now is None else now
return [s for s in self.heard.band.all()
if now - s.last <= self.hold]
def render(self, now: float | None = None, width: int | None = None):
now = time.time() if now is None else now
parts = [self._header(now)]
latest = list(getattr(self.heard, "latest", []) or [])
if latest:
parts.append(self._slot(latest))
here = self.showing(now)
if here:
parts.append(self._table(here, now))
elif not latest:
parts.append(Panel(_one_line(
"[grey62]nothing decoded yet — the first decodes arrive "
"when the slot after next ends, up to thirty seconds away. "
"If nothing comes at all, check the clock: FT8 needs it "
"right to a second or two[/grey62]"),
border_style="grey37", padding=(0, 1)))
return Group(*parts)
def _header(self, now: float):
heard = self.heard
slots = getattr(heard, "slots", 0)
decodes = getattr(heard, "decodes", 0)
stations = len(getattr(heard, "band", None).stations) if heard else 0
elapsed = max(1e-9, now - self.started)
bits = [f"[bold]FT8[/bold] {self.band}",
f"{slots} slots",
f"{decodes} decodes",
f"{stations} stations"]
if slots:
bits.append(f"{decodes / max(1, slots):.1f} a slot")
bits.append(_dur(elapsed))
if self.grid:
bits.append(f"from {self.grid.upper()}")
return Text.from_markup(" ".join(f"[grey62]{b}[/grey62]"
if i else b
for i, b in enumerate(bits)))
def _slot(self, latest):
table = Table(box=None, header_style="bold", pad_edge=False,
title=f"last slot — {len(latest)} decoded",
title_justify="left", title_style="bold")
table.add_column("snr", justify="right")
table.add_column("dt", justify="right")
table.add_column("hz", justify="right")
table.add_column("message")
for d in latest[:24]:
table.add_row(f"{d.snr_db:.0f}", f"{d.offset:+.1f}",
f"{d.hertz:.0f}",
f"[bold]{d.text}[/bold]" if d.calling else d.text)
return table
def _table(self, here, now: float):
from .ft8 import grid_away
here = sorted(here, key=lambda s: (-s.last, s.call))
table = Table(box=None, header_style="bold", pad_edge=False,
title=f"heard so far — {len(here)} stations",
title_justify="left", title_style="bold")
table.add_column("station")
table.add_column("grid")
if self.grid:
table.add_column("away", justify="right")
table.add_column("n", justify="right")
table.add_column("best", justify="right")
table.add_column("hz", justify="right")
table.add_column("last", justify="right")
for s in here[:20]:
row = [s.call, s.grid or "—"]
if self.grid:
away = grid_away(self.grid, s.grid) if s.grid else None
if away is None:
row.append("—")
elif self.imperial:
row.append(f"{away[0] * 0.621371:.0f} mi")
else:
row.append(f"{away[0]:.0f} km")
row += [str(s.decodes), f"{s.best_snr:.0f}", f"{s.hertz:.0f}",
f"{now - s.last:.0f}s"]
table.add_row(*row)
return table

View file

@ -1,5 +1,5 @@
.\" Generated by packaging/make-man.py -- do not edit by hand. .\" Generated by packaging/make-man.py -- do not edit by hand.
.TH BANDSAUNTER 1 "2026-09-21" "bandsaunter 2026-09-21_03" "User Commands" .TH BANDSAUNTER 1 "2026-09-21" "bandsaunter 2026-09-21_04" "User Commands"
.SH NAME .SH NAME
bandsaunter \- scan, record and identify radio signals with an RTL-SDR bandsaunter \- scan, record and identify radio signals with an RTL-SDR
.SH SYNOPSIS .SH SYNOPSIS
@ -2958,6 +2958,196 @@ Also write a map \[em] write the stations and their tracks for Google Earth.
.br .br
Setting name \fBkml\fR, default \fBno\fR. Setting name \fBkml\fR, default \fBno\fR.
.PP .PP
.SH FT8
Fifteen seconds of everybody at once. Every station on the band transmits in
the same quarter-minute slots, on the same dial frequency, fifty hertz wide
each, stacked across three kilohertz of audio \[em] so one receiver parked on
one frequency hears the whole band's worth of stations at the same time, and
hears most of them well below the noise.
.PP
Half of what is transmitted is error-correcting code, and that is the trick:
it is what buys a mode that decodes twenty-odd decibels under what an operator
can hear. A receiver that took the loudest tone of each symbol and hoped would
decode almost nothing, which is why the tone detector reports how confident it
is bit by bit rather than what it thinks it heard.
.SS What it needs
The clock has to be right to a second or two. The slots are quarter-minutes of
UTC and every station on earth agrees about which one it is. A receiver a
second out still decodes; one a slot out hears every transmission split across
two captures and decodes none of them. This is the one failure that looks
exactly like a dead band, so the display and the report both say so when
nothing arrives.
.PP
Almost all the activity is on shortwave, which a plain receiver of this kind
cannot reach. The default is therefore the two-metre channel at 144.174 MHz,
which it can. All thirteen channels are in the list and the shortwave ones
work through an upconverter or a receiver in direct sampling mode; the menu
says which is which rather than leaving somebody to find out by listening to
silence.
.SS What comes out
.BI \-\-grid " SQUARE"
is what turns decodes into geography: every station calling CQ says where it
is, so with your own square filled in each gets a distance and a bearing and
the furthest heard is named. Three tables afterwards \[em] stations heard,
calling CQ, and who was working whom.
.PP
.B \-\-adif
writes the log again in the form every amateur logging program imports,
marked as heard rather than worked. Nothing here transmits, so nothing here is
a contact, and an ADIF that let a logging program treat these as worked would
put claims into somebody's log that they cannot make.
.SS How well it works
Checked against eleven off-air recordings with published decodes, which is the
only honest way to test a decoder: an encoder tested against its own decoder
agrees with it about anything they are both wrong about. Ninety-seven of a
hundred and fifty messages, with no false decodes; timing within a hundredth
of a second, frequency within a hertz, signal reports within half a decibel on
average. The third not decoded are the weakest in each slot: a mature decoder
subtracts what it has decoded and looks again in the remainder, which is not
built here.
.SH FT8 OPTIONS
Every option the FT8 side takes, in the five groups the menu shows them in.
Each is a flag here and a line in the menu, and both come from one table in
the program, so they cannot disagree.
.SS Receiver
.TP
.B --device
Receiver \[em] which receiver to use, when more than one is plugged in.
.br
Setting name \fBdevice\fR, default \fB0\fR.
.br
Accepts: at least 0.
.TP
.B --gain
Gain \[em] tuner gain in dB, or automatic.
.br
Setting name \fBgain\fR, default \fBauto\fR.
.TP
.B --rate
Sample rate \[em] how fast to sample; 96 kS/s is the least that holds the channel (Hz).
.br
Setting name \fBrate\fR, default \fB240 kHz\fR.
.br
Accepts: at least 96000.
.TP
.B --band
Band \[em] which FT8 channel to listen on.
.br
Setting name \fBband\fR, default \fB2m\fR.
.br
Accepts: one of: 160m, 80m, 60m, 40m, 30m, 20m, 17m, 15m, 12m, 10m, 6m, 2m, 70cm.
.TP
.B --frequency --freq
Dial frequency \[em] the dial frequency, if you want one the band list does not have (Hz).
.br
Setting name \fBfrequency\fR, default \fB144.174 MHz\fR.
.br
Accepts: at least 100000, at most 2e+09.
.TP
.B --simulate / --no-simulate
Simulate \[em] invent a band instead of using a receiver.
.br
Setting name \fBsimulate\fR, default \fBno\fR.
.PP
.SS Listening
.TP
.B --seconds
Listen for \[em] how long to listen; zero means until stopped (s).
.br
Setting name \fBseconds\fR, default \fBuntil stopped\fR.
.br
Accepts: at least 0.
.TP
.B --slots
Or this many slots \[em] stop after this many fifteen-second slots; zero means no limit.
.br
Setting name \fBslots\fR, default \fBno limit\fR.
.br
Accepts: at least 0.
.TP
.B --log / --no-log
Write a log \[em] keep every decode in a file.
.br
Setting name \fBlog\fR, default \fByes\fR.
.TP
.B --decodes-seen / --no-decodes-seen
Print each decode \[em] a line per decode instead of a table that refreshes.
.br
Setting name \fBdecodes_seen\fR, default \fBno\fR.
.TP
.B --hold
Keep on display for \[em] how long a station stays on the table after its last decode (s).
.br
Setting name \fBhold\fR, default \fB3600 s\fR.
.br
Accepts: at least 1.
.PP
.SS Decoding
.TP
.B --lowest
Search from \[em] the lowest audio frequency to look for signals at (Hz).
.br
Setting name \fBlowest\fR, default \fB200 Hz\fR.
.br
Accepts: at least 0.
.TP
.B --highest
Search to \[em] the highest audio frequency to look for signals at (Hz).
.br
Setting name \fBhighest\fR, default \fB3 kHz\fR.
.br
Accepts: at least 100.
.TP
.B --most
Candidates per slot \[em] how many possible transmissions to try to decode each slot.
.br
Setting name \fBmost\fR, default \fB300\fR.
.br
Accepts: at least 1.
.TP
.B --rounds
Repair passes \[em] how many passes of error correction to make before giving up.
.br
Setting name \fBrounds\fR, default \fB30\fR.
.br
Accepts: at least 1.
.PP
.SS Showing
.TP
.B --grid
Aerial at \[em] your own grid square, so distances can be worked out.
.br
Setting name \fBgrid\fR, default \fBnot set, so no distances\fR.
.TP
.B --units
Show readings in \[em] metric or imperial, for the display and the export.
.br
Setting name \fBunits\fR, default \fBmetric\fR.
.br
Accepts: one of: metric, imperial.
.TP
.B --calls-only / --no-calls-only
Callsigns only \[em] leave out free text and telemetry.
.br
Setting name \fBcalls_only\fR, default \fBno\fR.
.PP
.SS Afterwards
.TP
.B --report / --no-report
Report at the end \[em] print the tables when the listening stops.
.br
Setting name \fBreport\fR, default \fByes\fR.
.TP
.B --csv / --no-csv
Also write a CSV \[em] a spreadsheet of every decode beside the log.
.br
Setting name \fBcsv\fR, default \fBno\fR.
.TP
.B --adif / --no-adif
Also write an ADIF \[em] the log again, in the form logging programs read.
.br
Setting name \fBadif\fR, default \fBno\fR.
.PP
.SH FILES .SH FILES
.TP .TP
.I ~/.config/bandsaunter/config.yaml .I ~/.config/bandsaunter/config.yaml
@ -3090,6 +3280,16 @@ terms, in
or at or at
.UR https://www.gnu.org/licenses/ .UR https://www.gnu.org/licenses/
.UE . .UE .
.PP
One file is not original work.
.I ft8tables.py
holds the two fixed tables that define the FT8 error-correcting code, taken
from ft8_lib (https://github.com/kgoba/ft8_lib), MIT licensed, copyright 2018
K\[u0101]rlis Goba, which took them in turn from WSJT\-X. They are reproduced
under the MIT terms and that file carries the attribution they ask for. They
are there because they cannot be derived: everything else about FT8 here is
worked out from first principles, but those two tables are the code itself,
chosen once by its designers and published. No decoding logic was taken.
.SH BUGS .SH BUGS
The DVB-T television driver claims these dongles on sight. If the receiver The DVB-T television driver claims these dongles on sight. If the receiver
cannot be opened, that is almost always why: the package blacklists the cannot be opened, that is almost always why: the package blacklists the

View file

@ -77,6 +77,13 @@ def aprs_section() -> list[str]:
return options_section(ap) return options_section(ap)
def ft8_section() -> list[str]:
"""Every FT8 option, from the same table again."""
from bandsaunter import ft8
return options_section(ft8)
def options_section(air) -> list[str]: def options_section(air) -> list[str]:
"""One section's options, written out from the table the program uses. """One section's options, written out from the table the program uses.
@ -1792,6 +1799,58 @@ Every option the APRS side takes, in the four groups the menu shows them in.
Each is a flag here and a line in the menu, and both come from one table in Each is a flag here and a line in the menu, and both come from one table in
the program, so they cannot disagree. the program, so they cannot disagree.
.APRS_OPTIONS_HERE .APRS_OPTIONS_HERE
.SH FT8
Fifteen seconds of everybody at once. Every station on the band transmits in
the same quarter-minute slots, on the same dial frequency, fifty hertz wide
each, stacked across three kilohertz of audio \[em] so one receiver parked on
one frequency hears the whole band's worth of stations at the same time, and
hears most of them well below the noise.
.PP
Half of what is transmitted is error-correcting code, and that is the trick:
it is what buys a mode that decodes twenty-odd decibels under what an operator
can hear. A receiver that took the loudest tone of each symbol and hoped would
decode almost nothing, which is why the tone detector reports how confident it
is bit by bit rather than what it thinks it heard.
.SS What it needs
The clock has to be right to a second or two. The slots are quarter-minutes of
UTC and every station on earth agrees about which one it is. A receiver a
second out still decodes; one a slot out hears every transmission split across
two captures and decodes none of them. This is the one failure that looks
exactly like a dead band, so the display and the report both say so when
nothing arrives.
.PP
Almost all the activity is on shortwave, which a plain receiver of this kind
cannot reach. The default is therefore the two-metre channel at 144.174 MHz,
which it can. All thirteen channels are in the list and the shortwave ones
work through an upconverter or a receiver in direct sampling mode; the menu
says which is which rather than leaving somebody to find out by listening to
silence.
.SS What comes out
.BI \-\-grid " SQUARE"
is what turns decodes into geography: every station calling CQ says where it
is, so with your own square filled in each gets a distance and a bearing and
the furthest heard is named. Three tables afterwards \[em] stations heard,
calling CQ, and who was working whom.
.PP
.B \-\-adif
writes the log again in the form every amateur logging program imports,
marked as heard rather than worked. Nothing here transmits, so nothing here is
a contact, and an ADIF that let a logging program treat these as worked would
put claims into somebody's log that they cannot make.
.SS How well it works
Checked against eleven off-air recordings with published decodes, which is the
only honest way to test a decoder: an encoder tested against its own decoder
agrees with it about anything they are both wrong about. Ninety-seven of a
hundred and fifty messages, with no false decodes; timing within a hundredth
of a second, frequency within a hertz, signal reports within half a decibel on
average. The third not decoded are the weakest in each slot: a mature decoder
subtracts what it has decoded and looks again in the remainder, which is not
built here.
.SH FT8 OPTIONS
Every option the FT8 side takes, in the five groups the menu shows them in.
Each is a flag here and a line in the menu, and both come from one table in
the program, so they cannot disagree.
.FT8_OPTIONS_HERE
.SH FILES .SH FILES
.TP .TP
.I ~/.config/bandsaunter/config.yaml .I ~/.config/bandsaunter/config.yaml
@ -1924,6 +1983,16 @@ terms, in
or at or at
.UR https://www.gnu.org/licenses/ .UR https://www.gnu.org/licenses/
.UE . .UE .
.PP
One file is not original work.
.I ft8tables.py
holds the two fixed tables that define the FT8 error-correcting code, taken
from ft8_lib (https://github.com/kgoba/ft8_lib), MIT licensed, copyright 2018
K\[u0101]rlis Goba, which took them in turn from WSJT\-X. They are reproduced
under the MIT terms and that file carries the attribution they ask for. They
are there because they cannot be derived: everything else about FT8 here is
worked out from first principles, but those two tables are the code itself,
chosen once by its designers and published. No decoding logic was taken.
.SH BUGS .SH BUGS
The DVB-T television driver claims these dongles on sight. If the receiver The DVB-T television driver claims these dongles on sight. If the receiver
cannot be opened, that is almost always why: the package blacklists the cannot be opened, that is almost always why: the package blacklists the
@ -1944,6 +2013,7 @@ def main() -> int:
text = text.replace(".WEATHER_OPTIONS_HERE", text = text.replace(".WEATHER_OPTIONS_HERE",
"\n".join(weather_section())) "\n".join(weather_section()))
text = text.replace(".APRS_OPTIONS_HERE", "\n".join(aprs_section())) text = text.replace(".APRS_OPTIONS_HERE", "\n".join(aprs_section()))
text = text.replace(".FT8_OPTIONS_HERE", "\n".join(ft8_section()))
text = text.replace("\n\n", "\n") # troff dislikes blank lines text = text.replace("\n\n", "\n") # troff dislikes blank lines
target = Path(sys.argv[1] if len(sys.argv) > 1 target = Path(sys.argv[1] if len(sys.argv) > 1
else Path(__file__).parent / "bandsaunter.1") else Path(__file__).parent / "bandsaunter.1")

839
tests/test_ft8.py Normal file
View file

@ -0,0 +1,839 @@
"""The FT8 section: the code, the radio, the options and the front ends.
The important thing about this file is where its truth comes from. An
encoder tested against its own decoder agrees with it about anything they
are both wrong about -- this project learned that expensively on 433 MHz --
so the decoding here is checked against signals that came off the air and
were decoded by somebody else's program, not against anything written here.
Those recordings live outside the repository, and the tests that use them
skip when they are absent. What does not skip is REAL: whole codewords
lifted off the air, which pin the checksum, the parity and every field of
the message format to reality in forty-four characters apiece.
"""
import math
import time
import wave
from pathlib import Path
import numpy as np
import pytest
from bandsaunter import ft8, ft8code as code, ft8log, ft8sim, ft8wave
from bandsaunter import ft8tables as tables
REFERENCE = Path("/mnt/global/bandsaunter/ft8ref")
needs_recordings = pytest.mark.skipif(
not REFERENCE.is_dir(), reason="the off-air recordings are not here")
# Whole codewords, off the air, with what they say. A hundred and
# seventy-four bits each: seventy-seven of message, fourteen of checksum and
# eighty-three of parity, so one of these exercises the lot.
REAL = [
("B79A7EA67655CA92DB4D342FDD55B071779A631CFB4C",
"PA3EPP SP8NFO KN09", "standard"),
("0000002046E853111D48153FD733DA62C110A5E053FC",
"CQ F4FSY JN25", "standard"),
("E20AFC7590F9DA9FA7C9EEEA8A1A96D06F6855C82B58",
"VK4BLE OH1EDK -20", "standard"),
("70D84EAC55E5FB9FA70CD7148B9180DD1CD36A7EFC6C",
"ET3RFG/R IN3ADG -23", "standard"),
("1E6191F629601212F10D6992973A9B5AEA0596877498",
"2M0OGG RA6ABO KN96", "standard"),
("70AFEC9338AD521FA50DE077F5C94CAD362B2CC3A364",
"ES5GI DD3SF 73", "standard"),
("00000024519BF311248987ED8D2C4AA596CE262E1E68",
"CQ IK4LZH JN54", "standard"),
("4B9ACCF4519BF31FAA4F0C50A7B6CEA7F9C51BED8E80",
"9A9TT IK4LZH -10", "standard"),
("0B13C7046826781FA50F539DFE6D9FAF63F7C21EC268",
"R2EA IZ4OUL 73", "standard"),
("CBEC04F45AC9D49FA509770E71FEDE65EE2EFE66E8A8",
"SA5QED IQ5PJ 73", "standard"),
]
def bits_of_hex(text: str) -> np.ndarray:
raw = np.unpackbits(np.frombuffer(bytes.fromhex(text), dtype=np.uint8))
return raw[:code.BITS].astype(np.uint8)
# ---------------------------------------------------------------------------
# The two tables the code is defined by
# ---------------------------------------------------------------------------
def test_the_generator_and_the_parity_matrix_describe_the_same_code():
"""The two published tables are not derivable from one another -- the
generator's parity half runs to fifty-odd bits a row and the sparse one
to six or seven -- so that they agree is checked rather than assumed.
If they disagreed, every codeword this builds would fail every check
and nothing would ever decode, which is a long way to find out."""
rng = np.random.default_rng(11)
for _ in range(50):
message = rng.integers(0, 2, code.PAYLOAD).astype(np.uint8)
assert code.parity_holds(code.encode(message))
def test_every_codeword_bit_is_checked_three_times():
"""A regular column weight, which is what the message passing assumes
when it gathers three slots per bit instead of scattering."""
assert code.SLOTS_OF_BIT.shape == (code.BITS, 3)
assert sorted({len(c) for c in tables.CHECKS}) == [6, 7]
def test_a_broken_codeword_fails_its_parity():
rng = np.random.default_rng(3)
word = code.encode(rng.integers(0, 2, code.PAYLOAD).astype(np.uint8))
for flip in (0, 40, 90, 173):
bad = word.copy()
bad[flip] ^= 1
assert not code.parity_holds(bad)
# ---------------------------------------------------------------------------
# Reality
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("packed,text,kind", REAL)
def test_a_codeword_off_the_air_says_what_it_said(packed, text, kind):
"""The whole chain against something nobody here made up."""
word = bits_of_hex(packed)
assert code.parity_holds(word), "the parity of a real transmission"
assert code.crc_holds(word[:code.PAYLOAD]), "the checksum of a real one"
message = code.unpack(word[:code.MESSAGE_BITS])
assert message.text == text
assert message.kind == kind
def test_a_real_codeword_is_repaired_from_a_damaged_copy():
"""What the error correction is for, on a real transmission rather than
a made-up one.
Ten of the hundred and seventy-four bits, each of them *confidently*
wrong, which is the hard case: a bit the detector is unsure about costs
the decoding far less than one it is certain about and mistaken. Real
errors arrive uncertain, which is why the same code reads signals off
the air with far more than ten bits astray."""
word = bits_of_hex(REAL[0][0])
rng = np.random.default_rng(5)
belief = np.where(word == 1, -4.0, 4.0).astype(np.float32)
for flip in rng.choice(code.BITS, 10, replace=False):
belief[flip] = -belief[flip]
got = code.repair(belief)
assert got is not None and (got == word).all()
assert code.unpack(got[:code.MESSAGE_BITS]).text == REAL[0][1]
def test_noise_does_not_decode_into_a_message():
"""The checksum's job. The error correction will converge on *a*
codeword given enough noise, and the fourteen bits are what stop that
becoming a line in somebody's log."""
rng = np.random.default_rng(17)
lied = 0
for _ in range(200):
belief = rng.standard_normal(code.BITS).astype(np.float32) * 2.0
got = code.repair(belief)
if got is not None and code.crc_holds(got[:code.PAYLOAD]):
lied += 1
# One in sixteen thousand gets through by arithmetic; two hundred tries
# should see none, and seeing several would mean the CRC is not being
# checked at all.
assert lied == 0, f"{lied} runs of noise passed the checksum"
# ---------------------------------------------------------------------------
# The checksum and the coding
# ---------------------------------------------------------------------------
def test_the_checksum_covers_the_message_zero_extended():
"""Eighty-two bits, not seventy-seven -- which is not the same answer
and is the sort of thing that decodes nothing at all."""
payload = np.zeros(code.MESSAGE_BITS, dtype=np.uint8)
payload[3] = payload[70] = 1
block = code.with_crc(payload)
assert block.size == code.PAYLOAD
assert code.crc_holds(block)
broken = block.copy()
broken[10] ^= 1
assert not code.crc_holds(broken)
def test_the_tones_carry_the_bits_and_the_sync_is_where_it_should_be():
rng = np.random.default_rng(2)
word = code.encode(rng.integers(0, 2, code.PAYLOAD).astype(np.uint8))
tones = code.tones_of(word)
assert tones.size == code.TONES
assert (code.bits_of(tones) == word).all()
for start in (0, 36, 72):
assert list(tones[start:start + 7]) == list(tables.COSTAS)
def test_a_tone_mistaken_for_its_neighbour_costs_one_bit():
"""Which is the whole reason for the Gray mapping, and is worth a test
because getting the map backwards still round-trips perfectly."""
for tone in range(7):
a = code.UNGRAY[tone]
b = code.UNGRAY[tone + 1]
assert bin(int(a) ^ int(b)).count("1") == 1
# ---------------------------------------------------------------------------
# Messages
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("text", [
"CQ DL1UDO JO31", "CQ DX Z33Z KN11", "CQ JA OH1LWZ KP11",
"VK4BLE OH8JK R-17", "JR5MJS OH8NW 73", "LZ1CWK DC8VA RR73",
"G4CUS SP4FCA +10", "2M0OGG RA6ABO KN96", "YO7CGS A41ZZ -11",
"R2EA IZ4OUL R-08", "W2XYZ K1ABC RRR",
])
def test_a_standard_message_survives_the_whole_chain(text):
payload = code.pack(text)
word = code.encode(code.with_crc(payload))
assert code.parity_holds(word)
back = code.bits_of(code.tones_of(word))
assert (back == word).all()
got = code.repair(np.where(word == 1, -4.0, 4.0).astype(np.float32))
assert got is not None and (got == word).all()
assert code.unpack(got[:code.MESSAGE_BITS]).text == text
def test_the_digit_of_a_callsign_is_found_rather_than_guessed_at():
"""Z33Z has a digit in the second place that belongs to the prefix, so
a packer that assumes the second character is the separating digit
turns it into a hash and loses the callsign."""
assert code.is_standard_call("Z33Z")
assert code.is_standard_call("K1ABC")
assert code.is_standard_call("DL1UDO")
assert code.is_standard_call("2M0OGG")
assert not code.is_standard_call("ET3RFG/R")
assert not code.is_standard_call("")
def test_free_text_is_not_dressed_up_as_two_callsigns():
"""A packer that falls back to hashing any word it does not recognise
always succeeds, and would send HELLO WORLD as two hashed callsigns."""
got = code.unpack(code.pack("HELLO WORLD"))
assert got.kind == "free text"
assert got.text == "HELLO WORLD"
def test_free_text_is_thirteen_characters_and_says_so_by_truncating():
got = code.unpack(code.pack("TNX FER QSO 73"))
assert got.kind == "free text"
assert len(got.text) <= 13
@pytest.mark.parametrize("text,grid,report,calling", [
("CQ DL1UDO JO31", "JO31", "", True),
("VK4BLE OH8JK R-17", "", "R-17", False),
("JR5MJS OH8NW 73", "", "73", False),
("W2XYZ K1ABC RRR", "", "RRR", False),
])
def test_what_can_be_picked_out_of_a_message(text, grid, report, calling):
got = code.unpack(code.pack(text))
assert got.grid == grid and got.report == report
assert got.calling is calling
def test_a_hashed_callsign_stays_unknown_until_it_has_been_heard_in_full():
"""A wrong callsign in a log is worse than a missing one, so a hash
that has not been heard spelled out stays <...> rather than guessed."""
book = code.CallBook()
assert book.look_up(code.hash_of("GM4ABC", 22), 22) == "<...>"
book.remember("GM4ABC")
assert book.look_up(code.hash_of("GM4ABC", 22), 22) == "GM4ABC"
assert book.look_up(code.hash_of("GM4ABD", 22), 22) == "<...>"
def test_a_callsign_too_short_to_mean_anything_is_not_remembered():
book = code.CallBook()
for rubbish in ("", "A", "CQ", "DE", "QRZ"):
book.remember(rubbish)
assert book.by_hash == {}
# ---------------------------------------------------------------------------
# The radio
# ---------------------------------------------------------------------------
def test_a_transmission_is_found_where_it_was_put():
"""The decoder's own clock and dial, against a signal whose time and
frequency are known to the sample."""
for hertz in (500.0, 1200.0, 2400.0):
for offset in (0.2, 0.5, 1.4):
audio = ft8wave.transmit("CQ DL1UDO JO31", hertz=hertz,
offset=offset)
got = ft8wave.listen_to(audio, 12000.0)
assert len(got) == 1, (hertz, offset)
assert got[0].text == "CQ DL1UDO JO31"
assert abs(got[0].hertz - hertz) < 3.5
# Reported against the nominal start of sending, half a second
# into the slot, which is what every FT8 program reports.
assert abs(got[0].offset - (offset - 0.5)) < 0.12
def test_the_nominal_start_is_half_a_second_into_the_slot():
"""A station whose clock is right reports as dt 0.0, not dt 0.5: the
transmission is 12.64 seconds long in a slot of fifteen and everybody
begins half a second in."""
audio = ft8wave.transmit("CQ DL1UDO JO31", offset=ft8wave.NOMINAL_START)
got = ft8wave.listen_to(audio, 12000.0)
assert got and abs(got[0].offset) < 0.1
def test_several_transmissions_in_one_slot_all_come_out():
"""Which is the whole point of the mode: they overlap in time entirely
and are told apart by frequency alone."""
said = [("CQ DL1UDO JO31", 500.0), ("VK4BLE OH8JK R-17", 1000.0),
("JR5MJS OH8NW 73", 1600.0), ("CQ DX Z33Z KN11", 2300.0)]
audio = np.zeros(int(12000 * code.SLOT_S), dtype=np.float32)
for text, hertz in said:
audio += ft8wave.transmit(text, hertz=hertz, offset=0.5)
got = {d.text for d in ft8wave.listen_to(audio, 12000.0)}
assert got == {t for t, _hz in said}
def test_a_slot_of_noise_decodes_to_nothing():
rng = np.random.default_rng(9)
noise = rng.standard_normal(int(12000 * code.SLOT_S)).astype(np.float32)
assert ft8wave.listen_to(noise, 12000.0) == []
def test_it_hears_a_signal_well_below_the_noise():
"""The claim the mode is built on. Fifteen decibels under is a signal
an operator hears nothing of at all."""
heard = 0
for seed in range(5):
audio = ft8wave.transmit("CQ DL1UDO JO31", hertz=1200.0, offset=0.5,
snr_db=-15.0, seed=seed)
got = ft8wave.listen_to(audio, 12000.0)
heard += any(d.text == "CQ DL1UDO JO31" for d in got)
assert heard == 5
def test_the_signal_report_tracks_the_signal():
"""Not to a decibel -- it is an estimate -- but a stronger signal has
to read stronger, or the number is worse than not printing one."""
readings = []
for want in (-15.0, -5.0, 5.0):
audio = ft8wave.transmit("CQ DL1UDO JO31", hertz=1200.0, offset=0.5,
snr_db=want, seed=1)
got = ft8wave.listen_to(audio, 12000.0)
assert got
readings.append(got[0].snr_db)
assert readings[0] < readings[1] < readings[2]
assert all(abs(r - w) < 8.0
for r, w in zip(readings, (-15.0, -5.0, 5.0)))
def test_the_waterfall_is_half_a_symbol_and_half_a_tone():
"""A transmission a quarter of a symbol late, or a quarter of a tone
off, still has to be caught."""
fall = ft8wave.waterfall(np.zeros(12000 * 15, dtype=np.float32), 12000.0)
assert fall.hz_per_bin == pytest.approx(code.SYMBOL_HZ
/ ft8wave.FREQ_STEPS)
assert fall.seconds_per_step == pytest.approx(code.SYMBOL_S
/ ft8wave.TIME_STEPS)
def test_audio_too_short_to_hold_a_symbol_is_refused_rather_than_guessed():
with pytest.raises(ValueError):
ft8wave.waterfall(np.zeros(10, dtype=np.float32), 12000.0)
# ---------------------------------------------------------------------------
# Against the air
# ---------------------------------------------------------------------------
def _recording(name):
with wave.open(str(REFERENCE / f"{name}.wav")) as handle:
raw = handle.readframes(handle.getnframes())
rate = float(handle.getframerate())
audio = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
want = {}
for line in (REFERENCE / f"{name}.txt").read_text().splitlines():
parts = line.split(None, 4)
if len(parts) >= 5:
want[" ".join(parts[4].lstrip("~ ").split())] = (
float(parts[1]), float(parts[2]), float(parts[3]))
return audio, rate, want
@needs_recordings
@pytest.mark.parametrize("name,least", [
("191111_110145", 2), ("websdr_test3", 7), ("20m_busy_test_02", 14),
])
def test_it_decodes_signals_that_came_off_the_air(name, least):
"""The only test here whose answers were not written by this program."""
audio, rate, want = _recording(name)
got = {d.text: d for d in ft8wave.listen_to(audio, rate)}
matched = [w for w in want if any(w.startswith(t) for t in got)]
assert len(matched) >= least, f"decoded {len(matched)} of {len(want)}"
# And nothing it decoded may be something nobody else heard: a false
# line in a log is worse than a missing one.
unknown = [t for t in got if not any(w.startswith(t) for w in want)]
assert len(unknown) <= 1, unknown
@needs_recordings
def test_the_time_and_frequency_agree_with_what_else_heard_them():
audio, rate, want = _recording("websdr_test3")
errors = []
for d in ft8wave.listen_to(audio, rate):
for text, (snr, dt, hz) in want.items():
if text.startswith(d.text):
errors.append((d.offset - dt, d.hertz - hz))
assert len(errors) >= 6
assert max(abs(dt) for dt, _hz in errors) < 0.2
assert max(abs(hz) for _dt, hz in errors) < 4.0
# ---------------------------------------------------------------------------
# Slots and grids
# ---------------------------------------------------------------------------
def test_slots_are_quarter_minutes_of_utc():
"""Not of the local clock and not of when this program started: the
protocol is built on every station on earth agreeing which one it is."""
assert ft8.slot_start(1_700_000_007.0) == 1_700_000_000.0 - \
(1_700_000_000.0 % 15)
for when in (0.0, 1_700_000_000.0, 1_700_000_014.9):
assert ft8.slot_start(when) % ft8.SLOT == 0
assert 0 <= when - ft8.slot_start(when) < ft8.SLOT
assert ft8.next_slot(when) == ft8.slot_start(when) + ft8.SLOT
@pytest.mark.parametrize("grid,lat,lon", [
("IO91", 51.5, -1.0), ("FN31", 41.5, -73.0), ("JN54", 44.5, 11.0),
("RE78", -41.5, 175.0),
])
def test_a_grid_square_is_where_it_says(grid, lat, lon):
got = ft8.grid_at(grid)
assert got == pytest.approx((lat, lon), abs=0.01)
def test_a_grid_that_is_not_one_is_refused():
for rubbish in ("", "IO", "9191", "ZZ99", "IO9", "ABCD"):
assert ft8.grid_at(rubbish) is None
def test_the_distance_between_two_squares_is_the_great_circle_one():
km, bearing = ft8.grid_away("IO91", "FN31")
assert km == pytest.approx(5390, abs=60)
assert bearing == pytest.approx(288, abs=3)
assert ft8.grid_away("IO91", "IO91")[0] < 1.0
assert ft8.grid_away("IO91", "") is None
# ---------------------------------------------------------------------------
# The options
# ---------------------------------------------------------------------------
def test_every_option_names_a_field_that_exists():
fields = set(ft8.Ft8Options().__dict__)
for option in ft8.OPTIONS:
assert option.key in fields, f"{option.key} is not an option"
def test_every_field_is_an_option_somebody_can_reach():
assert set(ft8.Ft8Options().__dict__) == {o.key for o in ft8.OPTIONS}
def test_every_option_is_in_a_group_and_says_what_it_does():
for option in ft8.OPTIONS:
assert option.group in ft8.OPTION_GROUPS
assert option.help and option.detail and option.flags
if option.kind == "bool":
assert option.off_flags, f"{option.key} cannot be turned off"
for group in ft8.OPTION_GROUPS:
assert ft8.in_group(group)
def test_every_option_flag_is_one_the_parser_accepts():
from bandsaunter.cli import build_parser
known = set()
for action in build_parser()._subparsers._group_actions[0].choices[
"ft8"]._actions:
known.update(action.option_strings)
for option in ft8.OPTIONS:
for flag in option.flags + option.off_flags:
assert flag in known, f"{flag} is in the table and not the parser"
def test_every_command_line_option_is_reachable_from_the_menu():
"""Asked for explicitly, and worth a test rather than a promise: the
menu builds itself from the groups, so an option in no group would be
settable from the command line and invisible in the menu."""
from bandsaunter import tui
text = Path(tui.__file__).read_text()
assert "def ft8_menu(" in text
assert "ft8_menu(console, cfg)" in text, "not reachable from the front page"
reachable = set()
for group in ft8.OPTION_GROUPS:
reachable |= {o.key for o in ft8.in_group(group)}
assert reachable == {o.key for o in ft8.OPTIONS}
def test_the_options_survive_being_saved_and_read_back(tmp_path):
options = ft8.Ft8Options(band="20m", frequency=14_074_000.0,
grid="IO91", units="imperial", slots=12)
ft8.save_options(options, tmp_path)
assert ft8.load_options(tmp_path) == options
def test_a_settings_file_with_a_mistake_in_it_costs_the_defaults(tmp_path):
(tmp_path / "ft8.yaml").write_text("band: [not a band\n")
assert ft8.load_options(tmp_path) == ft8.Ft8Options()
@pytest.mark.parametrize("options,broken", [
(ft8.Ft8Options(rate=48_000.0), "sample rate"),
(ft8.Ft8Options(seconds=-1.0), "negative"),
(ft8.Ft8Options(hold=0.0), "display"),
(ft8.Ft8Options(frequency=10.0), "frequency"),
(ft8.Ft8Options(lowest=2000.0, highest=500.0), "above the bottom"),
(ft8.Ft8Options(lowest=1000.0, highest=1050.0), "narrower"),
(ft8.Ft8Options(most=0), "candidate"),
(ft8.Ft8Options(rounds=0), "error correction"),
(ft8.Ft8Options(grid="nowhere"), "grid square"),
])
def test_a_setting_that_cannot_work_is_refused(options, broken):
assert any(broken in err for err in options.validate())
def test_the_defaults_are_all_workable():
assert ft8.Ft8Options().validate() == []
def test_choosing_a_band_sets_the_frequency_with_it():
options = ft8.defaults()
ft8.use_band(options, "20m")
assert options.frequency == 14_074_000.0 and options.band == "20m"
ft8.use_band(options, "nonsense")
assert options.frequency == 144_174_000.0
def test_the_band_list_says_which_ones_a_plain_receiver_can_reach():
"""Almost all the activity is on shortwave, which this kind of receiver
cannot hear without help, and finding that out by listening to silence
for ten minutes is the wrong way to learn it."""
assert "upconverter" in ft8.band_text(ft8.Ft8Options(band="20m"))
assert "upconverter" not in ft8.band_text(ft8.Ft8Options(band="2m"))
assert ft8.defaults().band == "2m"
def test_the_command_line_beats_the_saved_settings(tmp_path, monkeypatch):
from bandsaunter.cli import build_parser
ft8.save_options(ft8.Ft8Options(band="2m", grid="AA00"), tmp_path)
monkeypatch.setattr(ft8, "options_path",
lambda directory=None: tmp_path / "ft8.yaml")
args = build_parser().parse_args(["ft8", "--band", "20m",
"--grid", "IO91"])
assert args.band == "20m" and args.grid == "IO91"
# ---------------------------------------------------------------------------
# End to end
# ---------------------------------------------------------------------------
class _Clock:
"""A clock the test winds, so a run of slots takes no real time."""
def __init__(self, at=1_700_000_000.0):
self.at = at
def now(self):
return self.at
def sleep(self, seconds):
self.at += seconds
def _run(slots=2, **kw):
clock = _Clock()
band = ft8sim.SimulatedBand(rate=240_000.0, realtime=True, stations=12,
clock=clock.now, sleep=clock.sleep)
options = ft8.defaults()
options.slots = slots
for key, value in kw.items():
setattr(options, key, value)
heard = ft8.Heard()
heard.started = clock.now()
total = ft8.pump(band, options, heard, None, clock.now(),
clock=clock.now)
return heard, total
def test_a_made_up_band_comes_out_the_other_end():
"""The whole path: samples, sideband, slot boundaries, sync, decoding,
and into the book of stations."""
heard, total = _run(slots=2)
assert heard.slots == 2
assert total >= 8, f"only {total} decodes in two slots"
assert len(heard.band.stations) >= 6
for station in heard.band.all():
assert station.decodes >= 1
assert station.call and station.call != "<...>"
def test_the_slot_boundaries_are_found_rather_than_assumed():
"""A receiver that dated its samples a block out would slice every slot
late and cut the first half-second -- three symbols and part of the
opening sync -- off every transmission on the band."""
heard, _total = _run(slots=2)
assert heard.band.decodes
# Everything on this band starts within half a second of the nominal
# start, so everything should be reported within about that of zero.
worst = max(abs(d.offset) for d in heard.band.decodes)
assert worst < 1.0, f"the worst decode was {worst:.2f} s out"
def test_callsigns_only_leaves_out_the_free_text():
heard, _ = _run(slots=1, calls_only=True)
assert all(d.kind in ("standard", "non-standard")
for d in heard.band.decodes)
def test_the_search_can_be_narrowed_and_the_narrowing_is_obeyed():
heard, _ = _run(slots=1, lowest=800.0, highest=1600.0)
assert heard.band.decodes
assert all(800.0 <= d.hertz <= 1600.0 for d in heard.band.decodes)
def test_a_station_that_calls_cq_is_counted_as_calling():
heard, _ = _run(slots=2)
calling = [s for s in heard.band.all() if s.calling]
assert calling, "nobody called CQ on a band that is mostly CQ"
assert all(s.grid for s in calling), "a CQ says where it is"
# ---------------------------------------------------------------------------
# Writing it down
# ---------------------------------------------------------------------------
def _decode(text="CQ DL1UDO JO31", **kw):
fields = dict(text=text, kind="standard", calls=("DL1UDO",),
grid="JO31", report="", calling=True, hertz=1200.0,
offset=0.2, snr_db=-7.0, at=1_700_000_000.0)
fields.update(kw)
return ft8wave.Decode(**fields)
def test_a_log_is_written_and_read_back(tmp_path):
log = ft8log.open_log(tmp_path, 1_700_000_000.0, 14_074_000.0, "20m")
assert log is not None
for i in range(3):
log.append(_decode(hertz=1000.0 + i * 100))
log.close()
rows = ft8log.read_log(log.path)
assert len(rows) == 3
assert rows[0]["text"] == "CQ DL1UDO JO31"
assert rows[1]["hertz"] == 1100.0
def test_a_half_written_log_line_is_skipped_rather_than_raising(tmp_path):
path = tmp_path / "ft8-part.txt"
path.write_text("# header\n2023-11-14 22:13:20 -7 0.2 1200.0 ~ OK\n"
"2023-11-14 22:13:35 -7 0.2\n")
rows = ft8log.read_log(path)
assert len(rows) == 1 and rows[0]["text"] == "OK"
def test_a_csv_has_a_row_for_every_decode(tmp_path):
where = ft8log.write_csv(tmp_path / "x.csv",
[_decode(), _decode("W2XYZ K1ABC RRR")])
assert where is not None
body = where.read_text().splitlines()
assert len(body) == 3 and body[0].startswith("utc,")
def test_an_adif_says_these_were_heard_rather_than_worked(tmp_path):
"""Nothing here transmits, so nothing here is a contact. An ADIF that
let a logging program treat these as worked would put claims into
somebody's log that they cannot make."""
where = ft8log.write_adif(tmp_path / "x.adi",
[_decode(), _decode("W2XYZ K1ABC RRR",
calls=("W2XYZ", "K1ABC"))],
band="20m", frequency=14_074_000.0,
my_grid="IO91")
body = where.read_text()
assert "heard only, not worked" in body
assert "not contacts" in body
assert "<CALL:6>DL1UDO" in body
assert "<MODE:3>FT8" in body
assert "<MY_GRIDSQUARE:4>IO91" in body
def test_an_adif_keeps_one_record_a_station_rather_than_one_a_decode(tmp_path):
many = [_decode(snr_db=s) for s in (-20.0, -3.0, -11.0)]
where = ft8log.write_adif(tmp_path / "y.adi", many)
body = where.read_text()
assert body.count("<EOR>") == 1
# And the one kept is the best report, that being the one worth passing on.
assert "<RST_RCVD:2>-3" in body
# ---------------------------------------------------------------------------
# The display
# ---------------------------------------------------------------------------
def test_the_display_says_what_it_is_waiting_for_before_anything_arrives():
from rich.console import Console
from bandsaunter.ui import Ft8Display
console = Console(width=160, force_terminal=False)
display = Ft8Display(console, band="20m — 14.074 MHz")
display.update(ft8.Heard())
with console.capture() as caught:
console.print(display.render())
shown = caught.get()
assert "nothing decoded yet" in shown
# And the one thing that silently stops FT8 working is worth saying.
assert "clock" in shown
def test_the_display_shows_the_last_slot_and_the_running_total():
from rich.console import Console
from bandsaunter.ui import Ft8Display
console = Console(width=110, force_terminal=False)
display = Ft8Display(console, grid="IO91", band="20m — 14.074 MHz")
heard = ft8.Heard()
for text in ("CQ DL1UDO JO31", "W2XYZ K1ABC RRR"):
found = _decode(text, calls=tuple(text.split()[1:2] or ["DL1UDO"]),
grid="JO31" if "CQ" in text else "")
heard.band.add(found)
heard.decodes += 1
heard.latest = heard.band.decodes
heard.slots = 4
display.update(heard, 1_700_000_000.0)
with console.capture() as caught:
console.print(display.render(now=1_700_000_005.0))
shown = caught.get()
assert "last slot" in shown and "heard so far" in shown
assert "DL1UDO" in shown and "JO31" in shown
assert "4 slots" in shown
def test_the_report_says_nothing_rather_than_an_empty_table():
from rich.console import Console
console = Console(width=100, force_terminal=False)
band = ft8.Band()
band.slots = 12
with console.capture() as caught:
ft8.report(console, band, ft8.defaults())
shown = caught.get()
assert "nothing decoded" in shown
assert "clock" in shown, "the usual cause is worth naming"
def test_the_report_names_the_furthest_station_when_it_can():
from rich.console import Console
console = Console(width=120, force_terminal=False)
band = ft8.Band()
band.slots = 4
band.add(_decode("CQ VK4BLE QG62", calls=("VK4BLE",), grid="QG62"))
band.add(_decode("CQ DL1UDO JO31", calls=("DL1UDO",), grid="JO31"))
options = ft8.defaults()
options.grid = "IO91"
with console.capture() as caught:
ft8.report(console, band, options)
shown = caught.get()
assert "furthest heard" in shown and "VK4BLE" in shown
def test_the_report_leaves_distances_out_when_it_has_nowhere_to_measure_from():
from rich.console import Console
console = Console(width=120, force_terminal=False)
band = ft8.Band()
band.add(_decode("CQ DL1UDO JO31", calls=("DL1UDO",), grid="JO31"))
with console.capture() as caught:
ft8.report(console, band, ft8.defaults())
shown = caught.get()
assert "DL1UDO" in shown and "away" not in shown
# ---------------------------------------------------------------------------
# Opening a receiver
# ---------------------------------------------------------------------------
def test_every_name_these_modules_import_actually_exists():
"""Imports inside a function are not checked until that function runs.
Which is how `open_rtlsdr` -- a name nothing in this program has ever
exported -- sat in the one line that opens the receiver, past every
test, until somebody chose Listen and was told the section was broken.
The tests reached that line only through the simulator, which takes the
other branch.
"""
import ast
import importlib
missing = []
for name in ("ft8", "ft8code", "ft8wave", "ft8log", "ft8sim",
"ft8tables"):
module = importlib.import_module(f"bandsaunter.{name}")
tree = ast.parse(Path(module.__file__).read_text())
for node in ast.walk(tree):
if not isinstance(node, ast.ImportFrom) or node.level != 1:
continue
if not node.module:
continue
target = importlib.import_module(f"bandsaunter.{node.module}")
for alias in node.names:
if not hasattr(target, alias.name):
missing.append(f"{name}: {node.module}.{alias.name}")
assert not missing, missing
def test_the_made_up_band_needs_no_receiver_and_says_it_is_made_up():
from rich.console import Console
console = Console(width=90, force_terminal=False)
options = ft8.defaults()
options.simulate = True
with console.capture() as caught:
device = ft8.open_device(console, options)
assert isinstance(device, ft8sim.SimulatedBand)
assert "not there" in caught.get(), "a simulation must say so"
device.close()
def test_a_receiver_that_cannot_be_opened_is_reported_rather_than_raising():
"""A menu has to survive it, and the person has to be told which of the
two things went wrong: the receiver, or the listening."""
from rich.console import Console
console = Console(width=90, force_terminal=False)
options = ft8.defaults()
options.device = 99 # no such dongle
with console.capture() as caught:
assert ft8.open_device(console, options) is None
assert "cannot open the receiver" in caught.get()
def test_nothing_claims_to_be_listening_before_there_is_a_receiver(tmp_path):
"""Announcing the frequency and then failing to open a dongle reads as
though the listening went wrong, when what went wrong happened before
any of it started."""
from rich.console import Console
console = Console(width=90, force_terminal=False)
options = ft8.defaults()
options.device = 99
options.log = False
with console.capture() as caught:
assert ft8.listen(console, options, str(tmp_path)) == 0
shown = caught.get()
assert "cannot open the receiver" in shown
assert "listening on" not in shown