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

@ -216,12 +216,18 @@ aerial: a quarter-wave whip is 17 cm, which the stock telescopic aerial does
if it is collapsed to about that. `bandsaunter weather --simulate` runs the if it is collapsed to about that. `bandsaunter weather --simulate` runs the
whole thing without one. whole thing without one.
It listens at 250 kS/s, tuned straight at 433.92 MHz, with the RTL2832's
digital gain control left off — the same configuration the established tools
for this band use, because differing from it turned out to buy nothing.
If sensors you know are in range are not appearing, `bandsaunter weather If sensors you know are in range are not appearing, `bandsaunter weather
--diagnose` prints each second of band taken apart stage by stage and says --diagnose` prints each second of band taken apart stage by stage and says
which of the four possible faults it is — nothing arriving, nothing above the which of five possible faults it is — nothing arriving, nothing above the
noise, a burst that sliced into the wrong shape, or bits that came out and noise, something never keyed, a burst that framed as nothing, or a message
failed their checksums. The README section on it explains how to read the that framed and arrived only once. The README section on it explains how to
output. read the output. `--save-iq FILE` keeps the raw samples (2 MB a second, so
bound it with `--seconds 60`) and `--from-iq FILE` reads one back, so a
recording made where the aerial is can be worked on anywhere.
**Aircraft and callsign lookups need no installation**, only a network. They **Aircraft and callsign lookups need no installation**, only a network. They
ask public registers about a callsign or a 24-bit address and cache the ask public registers about a callsign or a 24-bit address and cache the

View file

@ -1942,22 +1942,27 @@ burst does. A real message begins after the sync and runs to the end.
### Off the air ### Off the air
Three things happen between the aerial and a bit, in this order. The receiver is tuned straight at 433.92 MHz, at 250 kS/s, with the tuner's own
gain control doing its job and the RTL2832's digital AGC left off — which is
what the established tools for this band do, and there is nothing to be gained
by differing from them.
The receiver is tuned a little to one side of 433.92 MHz, because every Each of those was once something else, and each was wrong. Tuning to one side
RTL-SDR puts a spike of its own at whatever it is tuned to, and a spike and shifting the signal back in software avoids the spike every RTL-SDR puts at
sitting on top of a signal that works by being switched on and off is the one whatever it is tuned to, which sounds worth doing until you notice that a
thing that stops it being off. The sensors are shifted back to the middle in filter narrow enough to reject that spike is narrow enough to lose a
software, which puts the spike out at the edge instead. `--offset 0` tunes transmitter that has drifted — and that which way round a dongle presents its
straight at them, which is worth trying once to see what the spike was costing. samples is not a thing to depend on. The spike is a steady addition to the
envelope and the burst rises clear of it. `--offset` is still there and does
nothing at all at the default rate: shifting a signal only matters if something
afterwards is narrower than the band, and at 250 kS/s nothing is.
Then a running average of the complex samples, long enough that its first null The digital AGC is off because it pumps. It winds the gain up through the
lands on the spike. It is a crude filter and a deliberately crude one: what it silence between one burst and the next, which lifts the noise towards the
has to reject is one tone at a frequency this end chose. signal and squeezes the very difference this depends on. It matters here in a
way it does not for aircraft, where a frame is found by correlating a preamble
Only then is the magnitude taken. Filtering before detection rather than after over a few microseconds rather than by comparing a burst with the quiet around
is what keeps the neighbours — a doorbell, a tyre sensor, a car key — from it.
adding themselves to the envelope of the sensor.
**Finding the bursts is done in two passes, and the reason is having more than **Finding the bursts is done in two passes, and the reason is having more than
one sensor.** The first pass only asks where anything is happening at all, and one sensor.** The first pass only asks where anything is happening at all, and
@ -1976,8 +1981,15 @@ block with one loud sensor in it yields exactly one sensor however many are out
there. there.
**Slicing the envelope into bits never measures anything against a clock**, **Slicing the envelope into bits never measures anything against a clock**,
and assumes as little as it can about how a bit is drawn. Three things are not and assumes as little as it can about how a bit is drawn. Nor about how long
assumed. Which of the pulse and the gap carries the bit — the newer sensors the silences in it are: the gaps inside one message range from 200 µs on the
newer sensors to 4000 µs on the oldest, so what belongs to one transmission is
settled from each burst's own widest gap rather than from a figure that would
have to suit every model at once. Anything that comes out longer than the
longest message there is gets cut at its largest gaps, because that is what a
boundary between two copies physically is.
Three further things are not assumed. Which of the pulse and the gap carries the bit — the newer sensors
vary the pulse, the older two vary the gap. Whether the gap is the complement vary the pulse, the older two vary the gap. Whether the gap is the complement
of the pulse, so that every bit takes the same time, or just a fixed spacer: of the pulse, so that every bit takes the same time, or just a fixed spacer:
judged against a fixed 200 µs spacer a short pulse of 220 µs is longer than its judged against a fixed 200 µs spacer a short pulse of 220 µs is longer than its
@ -2051,9 +2063,9 @@ only what is shown and can be changed afterwards on an old log.
|---|---|---|---| |---|---|---|---|
| Receiver | `--device` | 0 | which receiver, when more than one is plugged in | | Receiver | `--device` | 0 | which receiver, when more than one is plugged in |
| Gain | `--gain` | auto | tuner gain in dB, or automatic | | Gain | `--gain` | auto | tuner gain in dB, or automatic |
| Sample rate | `--rate` | 1.024 MS/s | how fast to sample; 250 kS/s is the minimum | | Sample rate | `--rate` | 250 kS/s | how fast to sample; this is also the minimum |
| Sensors on | `--frequency`, `--freq` | 433.92 MHz | where the sensors transmit | | Sensors on | `--frequency`, `--freq` | 433.92 MHz | where the sensors transmit |
| Tuning offset | `--offset` | 250 kHz | how far to one side of them to tune | | Tuning offset | `--offset` | 0 | how far to one side of them to tune; does nothing at the default rate |
| Invent a garden | `--simulate` / `--no-simulate` | no | six sensors that are not there | | Invent a garden | `--simulate` / `--no-simulate` | no | six sensors that are not there |
| Listen for | `--seconds` | until stopped | how long before stopping | | Listen for | `--seconds` | until stopped | how long before stopping |
| Write a log | `--log` / `--no-log` | yes | one line of JSON per message | | Write a log | `--log` / `--no-log` | yes | one line of JSON per message |
@ -2067,7 +2079,8 @@ only what is shown and can be changed afterwards on an old log.
| Also write a spreadsheet | `--csv` / `--no-csv` | no | CSV beside the log | | Also write a spreadsheet | `--csv` / `--no-csv` | no | CSV beside the log |
`--name ID=NAME` is not a setting: it names a sensor and is repeatable. `--name ID=NAME` is not a setting: it names a sensor and is repeatable.
Neither is `--save-iq FILE`, which is a one-off capture of the raw samples. Neither is `--save-iq FILE`, a one-off capture of the raw samples, nor
`--from-iq FILE`, which reads one back instead of the receiver.
Saved in `weather.yaml` beside the other settings, from the menu's **s** or by Saved in `weather.yaml` beside the other settings, from the menu's **s** or by
hand. `bandsaunter readings` also takes `--sensor NAME` to narrow a log to one hand. `bandsaunter readings` also takes `--sensor NAME` to narrow a log to one
@ -2138,8 +2151,20 @@ When the listening stops it says which of those it was, once:
``` ```
`--save-iq FILE` writes the raw samples alongside, for working out anything the `--save-iq FILE` writes the raw samples alongside, for working out anything the
diagnosis cannot. It is 8 MB a second at the default rate, so bound it with diagnosis cannot. It is 2 MB a second at the default rate, so bound it with
`--seconds 60`. `--seconds 60` — that is plenty, since every sensor reports at least twice in
a minute. It writes the settings it was taken at beside it, because a file of
raw samples with no idea what rate it was recorded at cannot be read back by
anything: at the wrong rate every pulse in it is the wrong length.
`--from-iq FILE` reads one back instead of the receiver, so a recording made
where the aerial is can be worked on anywhere, as many times as it takes, with
different settings each time. Everything downstream is the real thing — the
same filter, the same slicer, the same decoders — because the only part being
stood in for is the dongle. That is what tells a receiver problem and a
decoder problem apart: a capture that yields nothing on replay yields nothing
for anybody, and a capture that yields readings on replay but not on the air
is a setting.
## Meters on 900 MHz ## Meters on 900 MHz

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-07" VERSION_DATE = "2026-09-07"
VERSION_REVISION = 2 VERSION_REVISION = 3
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}" __version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"

