Listen the way the tools that work on this band listen

Still nothing, with rtl-433 receiving the same sensors on the same aerial from
a different receiver.  That settles where the fault is not: not the aerial,
not the sensors, not the band.  So the sensible thing is to stop differing
from the configuration known to work on that aerial, and this differed from it
in three ways, every one of them mine.

It tuned a quarter of a megahertz to one side of 433.92 and shifted the signal
back in software, to keep the receiver's own spike off a signal that works by
being switched off.  That is a real effect and avoiding it this way is a bad
trade: at the sample rate this ran at, the shift is followed by a filter, and
a filter narrow enough to reject the spike is narrow enough to lose a
transmitter that has drifted -- or to lose the signal outright if a dongle
presents its samples the other way round, which is not a thing to depend on.
The spike is a steady addition to the envelope and a burst rises clear of it.
Tuning straight at the sensors now, which is what the established tools do.

It sampled at a megasample a second where a quarter of one is plenty: the
shortest pulse these send is two hundred microseconds, which is fifty samples
at the lowest rate a dongle will do.  The extra rate bought nothing but the
room for that filter to exist in.  At 250 kS/s nothing after the mixer is
narrower than the band, so the offset now does nothing at all whatever it is
set to, and says so.

And it turned on the RTL2832's digital gain control along with the tuner's.
The two pump: the gain winds up through the silence between one burst and the
next, lifting the noise towards the signal and squeezing the very difference
the burst detector works on.  It matters here in a way it does not for
aircraft, where a frame is found by correlating a preamble over microseconds
rather than by comparing a burst with the quiet around it.

Three more faults found while going over the rest of it, all the same mistake
in different clothes -- treating the middle of a distribution as though it
were the quiet part of one.

The check that skips an empty block measured the peak against the median.  A
recording that is mostly burst measures its own burst against its own burst,
finds no difference and is discarded as silence, which is what happened to
every short capture.  The gate's scatter had the same trouble one level down
and could come out above the peak, which is the one setting that cannot be
right, so it is now capped below it.

And the rule deciding where one message ends keyed on the middle gap in a
burst.  Where a one is drawn as a gap three times a zero and most of the bits
are zeroes, the middle gap is the short one, twice it still falls inside the
message, and every one-bit ended a burst -- the message coming apart into
pieces of three pulses.  It keys on the widest gap now, which is a fact about
the message rather than about the data it happened to carry.

The pieces of a message are also put back together after being sliced rather
than before.  Grouping has to be tight, because the group is what fixes the
threshold and a group holding two sensors of unequal strength fixes it on the
louder; but the gaps inside one message run from two hundred microseconds on
the newer sensors to four thousand on the oldest, and a grouping tight enough
for the first tears the second into a bit at a time.  So each piece is
measured at its own amplitude and joined to its neighbours afterwards, and
anything that comes out longer than the longest message there is gets cut at
its largest gaps.

Measured rather than argued: across four hundred and eighty combinations of
pulse and gap timing, 464 now read where 417 did; across twenty-one gap-keyed
combinations, 18 where 12 did; and eighty seconds of receiver noise still
yields nothing at all.

--from-iq FILE reads a saved capture instead of the receiver, so a recording
made where the aerial is can be worked on anywhere, as many times as it takes.
Everything downstream of the dongle is the real thing, which is what tells a
receiver problem and a decoder problem apart.  --save-iq writes the settings
beside the samples, a file of raw samples with no record of its rate being
unreadable by anything.  The invented garden now waits like a dongle instead
of running as fast as the machine allows, which it should have done from the
start: --seconds meant nothing against it and a capture came out fifty times
too large.

Full suite 2331 passed; this work checked against sixteen deliberately broken
builds, two of which it survived until the tests were made to catch them, and
one change was removed for being unable to earn a test at all.  Built as
2026-09-07_03.

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-07 21:01:00 -07:00
parent 335d83f8a0
commit 706c632f47
10 changed files with 978 additions and 92 deletions

View file

