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:
parent
65cc03b78d
commit
335d83f8a0
10 changed files with 1105 additions and 96 deletions
|
|
@ -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.sensor == "0011"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
assert book.name_for("tower/0C41") == ""
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue