Hear every sensor in the garden, not only the loudest one

Reported as reading nothing at all with several sensors in range.  Two faults,
either of which is enough on its own, and both of them things I assumed rather
than checked -- this was written against messages I generated myself and never
against a sensor.

The first is the threshold.  Bursts were found by setting one level per second
of band, halfway between the noise floor and the loudest thing in that second.
That is the obvious way to write it and it is wrong: a sensor on the windowsill
and a sensor at the end of the garden differ by forty decibels, so a level set
halfway to the near one sits above everything the far one ever does.  The far
ones do not come through weakly, they vanish -- and vanish only while the near
one is transmitting, which is as confusing a symptom as radio produces.  A
block with one loud sensor in it yielded exactly one sensor however many were
out there.

Finding bursts is now two passes.  The first asks only where anything happened
at all and asks it against the noise -- the bottom fifth of the second, which
is noise however busy the rest was, and which does not move when something
loud arrives.  Whatever clears that is grouped into regions, and the second
pass re-thresholds each region against its own high and low.  Every sensor is
sliced at its own amplitude.  Six sensors spanning eighty times in strength
now all come back from one second of band.

The second fault is that the slicer knew how a bit is drawn.  It read a pulse
by comparing it with the gap that followed, which is right when the gap is the
complement of the pulse so that every bit takes the same time, and wrong when
the gap is a fixed spacer: a two-hundred-and-twenty microsecond pulse against
a two-hundred microsecond spacer is the longer of the two and reads as a one,
which is the wrong bit, and then every message fails its checksum having said
nothing about why.  Nothing is assumed now -- not which of the pulse and the
gap carries the bit, not whether the gap is a complement or a spacer, not
which of long and short means one.  The same burst is read half a dozen ways
and the checksums say which reading it was, at most one being able to satisfy
one.  Copies are counted per message rather than per reading, or two readings
of one burst would corroborate each other and the rule protecting the two
thinly-checked models would protect nothing.

Both were caught the same way: by measuring, rather than by reading the code
again.  A thousand seconds of the invented garden still yields no sensor that
is not there, and reception of the ones that are is up by a quarter, because
bursts that used to be masked now decode.

And, because none of the above should have needed me: `bandsaunter weather
--diagnose` prints each second taken apart stage by stage -- the noise, the
level a burst must clear, the loudest thing in the block, then every burst
with the lengths of its pulses and gaps and whatever was made of them.  Those
lengths are the useful part: a real message has two or three of them and
nothing in between, which says at a glance whether the trouble is the radio or
the arithmetic.  At the end it says which of five things it was: nothing
arriving, nothing above the noise, something never keyed, bursts that framed
as nothing, or messages that framed and arrived only once.  `--save-iq FILE`
keeps the raw samples for whatever that cannot settle.

Full suite 2286 passed; the new work checked against six deliberately broken
builds.  Built as 2026-09-07_02.

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 18:22:53 -07:00
parent 65cc03b78d
commit 335d83f8a0
10 changed files with 1105 additions and 96 deletions

View file

@ -216,6 +216,13 @@ 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.
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
which of the four possible faults it is — nothing arriving, nothing above the
noise, a burst that sliced into the wrong shape, or bits that came out and
failed their checksums. The README section on it explains how to read the
output.
**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
answers for a month; `--no-lookup` turns them off, and what the address and answers for a month; `--no-lookup` turns them off, and what the address and

103
README.md
View file

@ -1959,15 +1959,42 @@ Only then is the magnitude taken. Filtering before detection rather than after
is what keeps the neighbours — a doorbell, a tyre sensor, a car key — from is what keeps the neighbours — a doorbell, a tyre sensor, a car key — from
adding themselves to the envelope of the sensor. adding themselves to the envelope of the sensor.
Slicing the envelope into bits never measures anything against a clock. The **Finding the bursts is done in two passes, and the reason is having more than
newer sensors vary the length of the pulse and keep the gaps even; the two one sensor.** The first pass only asks where anything is happening at all, and
older ones keep the pulse even and vary the gap. Both readings of the same asks it against the noise — the bottom fifth of the second, which is noise
pulses are tried and the checksums say which it was — rather than deciding however busy the rest was. Whatever clears that is grouped into regions, and
from the timings, which is guessing, and wrong on a weak burst where the edges the second pass re-thresholds each region against its own high and low, so
have moved. A transmitter running ten per cent fast is read correctly and every sensor is sliced at its own amplitude.
never noticed, which matters: these transmitters are unlocked and drift tens
of kilohertz and a few per cent of rate with the temperature. An outdoor The obvious way to write this is one threshold per second, set halfway between
sensor in January is not the one that was on the fence in July. the noise floor and the loudest thing in it. That is wrong, and wrong in a way
worth describing because the symptom is so odd: a sensor on the windowsill and
a sensor at the end of the garden differ by forty decibels, so a threshold
halfway to the near one sits above everything the far one ever does — and the
far ones disappear, completely, and only while the near one is transmitting. A
block with one loud sensor in it yields exactly one sensor however many are out
there.
**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
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
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
own gap and reads as a one, which is the wrong bit, and every message then
fails its checksum saying nothing about why. And which of long and short means
one.
So the same burst is read half a dozen ways — the pulse against its own gap,
the pulse against each threshold the pulse lengths themselves suggest, the
same for the gaps, and each of those inverted — and the checksums say which
reading it was. At most one of them can satisfy a checksum. It costs a few
microseconds per burst.
A transmitter running ten per cent fast is therefore read correctly and never
noticed, which matters: these are unlocked and drift tens of kilohertz and a
few per cent of rate with the temperature. An outdoor sensor in January is not
the one that was on the fence in July.
### Afterwards ### Afterwards
@ -2031,6 +2058,7 @@ only what is shown and can be changed afterwards on an old log.
| 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 |
| Print every message | `--messages` / `--no-messages` | no | a stream of lines instead of a table | | Print every message | `--messages` / `--no-messages` | no | a stream of lines instead of a table |
| Say what is arriving | `--diagnose` / `--no-diagnose` | no | each second taken apart stage by stage |
| Keep on screen for | `--hold` | 1800 s | how long a sensor stays after its last message | | Keep on screen for | `--hold` | 1800 s | how long a sensor stays after its last message |
| Show readings in | `--units` | metric | metric or imperial, for the display and the export | | Show readings in | `--units` | metric | metric or imperial, for the display and the export |
| Only named sensors | `--only-named` / `--all-sensors` | no | ignore anything without a name | | Only named sensors | `--only-named` / `--all-sensors` | no | ignore anything without a name |
@ -2039,6 +2067,7 @@ 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.
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
@ -2061,12 +2090,56 @@ a program that cannot read a thermometer.
### If nothing is heard ### If nothing is heard
These are a few milliwatts. A quarter-wave whip for 433.92 MHz is 17 cm of **`bandsaunter weather --diagnose` is the answer to this**, because "nothing
wire, and the stock telescopic aerial set to about that length works well. was heard" is four different faults wearing the same coat and they want four
Indoors, behind a wall, with the dongle in the back of a machine, is usually different answers. It prints each second of band taken apart stage by stage:
the problem. Try `--gain 40` if the automatic gain control is not finding
them, and `--messages` to watch individual receptions arrive while moving the ```
aerial about. 12.4s noise 0.0219 gate 0.0450 peak 1.0179 (46.4x the noise) 3 bursts
60 pulses pulses 219×29 399×27 597×4 gaps 221×26 402×29 600×4
Tower 592TXR 1A2B ch A temperature 8.4 C humidity 88%
```
Read it from the left.
**`peak` is barely above `noise`, no bursts.** Nothing is arriving. That is an
aerial. These are a few milliwatts: a quarter-wave whip for 433.92 MHz is 17 cm
of wire, which is the stock telescopic aerial collapsed to about that, and
indoors behind a wall with the dongle in the back of a machine is usually the
problem. Try `--gain 40` if the automatic gain control is not finding them.
**`peak` is well above `noise` and there are no bursts.** Something is there
and did not group — usually a continuous transmitter rather than a keyed one,
which is not one of these.
**Bursts, but the pulse lengths are not two or three clean groups.** The
receiver is hearing it and the slicing is wrong. A real message shows two or
three lengths with nothing in between, like the `219×29 399×27 597×4` above.
A smear of lengths means noise is being sliced as signal, or two sensors are
transmitting over each other.
**Bursts with clean pulse lengths and `nothing framed`.** The radio is fine
and the message is from a model this does not read, or reads differently.
That line of pulse lengths is exactly what is needed to add it.
**`framed, but this model needs the same message twice`.** It was read
correctly and arrived once. The two older models are only believed on a second
copy; a stronger signal fixes it.
When the listening stops it says which of those it was, once:
```
╭───────────────────────── what that came to ──────────────────────────╮
│ 40 bursts were received and sliced, and not one of them framed as a │
│ message. The radio end is working: what is arriving is a model this │
│ cannot read, or reads differently. The pulse lengths printed above │
│ are exactly what is needed to add it. │
╰──────────────────────────────────────────────────────────────────────╯
```
`--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
`--seconds 60`.
## 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 = 1 VERSION_REVISION = 2
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}" __version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"

View file

@ -76,9 +76,10 @@ __all__ = ["Reading", "Measure", "format_measure",
"readings_from", "MODELS", "MODEL_NAMES", "CHANNELS", "ACURITE_HZ", "readings_from", "MODELS", "MODEL_NAMES", "CHANNELS", "ACURITE_HZ",
"tower_frame", "five_in_one_wind_rain", "five_in_one_weather", "tower_frame", "five_in_one_wind_rain", "five_in_one_weather",
"lightning_frame", "frame_609", "frame_606", "lightning_frame", "frame_609", "frame_606",
"bursts", "Burst", "bits_pwm", "bits_ppm", "pulse_train", "bursts", "Burst", "bits_pwm", "bits_ppm", "slicings", "pulse_train",
"modulate", "baseband", "WIND_POINTS", "compass", "parity8", "modulate", "baseband", "WIND_POINTS", "compass", "parity8",
"crc8", "VirtualSensor", "SimulatedSensors", "default_sensors"] "crc8", "VirtualSensor", "SimulatedSensors", "default_sensors",
"survey", "Survey", "timings"]
# Where every one of these sensors transmits. Nominally 433.92 MHz; the # Where every one of these sensors transmits. Nominally 433.92 MHz; the
@ -632,7 +633,10 @@ def decode_burst(burst: "Burst") -> Reading | None:
burst where the edges have moved -- both readings of the same pulses are burst where the edges have moved -- both readings of the same pulses are
tried, and the checksums say which one it was. tried, and the checksums say which one it was.
""" """
return _best(candidates(bits_pwm(burst)) + candidates(bits_ppm(burst))) found = []
for bits in slicings(burst):
found += candidates(bits)
return _best(found)
@ -708,68 +712,182 @@ def bursts(envelope: np.ndarray, rate: float, gap_us: float = 3_000.0,
min_level: float = 1.8) -> list[Burst]: min_level: float = 1.8) -> list[Burst]:
"""Split an envelope into the runs of pulses worth trying to read. """Split an envelope into the runs of pulses worth trying to read.
The threshold is set between the noise floor and the peak rather than at This is done in two passes, and the reason is a garden with more than one
a fixed level, because a sensor on the fence and a sensor three gardens sensor in it.
away differ by forty decibels and both should be read. The floor is the
median of the whole block, which is honest here in a way it usually is The first pass only asks where anything is happening at all, and asks it
not: a block is a second long, a message is a fiftieth of that, and so against the noise rather than against the loudest thing in the block.
the median of a block genuinely is the sound of nothing happening. That distinction is the whole point. A single threshold set halfway
between the noise floor and the peak of a whole second sounds reasonable
and is not: a sensor on the windowsill and a sensor at the end of the
garden differ by forty decibels, so a threshold set halfway to the near
one sits far above everything the far one ever does, and the far one
disappears -- not weakly, but completely, and only while the near one is
transmitting. A block with one loud sensor in it would yield exactly one
sensor however many were out there.
So the gate here is the noise floor plus a few times its own scatter,
which is a level the quietest readable burst still clears and noise does
not. Whatever clears it is grouped into regions, and the second pass
then re-thresholds each region against its own high and low. Every
sensor is sliced at its own amplitude, and a burst forty decibels down on
its neighbour is read exactly as well as the neighbour is.
""" """
if envelope.size < 4: if envelope.size < 4:
return [] return []
floor = float(np.median(envelope)) smooth = _smoothed(envelope, rate)
peak = float(np.percentile(envelope, 99.95)) floor = float(np.median(smooth))
peak = float(np.percentile(smooth, 99.99))
if peak <= floor * min_level or peak <= 0.0: if peak <= floor * min_level or peak <= 0.0:
return [] # nothing above the noise worth slicing return [] # nothing above the noise worth slicing
# Smoothed over a fraction of the shortest pulse these sensors send, so hot = smooth > _noise_gate(smooth, floor, peak)
# that a sample of noise on the wrong side of the threshold does not if not hot.any():
# become a pulse, and the shortest real pulse still survives it.
span = max(1, int(round(rate * 30e-6)))
if span > 1:
pad = np.concatenate(([0.0], np.cumsum(envelope, dtype=np.float64)))
smooth = ((pad[span:] - pad[:-span]) / span).astype(np.float32)
else:
smooth = envelope
on = smooth > (floor + 0.5 * (peak - floor))
if not on.any():
return [] return []
per_us = rate / 1e6 per_us = rate / 1e6
out: list[Burst] = []
for lo, hi in _regions(hot, int(round(gap_us * per_us))):
out += _slice(smooth, lo, hi, rate, gap_us, min_run_us, floor)
return [b for b in out if b.pulses >= min_pulses]
def _smoothed(envelope: np.ndarray, rate: float) -> np.ndarray:
"""A running mean over a fraction of the shortest pulse these send.
Enough that a sample of noise on the wrong side of a threshold cannot
become a pulse, and little enough that the shortest real pulse -- about
two hundred microseconds -- survives it with its edges where they were.
"""
span = max(1, int(round(rate * 30e-6)))
if span <= 1:
return envelope
pad = np.concatenate(([0.0], np.cumsum(envelope, dtype=np.float64)))
return ((pad[span:] - pad[:-span]) / span).astype(np.float32)
def _noise_gate(smooth: np.ndarray, floor: float, peak: float) -> float:
"""The level below which nothing is worth looking at.
This only has to find where something happened; how loud it was is
settled afterwards, region by region. So it wants an estimate of the
noise and nothing else, and in particular it must not move when
something loud happens -- that is exactly the case where a sensor at the
end of the garden is about to be lost.
Everything here is read off the bottom of the distribution rather than
the middle of it. A second of band holds a few hundred milliseconds of
sensor at the very most, so the lowest fifth of it is noise however busy
the rest was. The middle is not safe in the same way: the median and the
deviation about it both climb once a fair fraction of the block is
signal, and a gate built on them can end up above the peak and pass
nothing at all.
The other two terms are floors under the gate, for a signal so clean that
the noise has almost no scatter to measure -- a synthetic envelope, or a
receiver with nothing connected -- where the first term alone would be
zero and everything would be above it.
Note what none of the three terms is: a fraction of the way to the peak.
That is the obvious way to write this and it is the bug it replaced. Any
gate proportional to the loudest thing in the block rises when a sensor
close to the aerial transmits, and rises past every quieter sensor out
there -- so the far ones vanish, and vanish only while the near one is
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))
return quiet + max(6.0 * scatter, 0.5 * quiet, 0.001 * (peak - quiet))
def _regions(hot: np.ndarray, gap_samples: int) -> list[tuple[int, int]]:
"""Spans of activity, with short silences inside them kept.
The silences between the pulses of one message are part of the message,
so they must not end a region; only a silence longer than any gap within
a message does that.
"""
lengths, values, starts = _runs(hot)
spans: list[list[int]] = []
for length, live, start in zip(lengths, values, starts):
if not live:
continue
if spans and start - spans[-1][1] <= gap_samples:
spans[-1][1] = start + length
else:
spans.append([start, start + length])
return [(lo, hi) for lo, hi in spans]
def _slice(smooth: np.ndarray, lo: int, hi: int, rate: float, gap_us: float,
min_run_us: float, block_floor: float) -> list[Burst]:
"""Cut one region into marks and spaces, at its own amplitude.
The threshold is halfway between what this burst does when it is on and
what it does when it is off, both taken from the region itself. A little
of the silence either side is included so that there is an "off" to
measure even when the region is nearly all pulses.
"""
per_us = rate / 1e6
margin = int(round(400 * per_us))
lo = max(0, lo - margin)
hi = min(smooth.size, hi + margin)
part = smooth[lo:hi]
if part.size < 4:
return []
off = float(np.percentile(part, 5))
on_level = float(np.percentile(part, 98))
if on_level <= off:
return []
on = part > (off + on_level) / 2.0
lengths, values, starts = _runs(on) lengths, values, starts = _runs(on)
lengths, values, starts = _merge_short(lengths, values, starts, lengths, values, starts = _merge_short(lengths, values, starts,
min_run_us * per_us) min_run_us * per_us)
out: list[Burst] = [] out: list[Burst] = []
marks: list[float] = [] marks: list[float] = []
spaces: list[float] = [] spaces: list[float] = []
began = 0 began = 0
# 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
# wait until the next copy, and it is as long as that wait happens to be
# -- so measuring the last bit against it reads the last bit wrongly,
# which is the last bit of the checksum, which is the whole message.
# Held back rather than appended, and dropped if nothing follows.
waiting: float | None = None
for length, high, start in zip(lengths, values, starts): for length, high, start in zip(lengths, values, starts):
micro = length / per_us micro = length / per_us
if high: if high:
if not marks: if not marks:
began = start began = start
elif waiting is not None:
spaces.append(waiting)
waiting = None
marks.append(micro) marks.append(micro)
elif marks: elif marks:
if micro > gap_us: if micro > gap_us:
out.append(_burst_of(marks, spaces, began, rate, floor, peak)) out.append(_burst_of(marks, spaces, lo + began, rate,
marks, spaces = [], [] block_floor, on_level))
marks, spaces, waiting = [], [], None
else: else:
spaces.append(micro) waiting = micro
if marks: if marks:
out.append(_burst_of(marks, spaces, began, rate, floor, peak)) out.append(_burst_of(marks, spaces, lo + began, rate, block_floor,
return [b for b in out if b.pulses >= min_pulses] on_level))
return out
def _burst_of(marks, spaces, began, rate, floor, peak) -> Burst: def _burst_of(marks, spaces, began, rate, floor, peak) -> Burst:
# One more mark than spaces, always: the silence that ended the burst """One burst, from the marks and spaces it was cut into.
# belongs to what came after it.
# There is always one more mark than there are spaces: the silence that
# Everything is cast to an ordinary float on the way out. These numbers ended the burst belongs to whatever comes after it, not to this.
# came from numpy and end up as a timestamp in a log and in a file of
# names, and neither JSON nor YAML will write a numpy float -- which is Everything is cast to an ordinary float on the way out. These numbers
# the sort of thing that only shows up when a sensor is finally named at came from numpy and end up as a timestamp in a log and in a file of
# two in the morning. names, and neither JSON nor YAML will write a numpy float -- which is the
sort of thing that only shows up when a sensor is finally named at two in
the morning.
"""
marks = [float(m) for m in marks][:len(spaces) + 1] marks = [float(m) for m in marks][:len(spaces) + 1]
return Burst(at=float(began) / float(rate), marks=tuple(marks), return Burst(at=float(began) / float(rate), marks=tuple(marks),
spaces=tuple(float(s) for s in spaces), spaces=tuple(float(s) for s in spaces),
@ -860,6 +978,82 @@ def bits_ppm(burst: Burst) -> str:
return "".join("1" if space > middle else "0" for space in burst.spaces) return "".join("1" if space > middle else "0" for space in burst.spaces)
def _levels(values, most: int = 3) -> list[float]:
"""Thresholds that might separate short from long, best division first.
The lengths in a burst fall into clusters -- two of them for the bits,
and often a third for the sync, which is longer than either. Rather than
decide which cluster boundary is the one that means something, this
returns a few of the widest gaps in the sorted lengths and lets the
checksums say. Guessing here is what a fixed threshold does, and a fixed
threshold is wrong the moment a transmitter warms up.
"""
ordered = sorted(float(v) for v in values)
if len(ordered) < 4:
return []
span = ordered[-1] - ordered[0]
if span <= 0:
return []
steps = sorted(((ordered[i + 1] - ordered[i], i)
for i in range(len(ordered) - 1)), reverse=True)
out = []
for step, i in steps[:most]:
if step < 0.08 * span:
break # not a division, just jitter
out.append((ordered[i] + ordered[i + 1]) / 2.0)
return sorted(out)
_FLIP = str.maketrans("01", "10")
def _by_width(values, level: float) -> str:
return "".join("1" if value > level else "0" for value in values)
def slicings(burst: Burst) -> list[str]:
"""Every way this burst might be read, for the checksums to choose from.
There are two things not to assume about a burst, and both of them are
assumptions that hold on the sensor in front of you and fail on the next
one.
The first is which of the pulse and the gap carries the bit. The newer
models vary the pulse; the two older ones vary the gap.
The second is subtler and is the one that had this reading nothing at
all. Where the pulse carries the bit, the gap may be the complement of
it -- so that every bit takes the same time, and a pulse can be judged
against the gap that follows it -- or the gap may simply be a fixed
spacer. Judged against a fixed spacer of two hundred microseconds, a
short pulse of two hundred and twenty is longer than its gap and reads as
a one, which is the wrong bit, and every message fails its checksum with
nothing to say why.
So nothing is assumed. The pulse against its own gap, the pulse against
each threshold the pulse lengths themselves suggest, and the same for the
gaps: half a dozen readings of the same burst, of which at most one will
satisfy a checksum. It costs a few microseconds per burst and it is the
difference between working on the sensors this was written against and
working on the ones in somebody's garden.
"""
out = [bits_pwm(burst), bits_ppm(burst)]
for values in (burst.marks, burst.spaces):
for level in _levels(values):
# And the other way up. Which of long and short means one is a
# third thing not to assume, and complementing a bit string is
# free.
reading = _by_width(values, level)
out += [reading, reading.translate(_FLIP)]
seen: set = set()
keep = []
for bits in out:
if bits and bits not in seen:
seen.add(bits)
keep.append(bits)
return keep
def readings_from(iq: np.ndarray, sample_rate: float, offset: float = 0.0, def readings_from(iq: np.ndarray, sample_rate: float, offset: float = 0.0,
when: float = 0.0) -> list[Reading]: when: float = 0.0) -> list[Reading]:
"""Every distinct sensor message in one block of samples, in order. """Every distinct sensor message in one block of samples, in order.
@ -879,7 +1073,17 @@ def readings_from(iq: np.ndarray, sample_rate: float, offset: float = 0.0,
envelope, rate = baseband(iq, sample_rate, offset) envelope, rate = baseband(iq, sample_rate, offset)
found = [] found = []
for burst in bursts(envelope, rate): for burst in bursts(envelope, rate):
for reading in candidates(bits_pwm(burst)) + candidates(bits_ppm(burst)): # One burst is one copy of one message, however many ways of reading
# it happened to produce it. Counted per reading rather than per
# slicing, or two readings of the same burst would corroborate each
# other -- and corroboration is the only thing standing between the
# two thinly-checked models and a display full of sensors that are
# not there.
here: dict = {}
for bits in slicings(burst):
for reading in candidates(bits):
here.setdefault((reading.family, reading.bits), reading)
for reading in here.values():
reading.at = when + burst.at reading.at = when + burst.at
found.append(reading) found.append(reading)
return confirmed(found) return confirmed(found)
@ -905,6 +1109,82 @@ def confirmed(found: list[Reading]) -> list[Reading]:
return sorted(out, key=lambda r: (r.at, r.family, r.sensor)) return sorted(out, key=lambda r: (r.at, r.family, r.sensor))
# ---------------------------------------------------------------------------
# Looking at what actually arrived
# ---------------------------------------------------------------------------
def timings(values, tolerance: float = 0.25) -> list[tuple[float, int]]:
"""The distinct lengths in a burst, each with how many there were.
A message drawn as pulses has two or three lengths in it and nothing in
between, so this is the shape of the protocol written out: (220, 28)
(402, 28) (598, 4) is a burst of fifty-six bits and four sync pulses,
and anyone who knows the sensor can see that at a glance. It is the
single most useful thing to print when nothing is decoding, because it
says whether the trouble is the radio or the arithmetic.
"""
ordered = sorted(float(v) for v in values)
groups: list[list[float]] = []
for value in ordered:
if groups and value <= groups[-1][-1] * (1.0 + tolerance):
groups[-1].append(value)
else:
groups.append([value])
return [(sum(g) / len(g), len(g)) for g in groups]
@dataclass
class Survey:
"""What one block of samples looked like at every stage.
Only ever built to be printed. It exists because "nothing was heard" is
four different faults wearing the same coat -- no signal at all, a signal
below the gate, a burst that sliced into the wrong number of pulses, or
bits that came out and failed their checksums -- and they want four
different answers. Each stage here separates one of them from the next.
"""
quiet: float = 0.0 # the noise, as the gate measures it
gate: float = 0.0 # what a burst has to clear
peak: float = 0.0 # the loudest thing in the block
rate: float = 0.0 # what the envelope was sliced at
seen: list = field(default_factory=list) # (burst, slicings, framed)
readings: list = field(default_factory=list)
@property
def loudest(self) -> float:
return self.peak / self.quiet if self.quiet > 0 else float("inf")
def survey(iq: np.ndarray, sample_rate: float, offset: float = 0.0,
when: float = 0.0) -> Survey:
"""One block, taken apart stage by stage, for when nothing decodes.
The readings are obtained by running the ordinary path rather than by
repeating it here, so that what this reports and what the program does
cannot come apart -- a diagnostic that disagrees with the thing it is
diagnosing is worse than none.
"""
envelope, rate = baseband(iq, sample_rate, offset)
smooth = _smoothed(envelope, rate)
floor = float(np.median(smooth))
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
seen = []
for burst in bursts(envelope, rate):
tries = slicings(burst)
# Without the corroboration rule, so that a message which framed
# correctly and merely arrived once is reported as what it is,
# rather than as silence.
framed = _best([r for bits in tries for r in candidates(bits)],
confirm=False)
seen.append((burst, tries, framed))
return Survey(quiet=quiet,
gate=_noise_gate(smooth, floor, peak) if smooth.size else 0.0,
peak=peak, rate=rate, seen=seen,
readings=readings_from(iq, sample_rate, offset, when))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Building the same messages, so the decoder can be held to them # Building the same messages, so the decoder can be held to them
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -325,6 +325,17 @@ examples:
help="print every message as it arrives, not a table") help="print every message as it arrives, not a table")
we.add_argument("--no-messages", dest="messages", action="store_false", we.add_argument("--no-messages", dest="messages", action="store_false",
default=None, help="show the table that updates in place") default=None, help="show the table that updates in place")
we.add_argument("--diagnose", dest="diagnose", action="store_true",
default=None,
help="say what each second of band looked like at every "
"stage, for when sensors you know are there are not "
"appearing")
we.add_argument("--no-diagnose", dest="diagnose", action="store_false",
default=None, help="the ordinary display")
we.add_argument("--save-iq", default=None, metavar="FILE",
help="also write the raw samples, for working out why "
"something will not decode (8 MB a second at the "
"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 "
"last message") "last message")
@ -1404,7 +1415,8 @@ def _weather_options(args, options):
("gain", "gain"), ("device", "device"), ("gain", "gain"), ("device", "device"),
("frequency", "frequency"), ("offset", "offset"), ("frequency", "frequency"), ("offset", "offset"),
("simulate", "simulate"), ("log_messages", "log"), ("simulate", "simulate"), ("log_messages", "log"),
("messages", "messages"), ("hold", "hold"), ("messages", "messages"), ("diagnose", "diagnose"),
("hold", "hold"),
("units", "units"), ("only_named", "only_named"), ("units", "units"), ("only_named", "only_named"),
("unknown", "unknown"), ("report", "report"), ("unknown", "unknown"), ("report", "report"),
("csv", "csv")): ("csv", "csv")):
@ -1478,7 +1490,10 @@ def cmd_weather(args) -> int:
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) book=book, save_iq=args.save_iq)
if not heard.sensors and not options.diagnose:
console.print("[grey62]nothing decoded — `bandsaunter weather "
"--diagnose` says which stage it stops at[/grey62]")
return 0 if heard.sensors else 1 return 0 if heard.sensors else 1