@ -1244,3 +1244,300 @@ def test_a_working_run_is_counted_as_one(tmp_path, monkeypatch):
assert "Working" in cap.get()
assert heard.survey["reported"] == heard.messages
assert heard.survey["blocks"] == 30
# ---------------------------------------------------------------------------
# Working on a capture instead of on the air
# ---------------------------------------------------------------------------
def captured(tmp_path, seconds=3, rate=RATE, offset=OFFSET):
"""A recording of the invented garden, with its settings beside it."""
import json
sky = a.SimulatedSensors(sample_rate=rate, offset=offset, seed=3)
where = tmp_path / "band.cf32"
with open(where, "wb") as fh:
for _ in range(seconds):
sky.read_samples(int(rate)).astype("complex64").tofile(fh)
where.with_suffix(".cf32.json").write_text(json.dumps({
"sample_rate": rate, "frequency": a.ACURITE_HZ, "offset": offset,
"format": "complex64"}))
return where
def test_a_capture_answers_the_way_the_receiver_that_made_it_did(tmp_path):
where = captured(tmp_path, seconds=4)
replay = wx.Replay(where)
assert replay.rate == RATE and replay.offset == OFFSET
assert replay.frequency == a.ACURITE_HZ
assert len(replay) == 4 * int(RATE)
assert replay.seconds == pytest.approx(4.0)
assert replay.read_samples(int(RATE)).size == int(RATE)
def test_a_capture_runs_out_rather_than_repeating_itself(tmp_path):
replay = wx.Replay(captured(tmp_path, seconds=2))
assert replay.read_samples(int(RATE)).size == int(RATE)
assert replay.read_samples(int(RATE)).size == int(RATE)
assert replay.read_samples(int(RATE)).size == 0
def test_the_settings_are_written_beside_a_capture(tmp_path, monkeypatch):
"""A file of samples with no idea what rate it was taken at is nothing.
Read back at the wrong rate every pulse in it is the wrong length, and
nothing will ever decode however good the decoder is.
"""
import json
options = wx.WeatherOptions(rate=RATE, offset=OFFSET, messages=True,
log=False)
monkeypatch.setattr(wx, "open_device", lambda console, opts: Garden6(2))
where = tmp_path / "band.cf32"
console = Console(width=120, force_terminal=False)
with console.capture():
wx.listen(console, options, str(tmp_path),
book=SensorBook(path=tmp_path / "s.yaml"),
save_iq=str(where))
beside = json.loads(where.with_suffix(".cf32.json").read_text())
assert beside["sample_rate"] == RATE and beside["offset"] == OFFSET
assert beside["format"] == "complex64"
assert wx.Replay(where).rate == RATE
def test_a_capture_with_no_settings_beside_it_falls_back_rather_than_failing(
tmp_path):
where = captured(tmp_path, seconds=2)
where.with_suffix(".cf32.json").unlink()
replay = wx.Replay(where, rate=RATE, offset=OFFSET)
assert replay.rate == RATE and replay.settings == {}
def test_the_same_readings_come_out_of_a_capture_as_off_the_air(tmp_path,
monkeypatch):
"""The only thing stood in for is the dongle, so it has to be the same.
That is the whole use of a capture: a recording that yields nothing here
yields nothing for anybody and the fault is in this program, and one that
yields readings here and not on the air is a setting.
"""
where = captured(tmp_path, seconds=6)
console = Console(width=120, force_terminal=False)
options = wx.WeatherOptions(rate=RATE, offset=OFFSET, messages=True,
log=False, report=False)
monkeypatch.setattr(wx, "open_device", lambda console, opts: Garden6(6))
with console.capture():
live = wx.listen(console, options, str(tmp_path),
book=SensorBook(path=tmp_path / "a.yaml"))
with console.capture():
back = wx.listen(console, options, str(tmp_path), device=wx.Replay(where),
book=SensorBook(path=tmp_path / "b.yaml"))
assert {s.key for s in back.garden.all()} == {s.key for s in live.garden.all()}
assert back.messages == live.messages
def test_a_capture_can_be_replayed_from_the_command_line(tmp_path,
monkeypatch):
from bandsaunter.cli import build_parser, cmd_weather
import bandsaunter.cli as cli
where = captured(tmp_path, seconds=6)
monkeypatch.setattr(wx, "load_options",
lambda *a, **kw: wx.WeatherOptions(messages=True,
log=False,
report=False))
console = Console(width=120, force_terminal=False)
monkeypatch.setattr(cli, "console", console)
args = build_parser().parse_args(["weather", "--from-iq", str(where)])
with console.capture() as cap:
assert cmd_weather(args) == 0
out = cap.get()
assert "replaying" in out and "Tower 592TXR" in out
def test_replaying_something_that_is_not_a_capture_is_a_message(tmp_path,
monkeypatch):
from bandsaunter.cli import build_parser, cmd_weather
import bandsaunter.cli as cli
empty = tmp_path / "nothing.cf32"
empty.write_bytes(b"")
console = Console(width=120, force_terminal=False)
monkeypatch.setattr(cli, "console", console)
args = build_parser().parse_args(["weather", "--from-iq", str(empty)])
with console.capture() as cap:
assert cmd_weather(args) == 1
assert "no samples" in cap.get()
def test_the_capture_settings_win_over_whatever_was_saved(tmp_path,
monkeypatch):
"""Read at the wrong rate, every pulse in it is the wrong length."""
from bandsaunter.cli import build_parser, cmd_weather
import bandsaunter.cli as cli
where = captured(tmp_path, seconds=3)
monkeypatch.setattr(wx, "load_options",
lambda *a, **kw: wx.WeatherOptions(
rate=1_024_000.0, offset=250_000.0,
messages=True, log=False, report=False))
console = Console(width=120, force_terminal=False)
monkeypatch.setattr(cli, "console", console)
args = build_parser().parse_args(["weather", "--from-iq", str(where)])
with console.capture() as cap:
cmd_weather(args)
assert f"{RATE / 1e6:g} MS/s" in cap.get()
# ---------------------------------------------------------------------------
# Finding out where the sensors actually are
# ---------------------------------------------------------------------------
# Above about half a megasample a second there is room to filter, and a
# filter is applied -- which is the only circumstance in which the tuning
# offset does anything at all. At the default rate nothing after the mixer
# is narrower than the band, and turning a number does not change its size.
WIDE = 1_024_000.0
STEP = WIDE / 4.0
def test_the_tuning_offset_does_nothing_at_the_default_sample_rate():
"""Which is worth a test, because the option says so and people read it."""
iq = a.modulate(a.tower_frame(0x1A2B, 21.5, 48, "A"), RATE, offset=0.0,
noise=0.02)
straight = [r.bits for r in a.readings_from(iq, RATE, 0.0)]
shifted = [r.bits for r in a.readings_from(iq, RATE, RATE / 4.0)]
assert straight and straight == shifted
def test_the_diagnosis_finds_the_offset_that_would_have_worked():
"""Rather than asking somebody to try four combinations and report back.
Which way round a dongle presents its samples is not knowable from here
and is perfectly findable, so when a second plainly held something and
the configured setting read none of it, the others are tried.
"""
iq = a.modulate(a.tower_frame(0x1A2B, 21.5, 48, "A"), WIDE, offset=STEP,
noise=0.02)
looking_ahead = wx.WeatherOptions(rate=WIDE, offset=0.0)
assert a.readings_from(iq, WIDE, 0.0) == []
assert wx._other_settings(iq, looking_ahead) == f"--offset {STEP:.0f}"
def test_it_does_not_go_looking_when_the_setting_is_already_working():
iq = a.modulate(a.tower_frame(0x1A2B, 21.5, 48, "A"), WIDE, offset=STEP,
noise=0.02)
options = wx.WeatherOptions(rate=WIDE, offset=STEP)
assert a.readings_from(iq, WIDE, STEP) != []
def test_the_verdict_says_which_offset_to_use(tmp_path, monkeypatch):
step = STEP
class Misplaced:
"""A garden that is not where the receiver is looking."""
def __init__(self, blocks):
self.sky = a.SimulatedSensors(sample_rate=WIDE, offset=step,
seed=3)
self.left = blocks
def tune(self, hz, settle=True):
return int(hz)
def read_samples(self, count, flush=False):
if self.left <= 0:
return np.zeros(0, dtype=np.complex64)
self.left -= 1
return self.sky.read_samples(count)
def close(self):
return None
monkeypatch.setattr(wx, "open_device", lambda console, opts: Misplaced(25))
console = Console(width=140, force_terminal=False)
with console.capture() as cap:
heard = wx.listen(console, wx.WeatherOptions(rate=WIDE, offset=0.0,
diagnose=True, log=False,
report=False),
str(tmp_path),
book=SensorBook(path=tmp_path / "s.yaml"))
out = cap.get()
assert heard.sensors == 0
assert "try this" in out
assert f"--offset {step:.0f}" in out
# ---------------------------------------------------------------------------
# The invented garden keeps time
# ---------------------------------------------------------------------------
def test_the_invented_garden_hands_back_a_second_once_a_second_has_passed():
"""It stands in for a dongle, so it waits like one.
Without the wait a block comes back the moment it is asked for, the
garden lives several times faster than the clock its readings are
stamped with, and a capture bounded by --seconds comes out however many
times too large the machine happens to be worth.
"""
import time as clock
sky = a.SimulatedSensors(sample_rate=RATE, realtime=True)
began = clock.monotonic()
for _ in range(3):
sky.read_samples(int(RATE / 10)) # a tenth of a second each
took = clock.monotonic() - began
assert 0.15 < took < 0.6, f"three tenths of a second took {took:.2f}s"
def test_a_test_garden_does_not_wait_at_all():
import time as clock
sky = a.SimulatedSensors(sample_rate=RATE, realtime=False)
began = clock.monotonic()
for _ in range(3):
sky.read_samples(int(RATE))
assert clock.monotonic() - began < 2.0
def test_the_digital_gain_control_after_the_tuner_is_left_off(monkeypatch):
"""The tuner's own control does its job; the one after it pumps.
It winds the gain up through the silence between one burst and the next,
which lifts the noise towards the signal and squeezes the very difference
the burst detector works on. It matters here in a way it does not for
aircraft, where a frame is found by correlating a preamble over a few
microseconds rather than by comparing a burst with the quiet around it.
The established tools for this band leave it off, and so does this.
"""
import bandsaunter.device as device
asked = {}
class Fake:
def __init__(self, **kw):
asked.update(kw)
def open(self):
return self
monkeypatch.setattr(device, "RtlSdrDevice", Fake)
console = Console(width=120, force_terminal=False)
for gain in ("auto", "40"):
wx.open_device(console, wx.WeatherOptions(gain=gain))
assert asked["agc"] is False, f"digital AGC on with gain {gain!r}"
assert asked["gain"] == gain # the tuner's own is still asked for
def test_the_defaults_are_the_ones_the_established_tools_use():
"""250 kS/s, straight at 433.92 MHz, no offset.
Not a matter of taste: this is the configuration known to receive these
sensors on hardware this program has never run on, and differing from it
bought nothing and cost everything.
"""
options = wx.WeatherOptions()
assert options.rate == 250_000.0
assert options.offset == 0.0
assert options.frequency == a.ACURITE_HZ