View file

@ -736,19 +736,76 @@ def bursts(envelope: np.ndarray, rate: float, gap_us: float = 3_000.0,
if envelope.size < 4: if envelope.size < 4:
return [] return []
smooth = _smoothed(envelope, rate) smooth = _smoothed(envelope, rate)
floor = float(np.median(smooth)) # The noise, read off the bottom of the block rather than the middle of
# it, and for the same reason the gate is: the middle of a block that is
# largely signal is signal. Taken from the median, a burst occupying
# most of what it was handed looks no louder than the thing it is
# measured against, and the whole block is thrown away as empty -- which
# is what happened to any capture short enough to be mostly burst.
quiet = float(np.percentile(smooth, 20))
peak = float(np.percentile(smooth, 99.99)) peak = float(np.percentile(smooth, 99.99))
if peak <= floor * min_level or peak <= 0.0: if peak <= quiet * min_level or peak <= 0.0:
return [] # nothing above the noise worth slicing return [] # nothing above the noise worth slicing
hot = smooth > _noise_gate(smooth, floor, peak) hot = smooth > _noise_gate(smooth, quiet, peak)
if not hot.any(): if not hot.any():
return [] return []
per_us = rate / 1e6 per_us = rate / 1e6
out: list[Burst] = [] out: list[Burst] = []
# Grouped tightly, then joined back up. Tightly, because the region is
# what fixes the threshold, and a region holding two sensors of unequal
# strength fixes it on the louder -- which is the fault this whole
# arrangement exists to avoid, and it comes straight back if regions are
# allowed to be generous.
#
# But a tight region cannot hold a whole message from every model: the
# gaps inside one range from two hundred microseconds on the newer
# sensors to four thousand on the oldest, and a grouping tight enough to
# keep two sensors apart tears the oldest into a bit at a time.
#
# So the joining is done afterwards, on the sliced pulses rather than on
# the samples, where each piece has already been measured at its own
# amplitude and the gaps are known. Two pieces belong to one message
# when the silence between them looks like the silences inside them.
for lo, hi in _regions(hot, int(round(gap_us * per_us))): for lo, hi in _regions(hot, int(round(gap_us * per_us))):
out += _slice(smooth, lo, hi, rate, gap_us, min_run_us, floor) out += _slice(smooth, lo, hi, rate, gap_us, min_run_us, quiet)
return [b for b in out if b.pulses >= min_pulses] out.sort(key=lambda b: b.at)
return [b for b in _divide(_join(out)) if b.pulses >= min_pulses]
def _join(found: list[Burst]) -> list[Burst]:
"""Put back together the pieces of one message, and no more than that.
The test is whether the silence between two pieces looks like the
silences within them. Inside a message every gap is much of a size;
between one copy of a message and the next it is several times that. So
a separation of the same order as the gaps either side of it is part of
the message, and one much larger is the wait for the next copy.
"""
# What a gap inside a message looks like across the whole block, for
# pieces too small to say. The first pulses of a message often come off
# as a piece of one pulse and no gaps at all, and judged on their own
# contents there is nothing to judge -- so they were left stranded, and a
# message short of its first two bits is a message short of its checksum.
everything = [gap for burst in found for gap in burst.spaces]
fallback = float(np.median(everything)) if everything else 0.0
out: list[Burst] = []
for burst in found:
if out:
last = out[-1]
apart = (burst.at - (last.at + last.length_us / 1e6)) * 1e6
gaps = list(last.spaces) + list(burst.spaces)
typical = float(np.median(gaps)) if gaps else fallback
if 0.0 <= apart <= max(2.5 * typical, 700.0):
out[-1] = Burst(
at=last.at,
marks=last.marks + burst.marks,
spaces=last.spaces + (apart,) + burst.spaces,
level=max(last.level, burst.level))
continue
out.append(burst)
return out
def _smoothed(envelope: np.ndarray, rate: float) -> np.ndarray: def _smoothed(envelope: np.ndarray, rate: float) -> np.ndarray:
@ -765,7 +822,7 @@ def _smoothed(envelope: np.ndarray, rate: float) -> np.ndarray:
return ((pad[span:] - pad[:-span]) / span).astype(np.float32) return ((pad[span:] - pad[:-span]) / span).astype(np.float32)
def _noise_gate(smooth: np.ndarray, floor: float, peak: float) -> float: def _noise_gate(smooth: np.ndarray, quiet: float, peak: float) -> float:
"""The level below which nothing is worth looking at. """The level below which nothing is worth looking at.
This only has to find where something happened; how loud it was is This only has to find where something happened; how loud it was is
@ -794,9 +851,22 @@ def _noise_gate(smooth: np.ndarray, floor: float, peak: float) -> float:
there -- so the far ones vanish, and vanish only while the near one is there -- so the far ones vanish, and vanish only while the near one is
talking, which is as confusing a symptom as radio produces. talking, which is as confusing a symptom as radio produces.
""" """
quiet = float(np.percentile(smooth, 20))
scatter = float(np.percentile(smooth, 40) - np.percentile(smooth, 10)) scatter = float(np.percentile(smooth, 40) - np.percentile(smooth, 10))
return quiet + max(6.0 * scatter, 0.5 * quiet, 0.001 * (peak - quiet)) gate = quiet + max(6.0 * scatter, 0.5 * quiet, 0.001 * (peak - quiet))
# And never above the loudest thing in the block, whatever the arithmetic
# above comes to. A gate over the peak is the one setting that cannot be
# right: it reports silence on a second that plainly had something in it.
#
# It binds when the carrier is on for most of what was handed over -- a
# sensor with short gaps, or a capture trimmed close around one -- where
# the percentiles the scatter is taken from straddle the edge between off
# and on, and six times the resulting figure is a gate above everything
# in the block. Reading those percentiles lower down avoids that case
# instead of catching it, and was tried; but there turns out to be no
# signal it rescues that this does not, because a block cannot be mostly
# one sensor and also hold a much quieter one, so the simpler of the two
# is what is here.
return min(gate, quiet + 0.8 * (peak - quiet))
def _regions(hot: np.ndarray, gap_samples: int) -> list[tuple[int, int]]: def _regions(hot: np.ndarray, gap_samples: int) -> list[tuple[int, int]]:
@ -847,6 +917,7 @@ def _slice(smooth: np.ndarray, lo: int, hi: int, rate: float, gap_us: float,
marks: list[float] = [] marks: list[float] = []
spaces: list[float] = [] spaces: list[float] = []
began = 0 began = 0
ends = _copy_gap(lengths, values, per_us, gap_us)
# A silence only becomes a bit's gap once another pulse follows it. The # A silence only becomes a bit's gap once another pulse follows it. The
# silence at the end of a burst is not part of the last bit -- it is the # silence at the end of a burst is not part of the last bit -- it is the
# wait until the next copy, and it is as long as that wait happens to be # wait until the next copy, and it is as long as that wait happens to be
@ -864,7 +935,7 @@ def _slice(smooth: np.ndarray, lo: int, hi: int, rate: float, gap_us: float,
waiting = None waiting = None
marks.append(micro) marks.append(micro)
elif marks: elif marks:
if micro > gap_us: if micro > ends:
out.append(_burst_of(marks, spaces, lo + began, rate, out.append(_burst_of(marks, spaces, lo + began, rate,
block_floor, on_level)) block_floor, on_level))
marks, spaces, waiting = [], [], None marks, spaces, waiting = [], [], None
@ -876,6 +947,88 @@ def _slice(smooth: np.ndarray, lo: int, hi: int, rate: float, gap_us: float,
return out return out
def _copy_gap(lengths, values, per_us: float, ceiling: float) -> float:
"""The silence that means "that was the whole message", in microseconds.
Measured from the message rather than fixed, because the gaps inside one
differ by model: two hundred microseconds on the newer sensors, four
thousand on the oldest. A fixed figure has to be larger than the largest
gap inside any message and smaller than the smallest gap between two
copies of one, and there is no such figure -- pick it high and the three
copies of a message merge into one burst that matches nothing, pick it
low and a single message is torn into a bit at a time.
So it is taken from the burst. From the widest gap in it rather than
the middle one, which is the part that is easy to get wrong: where a
message draws a one as a gap three times the length of a zero, and most
of the bits are zeroes, the middle gap is the short one and twice it
still falls inside the message -- so every one-bit ends a burst and the
message comes apart into pieces of two or three pulses. The widest gap
inside a message is a fact about the message; the middle one is a fact
about the data it happens to be carrying that day.
"""
gaps = [length / per_us for length, live in zip(lengths, values)
if not live]
if len(gaps) < 4:
return ceiling
widest = float(np.percentile(gaps, 90))
return min(ceiling, max(2.0 * widest, 700.0))
# The longest message here is nine bytes, which with the four sync pulses in
# front of it is seventy-six. Anything appreciably longer than that is more
# than one message however it came to be in one piece.
MAX_PULSES = 88
def _divide(found: list[Burst]) -> list[Burst]:
"""Cut apart anything too long to be a single message.
Joining pieces back together goes by whether the silence between them
looks like the silences within them, and on the oldest sensor those two
are only a factor of two apart -- four thousand microseconds inside a
message and eight thousand between copies of it -- which is too close to
call from a ratio. So the join is allowed to be greedy and this undoes
it where the result is plainly too long to be one message: the largest
gaps in such a burst are the boundaries between the copies, because that
is what they physically are.
Kept separate from the joining rather than folded into it because the
two answer different questions. The join asks whether two pieces belong
together and can be wrong in one direction only; this asks how many
messages are in front of it, which is a question that can be answered by
counting.
"""
out: list[Burst] = []
for burst in found:
if burst.pulses <= MAX_PULSES or len(burst.spaces) < 8:
out.append(burst)
continue
cut = 1.5 * float(np.percentile(burst.spaces, 90))
pieces, marks, spaces = [], [], []
at = burst.at
began = at
for i, mark in enumerate(burst.marks):
marks.append(mark)
at += mark / 1e6
gap = burst.spaces[i] if i < len(burst.spaces) else None
if gap is None:
continue
if gap > cut:
pieces.append(Burst(at=began, marks=tuple(marks),
spaces=tuple(spaces), level=burst.level))
marks, spaces = [], []
began = at + gap / 1e6
else:
spaces.append(gap)
at += gap / 1e6
if marks:
pieces.append(Burst(at=began, marks=tuple(marks),
spaces=tuple(spaces), level=burst.level))
out += pieces if len(pieces) > 1 else [burst]
return out
def _burst_of(marks, spaces, began, rate, floor, peak) -> Burst: def _burst_of(marks, spaces, began, rate, floor, peak) -> Burst:
"""One burst, from the marks and spaces it was cut into. """One burst, from the marks and spaces it was cut into.
@ -1167,7 +1320,6 @@ def survey(iq: np.ndarray, sample_rate: float, offset: float = 0.0,
""" """
envelope, rate = baseband(iq, sample_rate, offset) envelope, rate = baseband(iq, sample_rate, offset)
smooth = _smoothed(envelope, rate) smooth = _smoothed(envelope, rate)
floor = float(np.median(smooth))
peak = float(np.percentile(smooth, 99.99)) if smooth.size else 0.0 peak = float(np.percentile(smooth, 99.99)) if smooth.size else 0.0
quiet = float(np.percentile(smooth, 20)) if smooth.size else 0.0 quiet = float(np.percentile(smooth, 20)) if smooth.size else 0.0
seen = [] seen = []
@ -1180,7 +1332,7 @@ def survey(iq: np.ndarray, sample_rate: float, offset: float = 0.0,
confirm=False) confirm=False)
seen.append((burst, tries, framed)) seen.append((burst, tries, framed))
return Survey(quiet=quiet, return Survey(quiet=quiet,
gate=_noise_gate(smooth, floor, peak) if smooth.size else 0.0, gate=_noise_gate(smooth, quiet, peak) if smooth.size else 0.0,
peak=peak, rate=rate, seen=seen, peak=peak, rate=rate, seen=seen,
readings=readings_from(iq, sample_rate, offset, when)) readings=readings_from(iq, sample_rate, offset, when))
@ -1552,8 +1704,19 @@ class SimulatedSensors:
seconds = count / self.sample_rate seconds = count / self.sample_rate
if self.realtime: if self.realtime:
# A dongle hands back a second of samples once a second has
# passed, and this stands in for a dongle, so it waits. Without
# the wait a block comes back the moment it is asked for, the
# garden lives several times faster than the clock the readings
# are stamped with, and anything measured against wall time --
# how long to listen for, how large a capture will be -- comes
# out wrong by whatever the machine happens to be worth.
now = _time.monotonic() now = _time.monotonic()
if self._last is not None: if self._last is not None:
behind = seconds - (now - self._last)
if behind > 0:
_time.sleep(behind)
now = _time.monotonic()
seconds = max(0.0, now - self._last) seconds = max(0.0, now - self._last)
self._last = now self._last = now
block = np.zeros(count, dtype=np.complex64) block = np.zeros(count, dtype=np.complex64)

View file

@ -332,9 +332,13 @@ examples:
"appearing") "appearing")
we.add_argument("--no-diagnose", dest="diagnose", action="store_false", we.add_argument("--no-diagnose", dest="diagnose", action="store_false",
default=None, help="the ordinary display") default=None, help="the ordinary display")
we.add_argument("--from-iq", default=None, metavar="FILE",
help="read a saved capture instead of the receiver, so a "
"recording made where the aerial is can be worked on "
"anywhere")
we.add_argument("--save-iq", default=None, metavar="FILE", we.add_argument("--save-iq", default=None, metavar="FILE",
help="also write the raw samples, for working out why " help="also write the raw samples, for working out why "
"something will not decode (8 MB a second at the " "something will not decode (2 MB a second at the "
"default rate, so bound it with --seconds)") "default rate, so bound it with --seconds)")
we.add_argument("--hold", type=float, default=None, metavar="SECONDS", we.add_argument("--hold", type=float, default=None, metavar="SECONDS",
help="how long a sensor stays on the display after its " help="how long a sensor stays on the display after its "
@ -1487,10 +1491,30 @@ def cmd_weather(args) -> int:
console.print(f"[red]{e}[/red]") console.print(f"[red]{e}[/red]")
return 2 return 2
device = None
if args.from_iq:
try:
device = wx.Replay(args.from_iq)
except (OSError, ValueError) as exc:
console.print(f"[red]cannot read {args.from_iq}: {exc}[/red]")
return 1
if not len(device):
console.print(f"[red]{args.from_iq} holds no samples[/red]")
return 1
# The capture decides these, not the saved settings: read at the
# wrong rate every pulse in it is the wrong length.
options.rate, options.offset = device.rate, device.offset
options.frequency, options.simulate = device.frequency, False
console.print(f"[grey62]replaying {device.seconds:.1f} s from "
f"{args.from_iq} at {device.rate/1e6:g} MS/s, "
f"offset {device.offset/1e3:g} kHz"
f"{'' if device.settings else ' (no settings beside it '
'— assuming the defaults)'}[/grey62]")
book = SensorBook() book = SensorBook()
_name_sensors(book, args.name) _name_sensors(book, args.name)
heard = wx.listen(console, options, cfg.output_dir, log_path=args.log, heard = wx.listen(console, options, cfg.output_dir, log_path=args.log,
book=book, save_iq=args.save_iq) book=book, save_iq=args.save_iq, device=device)
if not heard.sensors and not options.diagnose: if not heard.sensors and not options.diagnose:
console.print("[grey62]nothing decoded — `bandsaunter weather " console.print("[grey62]nothing decoded — `bandsaunter weather "
"--diagnose` says which stage it stops at[/grey62]") "--diagnose` says which stage it stops at[/grey62]")

View file

@ -90,9 +90,9 @@ class WeatherOptions:
# -- receiver ------------------------------------------------------- # -- receiver -------------------------------------------------------
device: int = 0 device: int = 0
gain: str = "auto" gain: str = "auto"
rate: float = 1_024_000.0 rate: float = 250_000.0
frequency: float = ACURITE_HZ frequency: float = ACURITE_HZ
offset: float = 250_000.0 offset: float = 0.0
simulate: bool = False simulate: bool = False
# -- listening ------------------------------------------------------ # -- listening ------------------------------------------------------
@ -159,11 +159,11 @@ OPTIONS: tuple[Setting, ...] = (
O("rate", "Sample rate", "Receiver", "float", O("rate", "Sample rate", "Receiver", "float",
"how fast to sample; a quarter of a megasample is the minimum", "how fast to sample; a quarter of a megasample is the minimum",
"The shortest pulse these sensors send is about two hundred " "The shortest pulse these sensors send is about two hundred "
"microseconds, so even the lowest rate a dongle will do gives fifty " "microseconds, so the lowest rate a dongle will do already gives fifty "
"samples to measure it with. The rate matters less here than it does " "samples to measure it with, and that is the default. Higher rates buy "
"for aircraft; what it buys is room to sit off to one side of the " "nothing here except room to sit off to one side of the signal, which "
"signal, which is what 'Tuning offset' is for.", "is what 'Tuning offset' is for, and cost processing in proportion.",
unit="Hz", minimum=250_000.0, flags=("--rate",), example="1024000"), unit="Hz", minimum=250_000.0, flags=("--rate",), example="250000"),
O("frequency", "Sensors on", "Receiver", "float", O("frequency", "Sensors on", "Receiver", "float",
"where the sensors transmit", "where the sensors transmit",
"433.92 MHz, which is where every one of these is sold to transmit. " "433.92 MHz, which is where every one of these is sold to transmit. "
@ -174,16 +174,28 @@ OPTIONS: tuple[Setting, ...] = (
unit="Hz", minimum=300_000_000.0, maximum=1_000_000_000.0, unit="Hz", minimum=300_000_000.0, maximum=1_000_000_000.0,
flags=("--frequency", "--freq"), example="433920000"), flags=("--frequency", "--freq"), example="433920000"),
O("offset", "Tuning offset", "Receiver", "float", O("offset", "Tuning offset", "Receiver", "float",
"how far to one side of the sensors to tune the receiver", "how far to one side of the sensors to tune, if at all",
"Every RTL-SDR puts a spike of its own making at whatever it is tuned " "Every RTL-SDR puts a spike of its own making at whatever it is tuned "
"to. A spike sitting on top of a signal that works by being switched " "to, and a spike on top of a signal that works by being switched on "
"on and off is the one thing that stops it being off, so the receiver " "and off is in principle the one thing that stops it being off. Tuning "
"is tuned to one side and the signal is shifted back in software. " "to one side and shifting the signal back in software avoids it.\n\n"
"Zero tunes straight at the sensors, which works with an R820T2 that " "It is off by default all the same, because in practice the spike is a "
"has been calibrated and not otherwise.", "steady addition to the envelope and the burst still rises clear of "
unit="Hz", minimum=0.0, flags=("--offset",), example="250000", "it, while shifting the signal about brings its own risks -- a filter "
guidance="A quarter of the sample rate is right and is the default. " "narrow enough to reject the spike is narrow enough to lose a "
"Only set it to zero to see what the spike was costing."), "transmitter that has drifted, and which way round a dongle presents "
"its samples is not something to depend on. Tuning straight at the "
"sensors is what the established tools for this band do.\n\n"
"Worth knowing: at the default sample rate this setting does nothing "
"whatever it is set to. Shifting a signal only matters if something "
"afterwards is narrower than the band, and at 250 kS/s nothing is -- "
"the envelope is taken across the whole of it, and the size of a "
"number is not changed by turning it. The offset starts to mean "
"something above about half a megasample a second, where there is "
"room to filter and a filter is applied.",
unit="Hz", minimum=0.0, flags=("--offset",), example="0",
guidance="Leave it at zero. It is worth trying a quarter of the sample "
"rate only if the diagnosis shows bursts that will not slice."),
O("simulate", "Invent a garden", "Receiver", "bool", O("simulate", "Invent a garden", "Receiver", "bool",
"put imaginary sensors on an imaginary fence", "put imaginary sensors on an imaginary fence",
"Six sensors that are not there -- one of every model this reads -- " "Six sensors that are not there -- one of every model this reads -- "
@ -513,7 +525,7 @@ class Heard:
# What the diagnosis saw, if it was asked for: how many blocks, how many # What the diagnosis saw, if it was asked for: how many blocks, how many
# of them held anything, how many bursts came out and how far each got. # of them held anything, how many bursts came out and how far each got.
survey: dict = field(default_factory=lambda: dict( survey: dict = field(default_factory=lambda: dict(
blocks=0, dead=0, loud=0, bursts=0, framed=0, reported=0)) blocks=0, dead=0, loud=0, bursts=0, framed=0, reported=0, better={}))
named: int = 0 # how many were given a name while listening named: int = 0 # how many were given a name while listening
@property @property
@ -521,6 +533,73 @@ class Heard:
return len(self.garden) if self.garden is not None else 0 return len(self.garden) if self.garden is not None else 0
class Replay:
"""A file of raw samples, answering the way the receiver that made it did.
So that a capture taken on the machine with the aerial can be worked on
anywhere, as many times as it takes, with different settings each time.
Everything downstream is the real thing -- the same filter, the same
slicer, the same decoders -- because the only part being stood in for is
the dongle.
This is what a receiver problem and a decoder problem are told apart
with. A capture that yields nothing here yields nothing for anybody, and
the fault is in this program; one that yields readings here and not on
the air is a setting.
"""
def __init__(self, path, rate: float = 0.0, offset: float = 0.0,
frequency: float = ACURITE_HZ):
import numpy as np
self.path = Path(path).expanduser()
self.settings = _capture_settings(self.path)
self.rate = float(self.settings.get("sample_rate") or rate
or 250_000.0)
self.offset = float(self.settings.get("offset", offset))
self.frequency = float(self.settings.get("frequency") or frequency)
self._samples = np.fromfile(self.path, dtype=np.complex64)
self._at = 0
def __len__(self) -> int:
return self._samples.size
@property
def seconds(self) -> float:
return self._samples.size / self.rate if self.rate else 0.0
def tune(self, hz: float, settle: bool = True) -> int:
return int(hz)
def read_samples(self, count: int, flush: bool = False):
import numpy as np
if self._at >= self._samples.size:
return np.zeros(0, dtype=np.complex64)
block = self._samples[self._at:self._at + count]
self._at += count
return block
def close(self) -> None:
return None
def _capture_settings(path: Path) -> dict:
"""The settings a capture was taken with, from the note beside it.
Written when the capture is, because a file of raw samples with no idea
what rate it was taken at is a file of nothing: read at the wrong rate
every pulse in it is the wrong length, and nothing will ever decode.
"""
import json
try:
return json.loads(path.with_suffix(path.suffix + ".json")
.read_text(encoding="utf8"))
except (OSError, ValueError):
return {}
def open_device(console, options: WeatherOptions): def open_device(console, options: WeatherOptions):
"""The receiver, or an invented garden, or None if neither can be had.""" """The receiver, or an invented garden, or None if neither can be had."""
from rich.panel import Panel from rich.panel import Panel
@ -535,10 +614,17 @@ def open_device(console, options: WeatherOptions):
return SimulatedSensors(sample_rate=options.rate, return SimulatedSensors(sample_rate=options.rate,
offset=options.offset, realtime=True).open() offset=options.offset, realtime=True).open()
try: try:
# The tuner's own gain control is left to do its job; the RTL2832's
# digital AGC after it is not turned on. The two together pump: it
# winds the gain up through the silence between one burst and the
# next, which lifts the noise towards the signal and squeezes the
# difference this depends 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 too.
device = RtlSdrDevice(index=options.device, device = RtlSdrDevice(index=options.device,
sample_rate=int(options.rate), sample_rate=int(options.rate),
gain=options.gain, gain=options.gain, agc=False)
agc=options.gain == "auto")
device.open() device.open()
except RtlSdrError as exc: except RtlSdrError as exc:
console.print(Panel(Text(str(exc)), console.print(Panel(Text(str(exc)),
@ -651,6 +737,40 @@ def _survey_line(console, options: WeatherOptions, samples, at: float,
tally["bursts"] += len(look.seen) tally["bursts"] += len(look.seen)
tally["framed"] += sum(1 for _b, _t, f in look.seen if f is not None) tally["framed"] += sum(1 for _b, _t, f in look.seen if f is not None)
tally["reported"] += len(look.readings) tally["reported"] += len(look.readings)
if not look.readings and look.loudest > 3.0:
better = _other_settings(samples, options)
if better:
tally["better"][better] = tally["better"].get(better, 0) + 1
console.print(f" [bold yellow]{better} would have read "
f"this second and the current setting did not"
f"[/bold yellow]")
# Where else the sensors might be, if they are not where this is looking.
# Only tried when the configured setting has come up empty on a second that
# plainly had something in it, so it costs nothing on a working receiver.
def _other_settings(samples, options: WeatherOptions) -> str:
"""Whether some other tuning offset would have read this block.
Which way round a dongle presents its samples, and whether the spike is
worth avoiding at all, are two things this cannot know from here and can
perfectly well find out. Rather than ask somebody to try four
combinations by hand and report back, it tries them.
"""
from .acurite import readings_from
step = options.rate / 4.0
for name, offset in (("--offset 0", 0.0),
(f"--offset {step:.0f}", step),
(f"--offset -{step:.0f}", -step)):
if abs(offset - options.offset) < 1.0:
continue
try:
if readings_from(samples, options.rate, offset):
return name
except (ValueError, FloatingPointError):
continue
return ""
def _open_capture(console, path, options: WeatherOptions): def _open_capture(console, path, options: WeatherOptions):
@ -664,14 +784,26 @@ def _open_capture(console, path, options: WeatherOptions):
except OSError as exc: except OSError as exc:
console.print(f"[red]cannot write {where}: {exc}[/red]") console.print(f"[red]cannot write {where}: {exc}[/red]")
return None return None
# The settings beside the samples, because a file of raw samples with no
# idea what rate it was taken at cannot be read back by anything.
import json
try:
where.with_suffix(where.suffix + ".json").write_text(json.dumps({
"sample_rate": options.rate, "frequency": options.frequency,
"offset": options.offset, "gain": options.gain,
"format": "complex64"}, indent=1) + "\n", encoding="utf8")
except OSError:
pass
console.print(f"[yellow]writing raw samples to {where} — " console.print(f"[yellow]writing raw samples to {where} — "
f"{options.rate * 8 / 1e6:.0f} MB a second, so bound it " f"{options.rate * 8 / 1e6:.0f} MB a second, so bound it "
f"with --seconds[/yellow]") f"with --seconds. Sixty seconds is plenty: every sensor "
f"reports at least twice in that.[/yellow]")
return handle return handle
def listen(console, options: WeatherOptions, output_dir: str, def listen(console, options: WeatherOptions, output_dir: str,
log_path=None, book=None, save_iq=None) -> Heard: log_path=None, book=None, save_iq=None, device=None) -> Heard:
"""Park on 433.92 MHz and write down what the neighbourhood says. """Park on 433.92 MHz and write down what the neighbourhood says.
Everything heard goes into the log as it arrives, and the names go into Everything heard goes into the log as it arrives, and the names go into
@ -681,7 +813,7 @@ def listen(console, options: WeatherOptions, output_dir: str,
from .sensors import SensorBook from .sensors import SensorBook
heard = Heard() heard = Heard()
device = open_device(console, options) device = open_device(console, options) if device is None else device
if device is None: if device is None:
return heard return heard
@ -927,6 +1059,14 @@ def _verdict(console, heard: Heard) -> None:
tally = heard.survey tally = heard.survey
if not tally["blocks"]: if not tally["blocks"]:
return return
if tally["better"]:
best = max(tally["better"], key=tally["better"].get)
console.print(Panel(Text(
f"Another tuning offset read {tally['better'][best]} of the "
f"seconds this one could not. Run it again with {best} — and if "
f"that is what it takes, say so, because the default is meant to "
f"be the one that works."),
title="[bold]try this", border_style="yellow", padding=(0, 1)))
if tally["dead"] == tally["blocks"]: if tally["dead"] == tally["blocks"]:
verdict = ("The receiver returned empty samples for every second of " verdict = ("The receiver returned empty samples for every second of "
"this. Nothing was decoded because nothing arrived at all " "this. Nothing was decoded because nothing arrived at all "

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-07" "bandsaunter 2026-09-07_02" "User Commands" .TH BANDSAUNTER 1 "2026-09-07" "bandsaunter 2026-09-07_03" "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
@ -2264,18 +2264,24 @@ and one byte of sum is all that is left. Corroboration does not help either,
the copies of a message being identical. What gives that window away every the copies of a message being identical. What gives that window away every
time is that it ends a whole byte before the burst does. time is that it ends a whole byte before the burst does.
.SS Getting it off the air .SS Getting it off the air
The receiver is tuned a little to one side of 433.92 MHz, because every The receiver is tuned straight at 433.92 MHz, at 250 kS/s, with the tuner's own
RTL-SDR puts a spike of its own at whatever it is tuned to, and a spike gain control doing its job and the RTL2832's digital AGC left off, which is
sitting on top of a signal that works by being switched on and off is the one what the established tools for this band do.
thing that stops it being off. The sensors are shifted back to the middle in
software, which puts the spike out at the edge instead.
.B \-\-offset 0
tunes straight at them, which is worth trying once to see what the spike was
costing.
.PP .PP
A running average of the complex samples then rejects the spike, and only The digital AGC is off because it pumps: it winds the gain up through the
after that is the magnitude taken \[em] filtering before detection rather than silence between one burst and the next, lifting the noise towards the signal
after is what keeps the neighbours out of the envelope of the sensor. and squeezing the difference this depends 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.
.PP
.B \-\-offset
tunes to one side of the sensors and shifts them back in software, which
avoids the spike every RTL-SDR puts at whatever it is tuned to. It is off by
default: the spike is a steady addition to the envelope and the burst rises
clear of it, while a filter narrow enough to reject the spike is narrow enough
to lose a transmitter that has drifted. At the default sample rate it does
nothing whatever it is set to, there being nothing after the mixer narrower
than the band.
.PP .PP
Finding the bursts is done in two passes, and the reason is having more than Finding the bursts is done in two passes, and the reason is having more than
one sensor. The first pass asks only where anything is happening at all, and one sensor. The first pass asks only where anything is happening at all, and
@ -2372,8 +2378,18 @@ on every quiet second.
.PP .PP
.BI \-\-save\-iq " FILE" .BI \-\-save\-iq " FILE"
writes the raw samples alongside, for anything the diagnosis cannot settle. It writes the raw samples alongside, for anything the diagnosis cannot settle. It
is 8 MB a second at the default rate, so bound it with is 2 MB a second at the default rate, so bound it with
.BR \-\-seconds . .BR \-\-seconds ;
sixty seconds is plenty, every sensor reporting at least twice in that. The
settings it was taken at are written beside it, a file of raw samples with no
record of its sample rate being unreadable by anything.
.PP
.BI \-\-from\-iq " FILE"
reads one back instead of the receiver, so a recording made where the aerial is
can be worked on anywhere. Everything downstream of the dongle is the real
thing, which is what tells a receiver problem and a decoder problem apart: a
capture that yields nothing on replay yields nothing for anybody, and one that
yields readings on replay and not on the air is a setting.
.SH WEATHER OPTIONS .SH WEATHER OPTIONS
Every option the weather side takes, in the four groups the menu shows them Every option the weather 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
@ -2399,7 +2415,7 @@ Leave it automatic first. If a sensor you know is there never appears, try 40 or
.B --rate .B --rate
Sample rate \[em] how fast to sample; a quarter of a megasample is the minimum (Hz). Sample rate \[em] how fast to sample; a quarter of a megasample is the minimum (Hz).
.br .br
Setting name \fBrate\fR, default \fB1.024 MHz\fR. Setting name \fBrate\fR, default \fB250 kHz\fR.
.br .br
Accepts: at least 250000. Accepts: at least 250000.
.TP .TP
@ -2411,14 +2427,14 @@ Setting name \fBfrequency\fR, default \fB433.92 MHz\fR.
Accepts: at least 3e+08, at most 1e+09. Accepts: at least 3e+08, at most 1e+09.
.TP .TP
.B --offset .B --offset
Tuning offset \[em] how far to one side of the sensors to tune the receiver (Hz). Tuning offset \[em] how far to one side of the sensors to tune, if at all (Hz).
.br .br
Setting name \fBoffset\fR, default \fB250 kHz\fR. Setting name \fBoffset\fR, default \fBstraight at them\fR.
.br .br
Accepts: at least 0. Accepts: at least 0.
.RS .RS
.PP .PP
A quarter of the sample rate is right and is the default. Only set it to zero to see what the spike was costing. Leave it at zero. It is worth trying a quarter of the sample rate only if the diagnosis shows bursts that will not slice.
.RE .RE
.TP .TP
.B --simulate / --no-simulate .B --simulate / --no-simulate

View file

@ -1376,18 +1376,24 @@ and one byte of sum is all that is left. Corroboration does not help either,
the copies of a message being identical. What gives that window away every the copies of a message being identical. What gives that window away every
time is that it ends a whole byte before the burst does. time is that it ends a whole byte before the burst does.
.SS Getting it off the air .SS Getting it off the air
The receiver is tuned a little to one side of 433.92 MHz, because every The receiver is tuned straight at 433.92 MHz, at 250 kS/s, with the tuner's own
RTL-SDR puts a spike of its own at whatever it is tuned to, and a spike gain control doing its job and the RTL2832's digital AGC left off, which is
sitting on top of a signal that works by being switched on and off is the one what the established tools for this band do.
thing that stops it being off. The sensors are shifted back to the middle in
software, which puts the spike out at the edge instead.
.B \-\-offset 0
tunes straight at them, which is worth trying once to see what the spike was
costing.
.PP .PP
A running average of the complex samples then rejects the spike, and only The digital AGC is off because it pumps: it winds the gain up through the
after that is the magnitude taken \[em] filtering before detection rather than silence between one burst and the next, lifting the noise towards the signal
after is what keeps the neighbours out of the envelope of the sensor. and squeezing the difference this depends 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.
.PP
.B \-\-offset
tunes to one side of the sensors and shifts them back in software, which
avoids the spike every RTL-SDR puts at whatever it is tuned to. It is off by
default: the spike is a steady addition to the envelope and the burst rises
clear of it, while a filter narrow enough to reject the spike is narrow enough
to lose a transmitter that has drifted. At the default sample rate it does
nothing whatever it is set to, there being nothing after the mixer narrower
than the band.
.PP .PP
Finding the bursts is done in two passes, and the reason is having more than Finding the bursts is done in two passes, and the reason is having more than
one sensor. The first pass asks only where anything is happening at all, and one sensor. The first pass asks only where anything is happening at all, and
@ -1484,8 +1490,18 @@ on every quiet second.
.PP .PP
.BI \-\-save\-iq " FILE" .BI \-\-save\-iq " FILE"
writes the raw samples alongside, for anything the diagnosis cannot settle. It writes the raw samples alongside, for anything the diagnosis cannot settle. It
is 8 MB a second at the default rate, so bound it with is 2 MB a second at the default rate, so bound it with
.BR \-\-seconds . .BR \-\-seconds ;
sixty seconds is plenty, every sensor reporting at least twice in that. The
settings it was taken at are written beside it, a file of raw samples with no
record of its sample rate being unreadable by anything.
.PP
.BI \-\-from\-iq " FILE"
reads one back instead of the receiver, so a recording made where the aerial is
can be worked on anywhere. Everything downstream of the dongle is the real
thing, which is what tells a receiver problem and a decoder problem apart: a
capture that yields nothing on replay yields nothing for anybody, and one that
yields readings on replay and not on the air is a setting.
.SH WEATHER OPTIONS .SH WEATHER OPTIONS
Every option the weather side takes, in the four groups the menu shows them Every option the weather 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

View file

@ -742,3 +742,202 @@ def test_the_pulse_lengths_of_a_burst_are_reported_as_the_protocol_shape():
def test_a_burst_of_one_length_is_reported_as_one_length(): def test_a_burst_of_one_length_is_reported_as_one_length():
assert a.timings([400.0] * 12) == [(400.0, 12)] assert a.timings([400.0] * 12) == [(400.0, 12)]
assert len(a.timings([200.0] * 6 + [400.0] * 6)) == 2 assert len(a.timings([200.0] * 6 + [400.0] * 6)) == 2
# ---------------------------------------------------------------------------
# Timings other than the ones this was written against
# ---------------------------------------------------------------------------
def keyed_at(bits, marks_gaps, copies=3, reset_us=8_000.0, amplitude=1.0,
rate=250_000.0, lead_us=3_000.0):
"""A sensor keyed with whatever timings, one (mark, gap) pair per bit."""
per_us = rate / 1e6
parts = [np.zeros(int(lead_us * per_us), dtype=np.float32)]
def push(mark, gap):
parts.append(np.full(int(round(mark * per_us)), amplitude,
dtype=np.float32))
if gap:
parts.append(np.zeros(int(round(gap * per_us)), dtype=np.float32))
for _ in range(copies):
for mark, gap in marks_gaps:
push(mark, gap)
push(500.0, reset_us)
return a._to_air(np.concatenate(parts), rate, 0.0, 0.02, 0)
def pwm_pairs(bits, one=408.0, zero=220.0, gap=None, syncs=4,
sync_mark=600.0, sync_gap=600.0):
"""Pulse-width keying: the bit is the pulse. ``gap=None`` completes the
bit period, which is one of the two conventions; a number is a spacer,
which is the other."""
out = [(sync_mark, sync_gap)] * syncs
for bit in bits:
mark = one if bit == "1" else zero
out.append((mark, (one + zero) - mark if gap is None else gap))
return out
def ppm_pairs(bits, mark=500.0, short=1_000.0, long=2_000.0):
"""Pulse-position keying: the bit is the gap."""
return [(mark, long if bit == "1" else short) for bit in bits]
@pytest.mark.parametrize("name,pairs,reset", [
# The two pulse-width conventions, at the published widths.
("pwm, gap completes the bit", pwm_pairs, 8_000.0),
# ...and with the pulses stretched and squeezed, because these
# transmitters are unlocked and drift with the temperature.
("pwm 20% slow", lambda b: [(m * 1.2, g * 1.2)
for m, g in pwm_pairs(b)], 9_600.0),
("pwm 15% fast", lambda b: [(m * 0.85, g * 0.85)
for m, g in pwm_pairs(b)], 6_800.0),
# A fixed spacer rather than a complement, at three widths.
("pwm, 200 us spacer", lambda b: pwm_pairs(b, gap=200.0), 8_000.0),
("pwm, 400 us spacer", lambda b: pwm_pairs(b, gap=400.0), 8_000.0),
("pwm, 600 us spacer", lambda b: pwm_pairs(b, gap=600.0), 8_000.0),
# Different numbers of sync pulses, since nothing here counts them.
("pwm, no sync", lambda b: pwm_pairs(b, syncs=0), 8_000.0),
("pwm, 8 sync pulses", lambda b: pwm_pairs(b, syncs=8), 8_000.0),
# And the short pulse meaning one.
("pwm inverted", lambda b: pwm_pairs(b, one=220.0, zero=408.0,
gap=200.0), 8_000.0),
])
def test_a_tower_sensor_is_read_at_timings_other_than_the_expected_ones(
name, pairs, reset):
bits = a.tower_frame(0x1A2B, 21.5, 48, "A")
build = pairs if callable(pairs) else (lambda b: pairs)
iq = keyed_at(bits, build(bits), reset_us=reset)
got = a.readings_from(iq, 250_000.0)
assert [r.sensor for r in got] == ["1A2B"], f"{name}: heard {got}"
@pytest.mark.parametrize("name,short,long,reset", [
# The 609TXC's published gaps.
("609 gaps", 1_000.0, 2_000.0, 10_000.0),
# The 606TX's, which are twice as wide -- wider than any grouping tight
# enough to keep two sensors apart, which is why the pieces of a message
# are put back together after being sliced rather than before.
("606 gaps", 2_000.0, 4_000.0, 8_000.0),
("half again as wide", 3_000.0, 6_000.0, 14_000.0),
])
def test_a_gap_keyed_sensor_is_read_however_wide_its_gaps_are(name, short,
long, reset):
bits = a.frame_609(0x5C, 4.2, 80)
iq = keyed_at(bits, ppm_pairs(bits, short=short, long=long),
reset_us=reset)
got = a.readings_from(iq, 250_000.0)
assert [r.sensor for r in got] == ["5C"], f"{name}: heard {got}"
def test_three_copies_run_together_are_still_three_copies():
"""A burst too long to be one message is cut at its largest gaps.
The gaps inside a 606 message and the wait between its copies are only a
factor of two apart, which is too close to tell from a ratio -- so the
joining is allowed to be greedy and the result is divided by counting
instead.
"""
bits = a.frame_606(0x93, -3.5)
iq = keyed_at(bits, ppm_pairs(bits, short=2_000.0, long=4_000.0),
reset_us=8_000.0)
envelope, rate = a.baseband(iq, 250_000.0, 0.0)
found = a.bursts(envelope, rate)
assert len(found) == 3, [b.pulses for b in found]
assert all(b.pulses <= a.MAX_PULSES for b in found)
assert [r.sensor for r in a.readings_from(iq, 250_000.0)] == ["93"]
def test_a_burst_no_longer_than_a_message_is_left_whole():
burst = a.Burst(marks=(400.0,) * 40, spaces=(220.0,) * 39)
assert a._divide([burst]) == [burst]
def test_a_capture_that_is_mostly_burst_is_not_thrown_away_as_empty():
"""The noise is read off the bottom of a block, never off the middle.
Taken from the median, a block that is largely signal measures its own
burst against its own burst, finds no difference, and is discarded as
silence. Which is what happened to any short capture: the shorter it
was, the larger the share of it that was the thing being looked for.
"""
bits = a.tower_frame(0x1A2B, 21.5, 48, "A")
# Short gaps, so the carrier is on for most of the recording: this is a
# real timing, and it is the one that showed the fault.
iq = keyed_at(bits, pwm_pairs(bits, gap=150.0), copies=1,
reset_us=1_000.0, lead_us=500.0)
envelope, rate = a.baseband(iq, 250_000.0, 0.0)
smooth = a._smoothed(envelope, rate)
# More than half of it is the sensor talking, which is what puts the
# median inside the signal and the block in danger of being discarded.
assert (smooth > smooth.max() / 2).mean() > 0.5
assert np.median(smooth) * 1.8 > np.percentile(smooth, 99.99)
assert [r.sensor for r in a.readings_from(iq, 250_000.0)] == ["1A2B"]
def test_a_bit_drawn_as_a_much_longer_gap_does_not_end_the_burst():
"""Where a one is three times a zero and most bits are zeroes, the middle
gap is the short one -- so a rule keyed on the middle of the gaps cuts
the message at every one-bit and hands back pieces of three pulses."""
bits = a.frame_609(0x5C, 4.2, 80)
iq = keyed_at(bits, ppm_pairs(bits, short=800.0, long=2_400.0),
reset_us=10_000.0)
envelope, rate = a.baseband(iq, 250_000.0, 0.0)
found = a.bursts(envelope, rate)
assert found and max(b.pulses for b in found) >= 40
assert [r.sensor for r in a.readings_from(iq, 250_000.0)] == ["5C"]
@pytest.mark.parametrize("one,zero,gap", [
(408.0, 220.0, None), (408.0, 220.0, 200.0), (500.0, 250.0, 250.0),
(300.0, 150.0, 150.0), (600.0, 300.0, 300.0), (400.0, 200.0, 400.0),
])
def test_the_envelope_of_pulse_widths_it_will_read(one, zero, gap):
bits = a.tower_frame(0x1A2B, 21.5, 48, "A")
iq = keyed_at(bits, pwm_pairs(bits, one=one, zero=zero, gap=gap))
assert [r.sensor for r in a.readings_from(iq, 250_000.0)] == ["1A2B"]
@pytest.mark.parametrize("duty", [0.05, 0.3, 0.5, 0.62, 0.75, 0.85])
def test_the_gate_sits_between_the_quiet_and_the_loudest_at_any_duty(duty):
"""Two ways to get this wrong, and both report a silence that was not.
The scatter it is built from has to be measured inside the quiet part.
Measured across the edge between off and on -- which is where a
percentile lands once the carrier is on for much of the block -- it
measures the signal, and six times the signal is a gate above everything
in the block.
And whatever the arithmetic comes to, a gate above the loudest thing
there is cannot be right: it is the one setting that guarantees nothing
is found on a second that plainly had something in it.
"""
rng = np.random.default_rng(4)
n = 60_000
envelope = np.abs(rng.standard_normal(n)
+ 1j * rng.standard_normal(n)).astype(np.float32) * 0.02
# The carrier keyed on for `duty` of the block, in pulses rather than
# one lump, which is what a message looks like.
period = 400
for start in range(0, n - period, period):
envelope[start:start + int(period * duty)] += 1.0
smooth = a._smoothed(envelope, 250_000.0)
quiet = float(np.percentile(smooth, 20))
peak = float(np.percentile(smooth, 99.99))
gate = a._noise_gate(smooth, quiet, peak)
assert gate < peak, f"duty {duty}: gate {gate:.3f} is above the peak {peak:.3f}"
assert gate > quiet, f"duty {duty}: gate {gate:.3f} is at or below the noise"
def test_the_gate_still_keeps_noise_out_when_there_is_no_signal():
"""The other half of it: a quiet band must find nothing at all."""
rng = np.random.default_rng(11)
for _ in range(8):
noise = np.abs(rng.standard_normal(60_000)
+ 1j * rng.standard_normal(60_000)).astype(np.float32)
smooth = a._smoothed(noise, 250_000.0)
quiet = float(np.percentile(smooth, 20))
peak = float(np.percentile(smooth, 99.99))
over = (smooth > a._noise_gate(smooth, quiet, peak)).mean()
assert over < 0.01, f"{over:.1%} of pure noise cleared the gate"

View file

@ -1244,3 +1244,300 @@ def test_a_working_run_is_counted_as_one(tmp_path, monkeypatch):
assert "Working" in cap.get() assert "Working" in cap.get()
assert heard.survey["reported"] == heard.messages assert heard.survey["reported"] == heard.messages
assert heard.survey["blocks"] == 30 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