Subsystem Search

Defined bands, and creating your own

By the end of this chapter, you know which bands Substation defines and which of them it records by default, and your configuration holds a band of your own, which Substation records.

The folder so far

The radio folder as A first scan left it: Substation installed in an environment inside it, switched on, its settings written, and the test signal made.

$ mkdir radio
$ cd radio
$ python3 -m venv .venv
$ source .venv/bin/activate
$ pip install "substation==0.8.2"
…
Successfully installed … substation-…
$ substation --init
Created /home/keanu/radio/config.yaml
This is the full default configuration, fully commented. Edit it to suit
your hardware and bands, then start a scan, e.g. `substation --band amateur_2m`.
Any setting you delete falls back to the built-in default.
# Makes test-signal.wav: eight seconds of the 2m band as a receiver hands it over,
# hiss with a transmission in the middle, for Substation to scan in place of a receiver.

import wave

import numpy

# The frequency the transmission is sent on, in hertz, and how many seconds it lasts.
frequency = 145_500_000
length = 3

# The frequency the recording is centred on, and how many IQ samples it holds
# a second, as a receiver set for the 2m band hands them over.
centre = 145_000_000
rate = 2_400_000

# Two seconds of hiss first, while Substation measures it, and three after.
start = 2
seconds = start + length + 3

# The hiss is random, but the same every time the script runs.
noise = numpy.random.default_rng(1)

with wave.open("test-signal.wav", "wb") as file:

	# Each IQ sample as two 16-bit numbers, I and Q, held as two audio channels.
	file.setnchannels(2)
	file.setsampwidth(2)
	file.setframerate(rate)

	# A second at a time, so the script needs little memory.
	for second in range(seconds):

		# The moment of each IQ sample, in seconds.
		t = second + numpy.arange(rate) / rate

		# The receiver's own hiss.
		hiss = (noise.standard_normal(rate) + 1j * noise.standard_normal(rate)) * 0.01

		# A buzz like a voice: overtones of a low note, each softer than the one below.
		voice = sum(5.3 / n ** 2 * numpy.sin(2 * numpy.pi * 150 * n * t) for n in range(2, 7))

		# Louder and softer six times a second, like syllables.
		loudness = numpy.abs(numpy.sin(2 * numpy.pi * 3 * t))

		# FM: the sound swings the transmission's frequency to either side of its own.
		phase = 2 * numpy.pi * (frequency - centre) * t + loudness * voice

		# The transmission, only while it is on, added to the hiss.
		on = (t >= start) & (t < start + length)
		iq = hiss + numpy.where(on, 0.3 * numpy.exp(1j * phase), 0)

		# Written as pairs of 16-bit numbers, I then Q.
		pairs = numpy.stack([iq.real, iq.imag], axis=1)
		file.writeframes((pairs * 32767).astype("<i2").tobytes())
$ python make_signal.py

Every band Substation knows

Substation defines 48 bands, each a range of radio channels with what it takes to hear them. --list-bands lists them all, a few lines each:

$ substation --list-bands

Available bands:
============================================================

pmr:
  Frequency range: 446.006 - 446.194 MHz
  Modulation: NFM
  Radio channel spacing: 12.5 kHz
  Reception class: not_general
  Records: no, detection only
…
air_military_uhf:
  Frequency range: 225.000 - 400.000 MHz
  Modulation: AM
  Radio channel spacing: 25.0 kHz
  Reception class: not_general
  Records: no, detection only
  Cannot be scanned: needs 175.046 MHz, but its sample rate captures 2.400 MHz
…
amateur_2m:
  Frequency range: 144.000 - 146.000 MHz
  Modulation: NFM
  Radio channel spacing: 12.5 kHz
  Reception class: general
  Records: yes
…

Each entry gives:

A band that cannot be scanned is wider than the range of frequencies it is set to take in at once. air_military_uhf spans 175 MHz, far more than any receiver in Your first receiver hands over at once.

The band library sets out every band in tables, one for each reception class, with its range and modulation.

Which bands record by default

Whether a band records by default depends on its reception class. A general band, such as amateur or CB radio, records: six of the 48. A not_general or unsettled band only detects: Substation logs each radio channel turning on and off, and records nothing. Substation sets each band's class by UK law, which its README explains under Reception and the law.

Laws on receiving and recording radio differ from country to country, so make yourself aware of the law where you are.

Only what you change

Substation loads its own defaults first, then your config.yaml over them, so the file need hold only the settings you change. The one --init wrote holds every one of them, which hides what you change among them. Keep it to read, under another name:

$ mv config.yaml defaults.yaml

Substation looks for a file named config.yaml, so it runs on its own defaults again until you write a new one. If you changed two settings in Straight into Subsample, put its recording and osc lines in your new file as well, to go on sending recordings to Subsample.

A band of your own

A band of your own is a range of radio channels you choose to watch. On the 2m band, the UK's band plan, from the Radio Society of Great Britain, gives 145.200 to 145.5625 MHz to simplex radio channels, 12.5 kHz apart, where two stations talk on one frequency with no repeater between them. Among them is 145.500 MHz, the radio channel for FM calling.

Write a new config.yaml in the radio folder, in any text editor, holding a band of those radio channels:

bands:
  amateur_2m_simplex:
    type: NFM
    freq_start: 145.2e+6
    freq_end: 145.5625e+6
    channel_spacing: 12.5e+3
    sample_rate: 2.4e+6

This is YAML, the format Substation reads its settings in. Each line is a name, a colon and its value, and a line indented under another belongs to it: bands holds your band, amateur_2m_simplex, and your band holds its settings. Indent with spaces, never tabs, and by the same amount at each level: two, here. A number written 145.2e+6 is 145.2 times a million, as Substation's own file writes a frequency in hertz.

The name is yours to choose, and the settings are:

Ask for the bands again. Yours is last:

$ substation --list-bands
…
amateur_2m_simplex:
  Frequency range: 145.200 - 145.562 MHz
  Modulation: NFM
  Radio channel spacing: 12.5 kHz
  Reception class: not_general
  Records: no, detection only

It only detects, because it took its reception class from its template.

A band's template

A band's type names its template, one of the band_defaults Substation ships, which holds what a kind of band has in common, so that each band of the kind sets only what differs. The NFM template sets two things: the modulation, NFM, and the reception class, not_general. amateur_2m is of type NFM too, and sets reception_class: general itself. A template can hold more: CB holds the radio channel spacing as well, so cb_uk sets only its range and its sample_rate.

Give your band the class general, which amateur_2m, the band it lies within, has too:

bands:
  amateur_2m_simplex:
    type: NFM
    reception_class: general
    freq_start: 145.2e+6
    freq_end: 145.5625e+6
    channel_spacing: 12.5e+3
    sample_rate: 2.4e+6
$ substation --list-bands
…
amateur_2m_simplex:
  Frequency range: 145.200 - 145.562 MHz
  Modulation: NFM
  Radio channel spacing: 12.5 kHz
  Reception class: general
  Records: yes

reception_class is one of two ways a band comes to record. The other is recording_enabled: true records a band whatever its class, and false records none of it. It switches recording on for a band of another class, or off for one that records by default.

A scan of your band

Scan the test signal on your band:

$ substation --band amateur_2m_simplex --iq-file test-signal.wav --center-freq 145000000 --start-time "2026-10-05 21:00:00"
… - INFO - Initialized scanner for band 'amateur_2m_simplex'
… - INFO - Frequency range: 145.20000 - 145.56250 MHz
… - INFO - Number of radio channels: 30
… - INFO - Center frequency: 145.38125 MHz
… - INFO - Required bandwidth: 0.38550 MHz (inc. 6.2kHz edge margin)
… - INFO - Sample rate: 2.400 MHz
… - INFO - SNR threshold: 12.0 dB ON / 9.0 dB OFF (hysteresis)
… - INFO - SDR Device: file (index 0)
… - INFO - Modulation: NFM
… - INFO - Recording: ENABLED (16000 Hz mono WAV to ./audio)
… - INFO - IQ file playback: test-signal.wav, center 145.000000 MHz, start 2026-10-05 21:00:00
… - INFO - Starting scan...
… - INFO - Setting up SDR device...
… - INFO - IQ file: test-signal.wav, 2.400 MHz, 19200000 frames (8s / 0.0h), center 145.000000 MHz
… - INFO - Using device center frequency: 145.000000 MHz (band midpoint: 145.381250 MHz)
… - INFO - SDR Gain: auto
… - INFO - SDR device configured successfully
… - INFO - Noise estimation regions: 31 gaps, 1630 bins total
… - INFO - FFT size: 65536 bins, frequency resolution: 36.6 Hz
… - INFO - Welch segments: 8, samples per slice: 524288
… - INFO - DC spike exclusion: 7 bins around center
… - INFO - Started async SDR streaming
… - INFO - Time slice: 200 ms (524288 samples)
… - INFO - Noise floor warmup complete (6.8 dB). Detection enabled.
… - INFO - Radio channel 25 ON (f = 145.50000 MHz, SNR = 15.3dB, recording: YES)
… - INFO - Radio channel 25 OFF (f = 145.50000 MHz, SNR = -0.2dB, recording: YES)
… - INFO - IQ file playback complete
… - INFO - Cancelled async SDR streaming
… - INFO - SDR device closed

Substation watched your band's 30 radio channels, and heard the transmission on radio channel 25, at 145.5 MHz, the radio channel for FM calling. Every setting your band leaves out is Substation's own default, which the configuration reference gives for each. The recording is in a folder named after your band:

$ ls audio/2026-10-05/amateur_2m_simplex
2026-10-05_21-00-02_amateur_2m_simplex_25_145.5MHz_15.3dB_file_0.wav

Substation reads config.yaml from the folder it runs in. To have it read another file instead, name the file with --config, so that one folder can hold more than one configuration, or a configuration can live somewhere else.

The next chapter looks at what Substation does between the receiver and a recording, and why a transmission turns a radio channel on.