View file

@ -99,6 +99,7 @@ class WeatherOptions:
seconds: float = 0.0 seconds: float = 0.0
log: bool = True log: bool = True
messages: bool = False messages: bool = False
diagnose: bool = False
hold: float = 1800.0 hold: float = 1800.0
# -- sensors -------------------------------------------------------- # -- sensors --------------------------------------------------------
@ -217,6 +218,22 @@ OPTIONS: tuple[Setting, ...] = (
"or to watch individual receptions arrive while working out whether " "or to watch individual receptions arrive while working out whether "
"an aerial is any good.", "an aerial is any good.",
flags=("--messages",), off_flags=("--no-messages",)), flags=("--messages",), off_flags=("--no-messages",)),
O("diagnose", "Say what is arriving", "Listening", "bool",
"print what each second of band looked like at every stage",
"For when nothing is being heard, which is four different faults "
"wearing the same coat. Each second is reported as the noise level, "
"the level a burst has to clear, the loudest thing in the block, and "
"then every burst found with the lengths of its pulses and gaps and "
"whatever was made of them. Between them those say whether the "
"trouble is that nothing is arriving, that it is too quiet to slice, "
"that it sliced into the wrong shape, or that the bits came out and "
"failed their checksums. Turns the table off, because the two cannot "
"share a screen.",
flags=("--diagnose",), off_flags=("--no-diagnose",),
guidance="Turn this on when sensors you know are there are not "
"appearing. The line of pulse lengths is the useful part: a "
"real message has two or three lengths in it and nothing in "
"between."),
O("hold", "Keep on screen for", "Listening", "float", O("hold", "Keep on screen for", "Listening", "float",
"how long a sensor stays on the display after its last message", "how long a sensor stays on the display after its last message",
"Half an hour by default, which is long compared with the sixteen " "Half an hour by default, which is long compared with the sixteen "
@ -492,6 +509,11 @@ class Heard:
book: object = None book: object = None
log_path: Path | None = None log_path: Path | None = None
csv_path: Path | None = None csv_path: Path | None = None
iq_path: Path | None = None
# 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.
survey: dict = field(default_factory=lambda: dict(
blocks=0, dead=0, loud=0, bursts=0, framed=0, reported=0))
named: int = 0 # how many were given a name while listening named: int = 0 # how many were given a name while listening
@property @property
@ -547,7 +569,7 @@ def open_log(console, options: WeatherOptions, output_dir: str,
def pump(device, options: WeatherOptions, garden: Garden, book, log, def pump(device, options: WeatherOptions, garden: Garden, book, log,
started: float, on_block=None, on_reading=None, started: float, on_block=None, on_reading=None, on_samples=None,
stopping=None) -> int: stopping=None) -> int:
"""Read the receiver until it stops, or until told to. """Read the receiver until it stops, or until told to.
@ -566,6 +588,8 @@ def pump(device, options: WeatherOptions, garden: Garden, book, log,
samples = device.read_samples(block) samples = device.read_samples(block)
if samples is None or samples.size == 0: if samples is None or samples.size == 0:
break break
if on_samples is not None:
on_samples(samples, at)
for reading in readings_from(samples, options.rate, options.offset, for reading in readings_from(samples, options.rate, options.offset,
when=at): when=at):
if not reading.measures and not options.unknown: if not reading.measures and not options.unknown:
@ -586,8 +610,68 @@ def pump(device, options: WeatherOptions, garden: Garden, book, log,
return total return total
def _survey_line(console, options: WeatherOptions, samples, at: float,
started: float, tally: dict) -> None:
"""One second of band, taken apart stage by stage.
Printed rather than summarised because the person reading it is trying to
find out which stage is the one that fails, and a summary would be this
with the answer removed.
"""
from .acurite import survey, timings
look = survey(samples, options.rate, options.offset, when=at)
console.print(
f"[grey62]{at - started:7.1f}s noise {look.quiet:.4f} "
f"gate {look.gate:.4f} peak {look.peak:.4f} "
f"({look.loudest:.1f}x the noise) "
f"{len(look.seen)} burst{'' if len(look.seen) == 1 else 's'}[/grey62]",
highlight=False)
for burst, tries, framed in look.seen:
marks = " ".join(f"{v:.0f}×{n}" for v, n in timings(burst.marks))
gaps = " ".join(f"{v:.0f}×{n}" for v, n in timings(burst.spaces))
console.print(f" [cyan]{burst.pulses:3d} pulses[/cyan] "
f"pulses {marks} gaps {gaps}", highlight=False)
if framed is None:
console.print(f" [yellow]nothing framed[/yellow] "
f"[grey62]{len(tries)} ways of reading it tried; "
f"the pulse lengths above are what to look at"
f"[/grey62]", highlight=False)
elif not look.readings:
console.print(f" [yellow]{framed.describe(options.imperial)}"
f"[/yellow] [grey62]framed, but this model needs "
f"the same message twice and it came once[/grey62]",
highlight=False)
else:
console.print(f" [green]{framed.describe(options.imperial)}"
f"[/green]", highlight=False)
tally["blocks"] += 1
tally["dead"] += look.peak <= 0.0
tally["loud"] += look.loudest > 3.0
tally["bursts"] += len(look.seen)
tally["framed"] += sum(1 for _b, _t, f in look.seen if f is not None)
tally["reported"] += len(look.readings)
def _open_capture(console, path, options: WeatherOptions):
"""The raw-sample file, or None. Says how fast it will fill."""
if not path:
return None
where = Path(path).expanduser()
try:
where.parent.mkdir(parents=True, exist_ok=True)
handle = open(where, "wb")
except OSError as exc:
console.print(f"[red]cannot write {where}: {exc}[/red]")
return None
console.print(f"[yellow]writing raw samples to {where} — "
f"{options.rate * 8 / 1e6:.0f} MB a second, so bound it "
f"with --seconds[/yellow]")
return handle
def listen(console, options: WeatherOptions, output_dir: str, def listen(console, options: WeatherOptions, output_dir: str,
log_path=None, book=None) -> Heard: log_path=None, book=None, save_iq=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
@ -612,10 +696,20 @@ def listen(console, options: WeatherOptions, output_dir: str,
if log is not None: if log is not None:
console.print(f"[grey62]writing {log.path}[/grey62]") console.print(f"[grey62]writing {log.path}[/grey62]")
capture = _open_capture(console, save_iq, options)
display, live = _open_display(console, options, book, started) display, live = _open_display(console, options, book, started)
if live is not None: if live is not None:
console.print() console.print()
def on_samples(samples, at: float) -> None:
if capture is not None:
try:
samples.astype("complex64").tofile(capture)
except OSError as exc:
console.print(f"[red]cannot write the samples: {exc}[/red]")
if options.diagnose:
_survey_line(console, options, samples, at, started, heard.survey)
def on_block(total: int) -> None: def on_block(total: int) -> None:
if live is None: if live is None:
return return
@ -623,6 +717,8 @@ def listen(console, options: WeatherOptions, output_dir: str,
live.update(display.render()) live.update(display.render())
def on_reading(reading, sensor) -> None: def on_reading(reading, sensor) -> None:
if options.diagnose:
return # the survey already said so
if options.messages or live is None: if options.messages or live is None:
label = sensor.name or reading.sensor label = sensor.name or reading.sensor
console.print(f"[cyan]{label}[/cyan] " console.print(f"[cyan]{label}[/cyan] "
@ -632,7 +728,7 @@ def listen(console, options: WeatherOptions, output_dir: str,
total = 0 total = 0
try: try:
total = _run(console, device, options, garden, book, log, started, total = _run(console, device, options, garden, book, log, started,
display, live, on_block, on_reading, heard) display, live, on_block, on_reading, on_samples, heard)
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
finally: finally:
@ -640,6 +736,9 @@ def listen(console, options: WeatherOptions, output_dir: str,
live.stop() live.stop()
console.print("[grey62]stopped listening[/grey62]") console.print("[grey62]stopped listening[/grey62]")
device.close() device.close()
if capture is not None:
capture.close()
heard.iq_path = Path(save_iq).expanduser()
if log is not None: if log is not None:
log.close() log.close()
heard.log_path = log.path heard.log_path = log.path
@ -648,7 +747,7 @@ def listen(console, options: WeatherOptions, output_dir: str,
def _run(console, device, options, garden, book, log, started, def _run(console, device, options, garden, book, log, started,
display, live, on_block, on_reading, heard) -> int: display, live, on_block, on_reading, on_samples, heard) -> int:
"""The listening loop, with the keyboard live where there is one. """The listening loop, with the keyboard live where there is one.
Naming happens here rather than afterwards because that is the moment it Naming happens here rather than afterwards because that is the moment it
@ -660,7 +759,8 @@ def _run(console, device, options, garden, book, log, started,
if live is None: if live is None:
return pump(device, options, garden, book, log, started, return pump(device, options, garden, book, log, started,
on_block=on_block, on_reading=on_reading) on_block=on_block, on_reading=on_reading,
on_samples=on_samples)
with KeyReader() as keys: with KeyReader() as keys:
pressed = {"key": ""} pressed = {"key": ""}
@ -685,7 +785,8 @@ def _run(console, device, options, garden, book, log, started,
return pressed["key"] == "stop" return pressed["key"] == "stop"
return pump(device, options, garden, book, log, started, return pump(device, options, garden, book, log, started,
on_block=block, on_reading=on_reading, stopping=stopping) on_block=block, on_reading=on_reading,
on_samples=on_samples, stopping=stopping)
def _name_one(console, keys, live, display, book, garden, def _name_one(console, keys, live, display, book, garden,
@ -745,7 +846,8 @@ def _open_display(console, options: WeatherOptions, book, started: float):
display that redraws itself twice a second is unreadable as a stream of display that redraws itself twice a second is unreadable as a stream of
text, and worse than useless in a file. text, and worse than useless in a file.
""" """
if options.messages or not getattr(console, "is_terminal", False): if options.messages or options.diagnose \
or not getattr(console, "is_terminal", False):
return None, None return None, None
from rich.live import Live from rich.live import Live
@ -776,7 +878,18 @@ def finish(console, options: WeatherOptions, output_dir: str, heard: Heard,
book.flush() book.flush()
except OSError as exc: except OSError as exc:
console.print(f"[red]cannot save {book.path}: {exc}[/red]") console.print(f"[red]cannot save {book.path}: {exc}[/red]")
# Before the early return below, because the run that heard nothing is
# exactly the run whose verdict is worth reading.
if options.diagnose:
_verdict(console, heard)
if heard.iq_path is not None:
console.print(f"[grey62]raw samples in {heard.iq_path} "
f"({heard.iq_path.stat().st_size / 1e6:.0f} MB of "
f"complex64 at {options.rate / 1e6:g} MS/s, tuned "
f"{options.offset / 1e3:g} kHz below "
f"{options.frequency / 1e6:g} MHz)[/grey62]")
if garden is None or not len(garden): if garden is None or not len(garden):
if not options.diagnose:
console.print("[yellow]nothing heard. These sensors are a few " console.print("[yellow]nothing heard. These sensors are a few "
"milliwatts at 433.92 MHz: a quarter-wave whip is " "milliwatts at 433.92 MHz: a quarter-wave whip is "
"17 cm, and indoors is usually the problem." "17 cm, and indoors is usually the problem."
@ -802,6 +915,53 @@ def finish(console, options: WeatherOptions, output_dir: str, heard: Heard,
return heard return heard
def _verdict(console, heard: Heard) -> None:
"""Which of the four faults it looks like, said once at the end.
Said at the end rather than on every quiet second, because a line an hour
is a diagnosis and a line a second is weather of its own.
"""
from rich.panel import Panel
from rich.text import Text
tally = heard.survey
if not tally["blocks"]:
return
if tally["dead"] == tally["blocks"]:
verdict = ("The receiver returned empty samples for every second of "
"this. Nothing was decoded because nothing arrived at all "
"\u2014 check `bandsaunter devices`.")
elif not tally["bursts"] and not tally["loud"]:
verdict = ("Nothing ever came above the noise. That is an aerial "
"rather than a decoder: these sensors are a few "
"milliwatts, a quarter-wave whip for 433.92 MHz is 17 cm "
"of wire, and indoors behind a wall is usually the "
"problem. Try --gain 40 as well.")
elif not tally["bursts"]:
verdict = (f"Something was above the noise in {tally['loud']} of "
f"{tally['blocks']} seconds and never grouped into a "
"burst, which usually means a transmitter that is on "
"continuously rather than one keying a carrier on and "
"off. Not one of these sensors.")
elif not tally["framed"]:
verdict = (f"{tally['bursts']} bursts were received and sliced, and "
"not one of them framed as a message. The radio end is "
"working: what is arriving is a model this cannot read, "
"or reads differently. The pulse lengths printed above "
"are exactly what is needed to add it.")
elif not tally["reported"]:
verdict = (f"{tally['framed']} of {tally['bursts']} bursts framed "
"correctly and none was reported, which means each "
"arrived once. The two older models are believed only on "
"a second copy of the same message, so this wants a "
"stronger signal rather than a different decoder.")
else:
verdict = (f"{tally['reported']} readings out of {tally['bursts']} "
f"bursts over {tally['blocks']} seconds. Working.")
console.print(Panel(Text(verdict), title="[bold]what that came to",
border_style="blue", padding=(0, 1)))
def _write_csv(console, heard: Heard, book, options: WeatherOptions): def _write_csv(console, heard: Heard, book, options: WeatherOptions):
from .weatherlog import read_logs, write_csv from .weatherlog import read_logs, write_csv

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_01" "User Commands" .TH BANDSAUNTER 1 "2026-09-07" "bandsaunter 2026-09-07_02" "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
@ -2277,13 +2277,28 @@ A running average of the complex samples then rejects the spike, and only
after that is the magnitude taken \[em] filtering before detection rather than after that is the magnitude taken \[em] filtering before detection rather than
after is what keeps the neighbours out of the envelope of the sensor. after is what keeps the neighbours out of the envelope of the sensor.
.PP .PP
Slicing the envelope into bits never measures anything against a clock. The Finding the bursts is done in two passes, and the reason is having more than
newer sensors vary the length of the pulse and keep the gaps even; the two one sensor. The first pass asks only where anything is happening at all, and
older ones keep the pulse even and vary the gap. Both readings of the same asks it against the noise: the bottom fifth of a second, which is noise
pulses are tried and the checksums say which it was. A transmitter running ten however busy the rest was. Whatever clears that is grouped into regions, and
per cent fast is read correctly and never noticed, which matters: these are the second pass re-thresholds each region against its own high and low, so
unlocked and drift with the temperature, and an outdoor sensor in January is every sensor is sliced at its own amplitude. One threshold per second, set
not the one that was on the fence in July. halfway between the noise and the loudest thing in it, is the obvious way to
write this and is wrong: a sensor on the windowsill and a sensor at the end of
the garden differ by forty decibels, so the far ones fall below it and vanish,
and vanish only while the near one is transmitting.
.PP
Slicing the envelope into bits never measures anything against a clock, and
assumes as little as it can about how a bit is drawn. Not which of the pulse
and the gap carries the bit; not whether the gap is the complement of the
pulse or a fixed spacer, since a 220 microsecond pulse against a 200
microsecond spacer is the longer of the two and reads as the wrong bit; and
not which of long and short means one. The same burst is read half a dozen
ways and the checksums say which reading it was, at most one of them being
able to satisfy one. A transmitter running ten per cent fast is therefore read
correctly and never noticed, which matters: these are unlocked and drift with
the temperature, and an outdoor sensor in January is not the one that was on
the fence in July.
.SS Afterwards .SS Afterwards
When the listening stops, two tables. The first is about reception \[em] who, When the listening stops, two tables. The first is about reception \[em] who,
how often, how well \[em] and is the one to look at when something is missing: how often, how well \[em] and is the one to look at when something is missing:
@ -2320,14 +2335,45 @@ transmitting real messages with real checksums, keyed on and off as a real one
does, through the real filter, the real slicer and the real decoders. Nothing does, through the real filter, the real slicer and the real decoders. Nothing
touches the receiver. touches the receiver.
.SS If nothing is heard .SS If nothing is heard
These are a few milliwatts. A quarter-wave whip for 433.92 MHz is 17 cm of .B "bandsaunter weather \-\-diagnose"
wire, and the stock telescopic aerial set to about that length works well; is the answer to this, because "nothing was heard" is four different faults
indoors, behind a wall, with the dongle in the back of a machine, is usually wearing the same coat and they want four different answers. It prints each
the problem. Try second of band taken apart stage by stage: the noise level, the level a burst
has to clear, the loudest thing in the block, and then every burst found with
the lengths of its pulses and gaps and whatever was made of them.
.TP
.B "peak barely above noise, no bursts"
Nothing is arriving, which is an aerial. These are a few milliwatts; a
quarter-wave whip for 433.92 MHz is 17 cm of wire, which is the stock
telescopic aerial collapsed to about that, and indoors behind a wall with the
dongle in the back of a machine is usually the problem. Try
.B \-\-gain 40 .B \-\-gain 40
if the automatic gain control is not finding them, and if the automatic gain control is not finding them.
.B \-\-messages .TP
to watch individual receptions arrive while moving the aerial about. .B "peak well above noise, no bursts"
Something is there and did not group into a burst, usually a transmitter that
is on continuously rather than keyed. Not one of these.
.TP
.B "bursts whose pulse lengths are not two or three clean groups"
The receiver is hearing it and the slicing is wrong. A real message shows two
or three lengths with nothing in between; a smear means noise is being sliced
as signal, or two sensors are transmitting over each other.
.TP
.B "clean pulse lengths, nothing framed"
The radio is fine and the message is from a model this does not read. The line
of pulse lengths is what is needed to add it.
.TP
.B "framed, but needs the same message twice"
It was read correctly and arrived once. The two older models are believed only
on a second copy, so this wants a stronger signal.
.PP
When the listening stops it says which of those five it was, once, rather than
on every quiet second.
.PP
.BI \-\-save\-iq " FILE"
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
.BR \-\-seconds .
.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
@ -2403,6 +2449,15 @@ Print every message \[em] one line per message instead of a table that updates i
.br .br
Setting name \fBmessages\fR, default \fBno\fR. Setting name \fBmessages\fR, default \fBno\fR.
.TP .TP
.B --diagnose / --no-diagnose
Say what is arriving \[em] print what each second of band looked like at every stage.
.br
Setting name \fBdiagnose\fR, default \fBno\fR.
.RS
.PP
Turn this on when sensors you know are there are not appearing. The line of pulse lengths is the useful part: a real message has two or three lengths in it and nothing in between.
.RE
.TP
.B --hold .B --hold
Keep on screen for \[em] how long a sensor stays on the display after its last message (s). Keep on screen for \[em] how long a sensor stays on the display after its last message (s).
.br .br

View file

@ -1389,13 +1389,28 @@ A running average of the complex samples then rejects the spike, and only
after that is the magnitude taken \[em] filtering before detection rather than after that is the magnitude taken \[em] filtering before detection rather than
after is what keeps the neighbours out of the envelope of the sensor. after is what keeps the neighbours out of the envelope of the sensor.
.PP .PP
Slicing the envelope into bits never measures anything against a clock. The Finding the bursts is done in two passes, and the reason is having more than
newer sensors vary the length of the pulse and keep the gaps even; the two one sensor. The first pass asks only where anything is happening at all, and
older ones keep the pulse even and vary the gap. Both readings of the same asks it against the noise: the bottom fifth of a second, which is noise
pulses are tried and the checksums say which it was. A transmitter running ten however busy the rest was. Whatever clears that is grouped into regions, and
per cent fast is read correctly and never noticed, which matters: these are the second pass re-thresholds each region against its own high and low, so
unlocked and drift with the temperature, and an outdoor sensor in January is every sensor is sliced at its own amplitude. One threshold per second, set
not the one that was on the fence in July. halfway between the noise and the loudest thing in it, is the obvious way to
write this and is wrong: a sensor on the windowsill and a sensor at the end of
the garden differ by forty decibels, so the far ones fall below it and vanish,
and vanish only while the near one is transmitting.
.PP
Slicing the envelope into bits never measures anything against a clock, and
assumes as little as it can about how a bit is drawn. Not which of the pulse
and the gap carries the bit; not whether the gap is the complement of the
pulse or a fixed spacer, since a 220 microsecond pulse against a 200
microsecond spacer is the longer of the two and reads as the wrong bit; and
not which of long and short means one. The same burst is read half a dozen
ways and the checksums say which reading it was, at most one of them being
able to satisfy one. A transmitter running ten per cent fast is therefore read
correctly and never noticed, which matters: these are unlocked and drift with
the temperature, and an outdoor sensor in January is not the one that was on
the fence in July.
.SS Afterwards .SS Afterwards
When the listening stops, two tables. The first is about reception \[em] who, When the listening stops, two tables. The first is about reception \[em] who,
how often, how well \[em] and is the one to look at when something is missing: how often, how well \[em] and is the one to look at when something is missing:
@ -1432,14 +1447,45 @@ transmitting real messages with real checksums, keyed on and off as a real one
does, through the real filter, the real slicer and the real decoders. Nothing does, through the real filter, the real slicer and the real decoders. Nothing
touches the receiver. touches the receiver.
.SS If nothing is heard .SS If nothing is heard
These are a few milliwatts. A quarter-wave whip for 433.92 MHz is 17 cm of .B "bandsaunter weather \-\-diagnose"
wire, and the stock telescopic aerial set to about that length works well; is the answer to this, because "nothing was heard" is four different faults
indoors, behind a wall, with the dongle in the back of a machine, is usually wearing the same coat and they want four different answers. It prints each
the problem. Try second of band taken apart stage by stage: the noise level, the level a burst
has to clear, the loudest thing in the block, and then every burst found with
the lengths of its pulses and gaps and whatever was made of them.
.TP
.B "peak barely above noise, no bursts"
Nothing is arriving, which is an aerial. These are a few milliwatts; a
quarter-wave whip for 433.92 MHz is 17 cm of wire, which is the stock
telescopic aerial collapsed to about that, and indoors behind a wall with the
dongle in the back of a machine is usually the problem. Try
.B \-\-gain 40 .B \-\-gain 40
if the automatic gain control is not finding them, and if the automatic gain control is not finding them.
.B \-\-messages .TP
to watch individual receptions arrive while moving the aerial about. .B "peak well above noise, no bursts"
Something is there and did not group into a burst, usually a transmitter that
is on continuously rather than keyed. Not one of these.
.TP
.B "bursts whose pulse lengths are not two or three clean groups"
The receiver is hearing it and the slicing is wrong. A real message shows two
or three lengths with nothing in between; a smear means noise is being sliced
as signal, or two sensors are transmitting over each other.
.TP
.B "clean pulse lengths, nothing framed"
The radio is fine and the message is from a model this does not read. The line
of pulse lengths is what is needed to add it.
.TP
.B "framed, but needs the same message twice"
It was read correctly and arrived once. The two older models are believed only
on a second copy, so this wants a stronger signal.
.PP
When the listening stops it says which of those five it was, once, rather than
on every quiet second.
.PP
.BI \-\-save\-iq " FILE"
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
.BR \-\-seconds .
.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

@ -540,3 +540,205 @@ def test_a_nine_byte_sensor_that_is_not_a_lightning_detector_is_not_read_as_one(
assert got.measures == () assert got.measures == ()
assert got.sensor == "0011" assert got.sensor == "0011"
assert got.value("temperature") is None assert got.value("temperature") is None
# ---------------------------------------------------------------------------
# A garden with more than one sensor in it
# ---------------------------------------------------------------------------
def block_of(*bursts_in, seconds: float = 1.0, noise: float = 0.02,
rate: float = RATE, offset: float = OFFSET, seed: int = 0):
"""One second of band with whatever was handed in placed about in it."""
rng = np.random.default_rng(seed)
n = int(rate * seconds)
block = ((rng.standard_normal(n) + 1j * rng.standard_normal(n))
* noise).astype(np.complex64)
for at, part in bursts_in:
start = int(at * rate)
room = min(part.size, max(0, n - start))
block[start:start + room] += part[:room]
return block
def keyed(bits, amplitude: float = 1.0, rate: float = RATE,
offset: float = OFFSET):
return a.modulate(bits, rate, offset=offset, amplitude=amplitude,
lead_us=0.0, noise=0.0)
def test_a_sensor_by_the_aerial_does_not_hide_the_rest_of_the_garden():
"""The fault that had this reading one sensor out of six.
A threshold set halfway between the noise and the loudest thing in the
block is halfway to whichever sensor happens to be nearest, and every
quieter sensor is then below it -- so they disappear, and disappear only
while the near one is transmitting, which is as confusing a symptom as
radio produces. Thirty-six decibels between these two.
"""
loud = keyed(a.tower_frame(0x1A2B, 21.5, 48, "A"), amplitude=8.0)
faint = keyed(a.tower_frame(0x0C41, 3.2, 91, "B"), amplitude=0.12)
block = block_of((0.05, loud), (0.5, faint))
heard_now = {r.sensor for r in a.readings_from(block, RATE, offset=OFFSET)}
assert heard_now == {"1A2B", "0C41"}
@pytest.mark.parametrize("apart", [4.0, 20.0, 80.0])
def test_two_sensors_are_both_read_however_far_apart_in_strength(apart):
loud = keyed(a.tower_frame(0x1A2B, 21.5, 48, "A"), amplitude=0.9)
faint = keyed(a.tower_frame(0x0C41, 3.2, 91, "B"), amplitude=0.9 / apart)
block = block_of((0.05, loud), (0.5, faint), noise=0.9 / apart / 12.0)
got = {r.sensor for r in a.readings_from(block, RATE, offset=OFFSET)}
assert got == {"1A2B", "0C41"}, f"{apart:g}x apart: heard {got}"
def test_six_sensors_in_one_second_all_come_back():
parts = [(0.02 + i * 0.14,
keyed(a.tower_frame(0x100 + i, 10.0 + i, 50, "A"),
amplitude=0.15 * (i + 1)))
for i in range(6)]
got = {r.sensor for r in a.readings_from(block_of(*parts), RATE,
offset=OFFSET)}
assert got == {f"{0x100 + i:04X}" for i in range(6)}
# ---------------------------------------------------------------------------
# Not assuming how a bit is drawn
# ---------------------------------------------------------------------------
def transmitted(bits, one_mark, zero_mark, gap, sync_mark=600.0,
sync_gap=600.0, syncs=4, copies=3, amplitude=1.0):
"""A sensor keyed with whatever timings, rather than with mine."""
per_us = RATE / 1e6
parts = [np.zeros(int(3_000 * per_us), dtype=np.float32)]
def push(mark, space):
parts.append(np.full(int(round(mark * per_us)), amplitude,
dtype=np.float32))
parts.append(np.zeros(int(round(space * per_us)), dtype=np.float32))
for _ in range(copies):
for _ in range(syncs):
push(sync_mark, sync_gap)
for bit in bits:
push(one_mark if bit == "1" else zero_mark, gap)
parts.append(np.zeros(int(9_000 * per_us), dtype=np.float32))
return a._to_air(np.concatenate(parts), RATE, OFFSET, 0.02, 0)
@pytest.mark.parametrize("name,one,zero,gap", [
# The gap is the complement of the pulse, so every bit takes the same
# time. This is the one it was written against.
("complementary gap", 408.0, 220.0, None),
# The gap is a fixed spacer. Judged against a 200 us spacer a 220 us
# pulse is the longer of the two and reads as a one, which is the wrong
# bit, and every message fails its checksum saying nothing about why.
("short fixed gap", 408.0, 220.0, 200.0),
("long fixed gap", 408.0, 220.0, 500.0),
# And the other way up: the short pulse is the one.
("inverted", 220.0, 408.0, 200.0),
])
def test_a_burst_is_read_whichever_way_the_bits_are_drawn(name, one, zero,
gap):
bits = a.tower_frame(0x1A2B, 21.5, 48, "A")
if gap is None:
# complementary: build it a bit at a time so each gap completes its
# own bit period
per_us = RATE / 1e6
parts = [np.zeros(int(3_000 * per_us), dtype=np.float32)]
for _ in range(3):
for mark, space in zip(*a.pulse_train(bits, "pwm")):
parts.append(np.full(int(round(mark * per_us)), 1.0,
dtype=np.float32))
parts.append(np.zeros(int(round(space * per_us)),
dtype=np.float32))
parts.append(np.zeros(int(9_000 * per_us), dtype=np.float32))
iq = a._to_air(np.concatenate(parts), RATE, OFFSET, 0.02, 0)
else:
iq = transmitted(bits, one, zero, gap)
got = a.readings_from(iq, RATE, offset=OFFSET)
assert [r.sensor for r in got] == ["1A2B"], f"{name}: heard {got}"
def test_the_readings_of_a_burst_are_all_different_from_each_other():
"""Half a dozen ways of reading it, and no duplicates among them."""
burst = a.bursts(*a.baseband(
a.modulate(a.tower_frame(0x1A2B, 21.5, 48, "A"), RATE, offset=OFFSET,
noise=0.02), RATE, OFFSET))[0]
tries = a.slicings(burst)
assert len(tries) >= 4
assert len(set(tries)) == len(tries)
assert a.tower_frame(0x1A2B, 21.5, 48, "A") in "".join(tries)
def test_reading_a_burst_several_ways_is_not_the_same_as_hearing_it_twice():
"""Corroboration counts messages, not readings of one message.
The two thinly-checked models are believed when the same message arrives
twice. If two ways of reading one burst each produced it, that would
look like two arrivals and the rule would protect nothing.
"""
iq = a.modulate(a.frame_609(0x5C, 4.2, 80), RATE, coding="ppm",
offset=OFFSET, repeats=1, noise=0.02)
burst = a.bursts(*a.baseband(iq, RATE, OFFSET))[0]
ways = [r for bits in a.slicings(burst) for r in a.candidates(bits)]
assert any(r.family == "609" for r in ways), "it did frame"
assert a.readings_from(iq, RATE, offset=OFFSET) == []
# ---------------------------------------------------------------------------
# Saying what arrived, when nothing decodes
# ---------------------------------------------------------------------------
def test_the_survey_reports_the_same_readings_the_program_acts_on():
"""A diagnostic that disagrees with the thing it diagnoses is worse than
none, so it runs the ordinary path rather than repeating it."""
iq = a.modulate(a.tower_frame(0x1A2B, 21.5, 48, "A"), RATE, offset=OFFSET,
noise=0.05)
look = a.survey(iq, RATE, OFFSET, when=1_000.0)
assert [r.describe() for r in look.readings] == \
[r.describe() for r in a.readings_from(iq, RATE, offset=OFFSET,
when=1_000.0)]
def test_the_survey_separates_nothing_arriving_from_nothing_decoding():
rng = np.random.default_rng(2)
n = int(RATE)
quiet = ((rng.standard_normal(n) + 1j * rng.standard_normal(n))
* 0.02).astype(np.complex64)
nothing = a.survey(quiet, RATE, OFFSET)
assert nothing.seen == [] and nothing.readings == []
assert nothing.loudest < 3.0 # and it says the band was quiet
# A burst of the right shape whose bits are nonsense: it groups, it
# slices, and it frames nothing. A different fault, and it looks it.
rubbish = transmitted("01" * 28, 408.0, 220.0, 220.0)
junk = a.survey(rubbish, RATE, OFFSET)
assert junk.seen and junk.readings == []
assert all(framed is None for _b, _t, framed in junk.seen)
assert junk.loudest > 3.0
def test_the_survey_shows_a_message_that_framed_and_was_not_corroborated():
"""Which is a third fault again, and the one hardest to guess at."""
iq = a.modulate(a.frame_609(0x5C, 4.2, 80), RATE, coding="ppm",
offset=OFFSET, repeats=1, noise=0.02)
look = a.survey(iq, RATE, OFFSET)
assert look.readings == []
assert any(framed is not None and framed.family == "609"
for _b, _t, framed in look.seen)
def test_the_pulse_lengths_of_a_burst_are_reported_as_the_protocol_shape():
burst = a.bursts(*a.baseband(
a.modulate(a.tower_frame(0x1A2B, 21.5, 48, "A"), RATE, offset=OFFSET,
noise=0.02), RATE, OFFSET))[0]
marks = a.timings(burst.marks)
assert len(marks) == 3 # short, long, sync
assert [n for _v, n in marks] == [28, 28, 4]
assert [round(v / 10) * 10 for v, _n in marks] == [220, 400, 600]
assert sum(n for _v, n in marks) == burst.pulses
def test_a_burst_of_one_length_is_reported_as_one_length():
assert a.timings([400.0] * 12) == [(400.0, 12)]
assert len(a.timings([200.0] * 6 + [400.0] * 6)) == 2

View file

@ -1073,3 +1073,174 @@ def test_a_waiting_name_does_not_get_handed_to_the_wrong_sensor(book):
book.heard(reading(sensor=0x0C41, at=1_000.0)) book.heard(reading(sensor=0x0C41, at=1_000.0))
assert book.name_for("tower/0C41") == "" assert book.name_for("tower/0C41") == ""
assert book.name_for("?/1A2B") == "back fence" assert book.name_for("?/1A2B") == "back fence"
# ---------------------------------------------------------------------------
# Saying what is arriving, for when nothing is
# ---------------------------------------------------------------------------
def test_the_diagnosis_says_what_each_second_of_band_looked_like(tmp_path,
monkeypatch):
options = wx.WeatherOptions(rate=RATE, offset=OFFSET, diagnose=True,
log=False)
monkeypatch.setattr(wx, "open_device", lambda console, opts: Garden6(30))
console = Console(width=140, force_terminal=False)
with console.capture() as cap:
wx.listen(console, options, str(tmp_path),
book=SensorBook(path=tmp_path / "s.yaml"))
out = cap.get()
assert "noise" in out and "gate" in out and "the noise" in out
assert "pulses" in out and "gaps" in out
# The pulse lengths are the useful part: two or three, and nothing
# in between.
assert "×" in out
assert "Tower 592TXR" in out
def test_the_diagnosis_turns_the_live_table_off(tmp_path):
"""The two cannot share a screen: one redraws in place, one scrolls."""
console = Console(width=120, force_terminal=True)
display, live = wx._open_display(
console, wx.WeatherOptions(diagnose=True), None, time.time())
assert (display, live) == (None, None)
def test_raw_samples_can_be_captured_for_working_out_why(tmp_path,
monkeypatch):
options = wx.WeatherOptions(rate=RATE, offset=OFFSET, messages=True,
log=False)
monkeypatch.setattr(wx, "open_device", lambda console, opts: Garden6(3))
where = tmp_path / "band.cf32"
console = Console(width=120, force_terminal=False)
with console.capture() as cap:
heard = wx.listen(console, options, str(tmp_path),
book=SensorBook(path=tmp_path / "s.yaml"),
save_iq=str(where))
assert heard.iq_path == where and where.exists()
kept = np.fromfile(where, dtype=np.complex64)
assert kept.size == 3 * int(RATE)
# And it says how fast that fills, because it fills fast.
assert "MB a second" in cap.get()
def test_capturing_to_somewhere_unwritable_is_a_message_not_a_crash(
tmp_path, monkeypatch):
options = wx.WeatherOptions(rate=RATE, offset=OFFSET, messages=True,
log=False)
monkeypatch.setattr(wx, "open_device", lambda console, opts: Silence(2))
console = Console(width=120, force_terminal=False)
with console.capture() as cap:
heard = wx.listen(console, options, str(tmp_path),
book=SensorBook(path=tmp_path / "s.yaml"),
save_iq="/proc/nowhere/band.cf32")
assert heard.iq_path is None
assert "cannot write" in cap.get()
def test_an_offset_too_big_for_the_sample_rate_is_refused_with_a_reason():
"""Half the sample rate is all the band there is to move a signal within."""
errs = wx.WeatherOptions(rate=400_000.0, offset=250_000.0).validate()
assert any("offset" in e and "sample rate" in e for e in errs)
assert wx.WeatherOptions(rate=400_000.0, offset=100_000.0).validate() == []
def test_hearing_nothing_points_at_the_diagnosis(tmp_path, monkeypatch):
from bandsaunter.cli import build_parser, cmd_weather
import bandsaunter.cli as cli
monkeypatch.setattr(wx, "open_device", lambda console, opts: Silence(2))
monkeypatch.setattr(wx, "load_options",
lambda *a, **kw: wx.WeatherOptions(rate=RATE,
offset=OFFSET,
messages=True,
log=False))
console = Console(width=120, force_terminal=False)
monkeypatch.setattr(cli, "console", console)
args = build_parser().parse_args(["weather"])
with console.capture() as cap:
assert cmd_weather(args) == 1
assert "--diagnose" in cap.get()
@pytest.mark.parametrize("flags,key,value", [
(["--diagnose"], "diagnose", True),
(["--no-diagnose"], "diagnose", False),
])
def test_the_diagnosis_flags_reach_the_option(flags, key, value):
from bandsaunter.cli import _weather_options, build_parser
args = build_parser().parse_args(["weather"] + flags)
assert getattr(_weather_options(args, wx.WeatherOptions()), key) == value
def test_save_iq_is_a_path_on_the_command_line_and_not_a_saved_setting():
"""It is a one-off capture, not something to carry between runs."""
from bandsaunter.cli import build_parser
args = build_parser().parse_args(["weather", "--save-iq", "/tmp/x.cf32"])
assert args.save_iq == "/tmp/x.cf32"
assert not hasattr(wx.WeatherOptions(), "save_iq")
# ---------------------------------------------------------------------------
# The verdict: which of the four faults it was
# ---------------------------------------------------------------------------
def verdict_of(**tally):
heard = wx.Heard()
heard.survey.update(tally)
console = Console(width=120, force_terminal=False)
with console.capture() as cap:
wx._verdict(console, heard)
return " ".join(cap.get().split())
@pytest.mark.parametrize("tally,says", [
# Nothing arrived at all: not the decoder's fault, and not the aerial's.
(dict(blocks=30, dead=30), "empty samples"),
# Nothing above the noise: the aerial.
(dict(blocks=30), "aerial"),
# Something there, never keyed: not one of these sensors.
(dict(blocks=30, loud=12), "continuously"),
# Sliced and never framed: the radio works, the protocol is not one of
# the five. This is the one where the pulse lengths matter.
(dict(blocks=30, loud=12, bursts=40), "cannot read"),
# Framed and never corroborated: a stronger signal, not a new decoder.
(dict(blocks=30, loud=12, bursts=40, framed=9), "second copy"),
(dict(blocks=30, loud=12, bursts=40, framed=9, reported=6), "Working"),
])
def test_the_verdict_tells_the_four_faults_apart(tally, says):
assert says in verdict_of(**tally)
def test_the_verdict_says_nothing_when_there_was_nothing_to_judge():
assert verdict_of() == ""
def test_the_verdict_is_given_even_when_nothing_was_heard(tmp_path,
monkeypatch):
"""Which is exactly the run whose verdict is worth reading."""
monkeypatch.setattr(wx, "open_device", lambda console, opts: Silence(3))
console = Console(width=120, force_terminal=False)
with console.capture() as cap:
heard = wx.listen(console, wx.WeatherOptions(rate=RATE, offset=OFFSET,
diagnose=True, log=False),
str(tmp_path),
book=SensorBook(path=tmp_path / "s.yaml"))
assert heard.sensors == 0
assert "what that came to" in cap.get()
assert "empty samples" in cap.get()
def test_a_working_run_is_counted_as_one(tmp_path, monkeypatch):
monkeypatch.setattr(wx, "open_device", lambda console, opts: Garden6(30))
console = Console(width=120, force_terminal=False)
with console.capture() as cap:
heard = wx.listen(console, wx.WeatherOptions(rate=RATE, offset=OFFSET,
diagnose=True, log=False,
report=False),
str(tmp_path),
book=SensorBook(path=tmp_path / "s.yaml"))
assert "Working" in cap.get()
assert heard.survey["reported"] == heard.messages
assert heard.survey["blocks"] == 30