Listen the way the tools that work on this band listen
Still nothing, with rtl-433 receiving the same sensors on the same aerial from a different receiver. That settles where the fault is not: not the aerial, not the sensors, not the band. So the sensible thing is to stop differing from the configuration known to work on that aerial, and this differed from it in three ways, every one of them mine. It tuned a quarter of a megahertz to one side of 433.92 and shifted the signal back in software, to keep the receiver's own spike off a signal that works by being switched off. That is a real effect and avoiding it this way is a bad trade: at the sample rate this ran at, the shift is followed by a filter, and a filter narrow enough to reject the spike is narrow enough to lose a transmitter that has drifted -- or to lose the signal outright if a dongle presents its samples the other way round, which is not a thing to depend on. The spike is a steady addition to the envelope and a burst rises clear of it. Tuning straight at the sensors now, which is what the established tools do. It sampled at a megasample a second where a quarter of one is plenty: the shortest pulse these send is two hundred microseconds, which is fifty samples at the lowest rate a dongle will do. The extra rate bought nothing but the room for that filter to exist in. At 250 kS/s nothing after the mixer is narrower than the band, so the offset now does nothing at all whatever it is set to, and says so. And it turned on the RTL2832's digital gain control along with the tuner's. The two pump: the gain winds up through the silence between one burst and the next, lifting the noise towards the signal and squeezing the very difference the burst detector works on. It matters here in a way it does not for aircraft, where a frame is found by correlating a preamble over microseconds rather than by comparing a burst with the quiet around it. Three more faults found while going over the rest of it, all the same mistake in different clothes -- treating the middle of a distribution as though it were the quiet part of one. The check that skips an empty block measured the peak against the median. A recording that is mostly burst measures its own burst against its own burst, finds no difference and is discarded as silence, which is what happened to every short capture. The gate's scatter had the same trouble one level down and could come out above the peak, which is the one setting that cannot be right, so it is now capped below it. And the rule deciding where one message ends keyed on the middle gap in a burst. Where a one is drawn as a gap three times a zero and most of the bits are zeroes, the middle gap is the short one, twice it still falls inside the message, and every one-bit ended a burst -- the message coming apart into pieces of three pulses. It keys on the widest gap now, which is a fact about the message rather than about the data it happened to carry. The pieces of a message are also put back together after being sliced rather than before. Grouping has to be tight, because the group is what fixes the threshold and a group holding two sensors of unequal strength fixes it on the louder; but the gaps inside one message run from two hundred microseconds on the newer sensors to four thousand on the oldest, and a grouping tight enough for the first tears the second into a bit at a time. So each piece is measured at its own amplitude and joined to its neighbours afterwards, and anything that comes out longer than the longest message there is gets cut at its largest gaps. Measured rather than argued: across four hundred and eighty combinations of pulse and gap timing, 464 now read where 417 did; across twenty-one gap-keyed combinations, 18 where 12 did; and eighty seconds of receiver noise still yields nothing at all. --from-iq FILE reads a saved capture instead of the receiver, so a recording made where the aerial is can be worked on anywhere, as many times as it takes. Everything downstream of the dongle is the real thing, which is what tells a receiver problem and a decoder problem apart. --save-iq writes the settings beside the samples, a file of raw samples with no record of its rate being unreadable by anything. The invented garden now waits like a dongle instead of running as fast as the machine allows, which it should have done from the start: --seconds meant nothing against it and a capture came out fifty times too large. Full suite 2331 passed; this work checked against sixteen deliberately broken builds, two of which it survived until the tests were made to catch them, and one change was removed for being unable to earn a test at all. Built as 2026-09-07_03. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
This commit is contained in:
parent
335d83f8a0
commit
706c632f47
10 changed files with 978 additions and 92 deletions
|
|
@ -742,3 +742,202 @@ def test_the_pulse_lengths_of_a_burst_are_reported_as_the_protocol_shape():
|
|||
def test_a_burst_of_one_length_is_reported_as_one_length():
|
||||
assert a.timings([400.0] * 12) == [(400.0, 12)]
|
||||
assert len(a.timings([200.0] * 6 + [400.0] * 6)) == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Timings other than the ones this was written against
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def keyed_at(bits, marks_gaps, copies=3, reset_us=8_000.0, amplitude=1.0,
|
||||
rate=250_000.0, lead_us=3_000.0):
|
||||
"""A sensor keyed with whatever timings, one (mark, gap) pair per bit."""
|
||||
per_us = rate / 1e6
|
||||
parts = [np.zeros(int(lead_us * per_us), dtype=np.float32)]
|
||||
|
||||
def push(mark, gap):
|
||||
parts.append(np.full(int(round(mark * per_us)), amplitude,
|
||||
dtype=np.float32))
|
||||
if gap:
|
||||
parts.append(np.zeros(int(round(gap * per_us)), dtype=np.float32))
|
||||
|
||||
for _ in range(copies):
|
||||
for mark, gap in marks_gaps:
|
||||
push(mark, gap)
|
||||
push(500.0, reset_us)
|
||||
return a._to_air(np.concatenate(parts), rate, 0.0, 0.02, 0)
|
||||
|
||||
|
||||
def pwm_pairs(bits, one=408.0, zero=220.0, gap=None, syncs=4,
|
||||
sync_mark=600.0, sync_gap=600.0):
|
||||
"""Pulse-width keying: the bit is the pulse. ``gap=None`` completes the
|
||||
bit period, which is one of the two conventions; a number is a spacer,
|
||||
which is the other."""
|
||||
out = [(sync_mark, sync_gap)] * syncs
|
||||
for bit in bits:
|
||||
mark = one if bit == "1" else zero
|
||||
out.append((mark, (one + zero) - mark if gap is None else gap))
|
||||
return out
|
||||
|
||||
|
||||
def ppm_pairs(bits, mark=500.0, short=1_000.0, long=2_000.0):
|
||||
"""Pulse-position keying: the bit is the gap."""
|
||||
return [(mark, long if bit == "1" else short) for bit in bits]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,pairs,reset", [
|
||||
# The two pulse-width conventions, at the published widths.
|
||||
("pwm, gap completes the bit", pwm_pairs, 8_000.0),
|
||||
# ...and with the pulses stretched and squeezed, because these
|
||||
# transmitters are unlocked and drift with the temperature.
|
||||
("pwm 20% slow", lambda b: [(m * 1.2, g * 1.2)
|
||||
for m, g in pwm_pairs(b)], 9_600.0),
|
||||
("pwm 15% fast", lambda b: [(m * 0.85, g * 0.85)
|
||||
for m, g in pwm_pairs(b)], 6_800.0),
|
||||
# A fixed spacer rather than a complement, at three widths.
|
||||
("pwm, 200 us spacer", lambda b: pwm_pairs(b, gap=200.0), 8_000.0),
|
||||
("pwm, 400 us spacer", lambda b: pwm_pairs(b, gap=400.0), 8_000.0),
|
||||
("pwm, 600 us spacer", lambda b: pwm_pairs(b, gap=600.0), 8_000.0),
|
||||
# Different numbers of sync pulses, since nothing here counts them.
|
||||
("pwm, no sync", lambda b: pwm_pairs(b, syncs=0), 8_000.0),
|
||||
("pwm, 8 sync pulses", lambda b: pwm_pairs(b, syncs=8), 8_000.0),
|
||||
# And the short pulse meaning one.
|
||||
("pwm inverted", lambda b: pwm_pairs(b, one=220.0, zero=408.0,
|
||||
gap=200.0), 8_000.0),
|
||||
])
|
||||
def test_a_tower_sensor_is_read_at_timings_other_than_the_expected_ones(
|
||||
name, pairs, reset):
|
||||
bits = a.tower_frame(0x1A2B, 21.5, 48, "A")
|
||||
build = pairs if callable(pairs) else (lambda b: pairs)
|
||||
iq = keyed_at(bits, build(bits), reset_us=reset)
|
||||
got = a.readings_from(iq, 250_000.0)
|
||||
assert [r.sensor for r in got] == ["1A2B"], f"{name}: heard {got}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,short,long,reset", [
|
||||
# The 609TXC's published gaps.
|
||||
("609 gaps", 1_000.0, 2_000.0, 10_000.0),
|
||||
# The 606TX's, which are twice as wide -- wider than any grouping tight
|
||||
# enough to keep two sensors apart, which is why the pieces of a message
|
||||
# are put back together after being sliced rather than before.
|
||||
("606 gaps", 2_000.0, 4_000.0, 8_000.0),
|
||||
("half again as wide", 3_000.0, 6_000.0, 14_000.0),
|
||||
])
|
||||
def test_a_gap_keyed_sensor_is_read_however_wide_its_gaps_are(name, short,
|
||||
long, reset):
|
||||
bits = a.frame_609(0x5C, 4.2, 80)
|
||||
iq = keyed_at(bits, ppm_pairs(bits, short=short, long=long),
|
||||
reset_us=reset)
|
||||
got = a.readings_from(iq, 250_000.0)
|
||||
assert [r.sensor for r in got] == ["5C"], f"{name}: heard {got}"
|
||||
|
||||
|
||||
def test_three_copies_run_together_are_still_three_copies():
|
||||
"""A burst too long to be one message is cut at its largest gaps.
|
||||
|
||||
The gaps inside a 606 message and the wait between its copies are only a
|
||||
factor of two apart, which is too close to tell from a ratio -- so the
|
||||
joining is allowed to be greedy and the result is divided by counting
|
||||
instead.
|
||||
"""
|
||||
bits = a.frame_606(0x93, -3.5)
|
||||
iq = keyed_at(bits, ppm_pairs(bits, short=2_000.0, long=4_000.0),
|
||||
reset_us=8_000.0)
|
||||
envelope, rate = a.baseband(iq, 250_000.0, 0.0)
|
||||
found = a.bursts(envelope, rate)
|
||||
assert len(found) == 3, [b.pulses for b in found]
|
||||
assert all(b.pulses <= a.MAX_PULSES for b in found)
|
||||
assert [r.sensor for r in a.readings_from(iq, 250_000.0)] == ["93"]
|
||||
|
||||
|
||||
def test_a_burst_no_longer_than_a_message_is_left_whole():
|
||||
burst = a.Burst(marks=(400.0,) * 40, spaces=(220.0,) * 39)
|
||||
assert a._divide([burst]) == [burst]
|
||||
|
||||
|
||||
def test_a_capture_that_is_mostly_burst_is_not_thrown_away_as_empty():
|
||||
"""The noise is read off the bottom of a block, never off the middle.
|
||||
|
||||
Taken from the median, a block that is largely signal measures its own
|
||||
burst against its own burst, finds no difference, and is discarded as
|
||||
silence. Which is what happened to any short capture: the shorter it
|
||||
was, the larger the share of it that was the thing being looked for.
|
||||
"""
|
||||
bits = a.tower_frame(0x1A2B, 21.5, 48, "A")
|
||||
# Short gaps, so the carrier is on for most of the recording: this is a
|
||||
# real timing, and it is the one that showed the fault.
|
||||
iq = keyed_at(bits, pwm_pairs(bits, gap=150.0), copies=1,
|
||||
reset_us=1_000.0, lead_us=500.0)
|
||||
envelope, rate = a.baseband(iq, 250_000.0, 0.0)
|
||||
smooth = a._smoothed(envelope, rate)
|
||||
# More than half of it is the sensor talking, which is what puts the
|
||||
# median inside the signal and the block in danger of being discarded.
|
||||
assert (smooth > smooth.max() / 2).mean() > 0.5
|
||||
assert np.median(smooth) * 1.8 > np.percentile(smooth, 99.99)
|
||||
assert [r.sensor for r in a.readings_from(iq, 250_000.0)] == ["1A2B"]
|
||||
|
||||
|
||||
def test_a_bit_drawn_as_a_much_longer_gap_does_not_end_the_burst():
|
||||
"""Where a one is three times a zero and most bits are zeroes, the middle
|
||||
gap is the short one -- so a rule keyed on the middle of the gaps cuts
|
||||
the message at every one-bit and hands back pieces of three pulses."""
|
||||
bits = a.frame_609(0x5C, 4.2, 80)
|
||||
iq = keyed_at(bits, ppm_pairs(bits, short=800.0, long=2_400.0),
|
||||
reset_us=10_000.0)
|
||||
envelope, rate = a.baseband(iq, 250_000.0, 0.0)
|
||||
found = a.bursts(envelope, rate)
|
||||
assert found and max(b.pulses for b in found) >= 40
|
||||
assert [r.sensor for r in a.readings_from(iq, 250_000.0)] == ["5C"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("one,zero,gap", [
|
||||
(408.0, 220.0, None), (408.0, 220.0, 200.0), (500.0, 250.0, 250.0),
|
||||
(300.0, 150.0, 150.0), (600.0, 300.0, 300.0), (400.0, 200.0, 400.0),
|
||||
])
|
||||
def test_the_envelope_of_pulse_widths_it_will_read(one, zero, gap):
|
||||
bits = a.tower_frame(0x1A2B, 21.5, 48, "A")
|
||||
iq = keyed_at(bits, pwm_pairs(bits, one=one, zero=zero, gap=gap))
|
||||
assert [r.sensor for r in a.readings_from(iq, 250_000.0)] == ["1A2B"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("duty", [0.05, 0.3, 0.5, 0.62, 0.75, 0.85])
|
||||
def test_the_gate_sits_between_the_quiet_and_the_loudest_at_any_duty(duty):
|
||||
"""Two ways to get this wrong, and both report a silence that was not.
|
||||
|
||||
The scatter it is built from has to be measured inside the quiet part.
|
||||
Measured across the edge between off and on -- which is where a
|
||||
percentile lands once the carrier is on for much of the block -- it
|
||||
measures the signal, and six times the signal is a gate above everything
|
||||
in the block.
|
||||
|
||||
And whatever the arithmetic comes to, a gate above the loudest thing
|
||||
there is cannot be right: it is the one setting that guarantees nothing
|
||||
is found on a second that plainly had something in it.
|
||||
"""
|
||||
rng = np.random.default_rng(4)
|
||||
n = 60_000
|
||||
envelope = np.abs(rng.standard_normal(n)
|
||||
+ 1j * rng.standard_normal(n)).astype(np.float32) * 0.02
|
||||
# The carrier keyed on for `duty` of the block, in pulses rather than
|
||||
# one lump, which is what a message looks like.
|
||||
period = 400
|
||||
for start in range(0, n - period, period):
|
||||
envelope[start:start + int(period * duty)] += 1.0
|
||||
smooth = a._smoothed(envelope, 250_000.0)
|
||||
quiet = float(np.percentile(smooth, 20))
|
||||
peak = float(np.percentile(smooth, 99.99))
|
||||
gate = a._noise_gate(smooth, quiet, peak)
|
||||
assert gate < peak, f"duty {duty}: gate {gate:.3f} is above the peak {peak:.3f}"
|
||||
assert gate > quiet, f"duty {duty}: gate {gate:.3f} is at or below the noise"
|
||||
|
||||
|
||||
def test_the_gate_still_keeps_noise_out_when_there_is_no_signal():
|
||||
"""The other half of it: a quiet band must find nothing at all."""
|
||||
rng = np.random.default_rng(11)
|
||||
for _ in range(8):
|
||||
noise = np.abs(rng.standard_normal(60_000)
|
||||
+ 1j * rng.standard_normal(60_000)).astype(np.float32)
|
||||
smooth = a._smoothed(noise, 250_000.0)
|
||||
quiet = float(np.percentile(smooth, 20))
|
||||
peak = float(np.percentile(smooth, 99.99))
|
||||
over = (smooth > a._noise_gate(smooth, quiet, peak)).mean()
|
||||
assert over < 0.01, f"{over:.1%} of pure noise cleared the gate"
|
||||
|
|
|
|||
|
|
@ -1244,3 +1244,300 @@ def test_a_working_run_is_counted_as_one(tmp_path, monkeypatch):
|
|||
assert "Working" in cap.get()
|
||||
assert heard.survey["reported"] == heard.messages
|
||||
assert heard.survey["blocks"] == 30
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Working on a capture instead of on the air
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def captured(tmp_path, seconds=3, rate=RATE, offset=OFFSET):
|
||||
"""A recording of the invented garden, with its settings beside it."""
|
||||
import json
|
||||
|
||||
sky = a.SimulatedSensors(sample_rate=rate, offset=offset, seed=3)
|
||||
where = tmp_path / "band.cf32"
|
||||
with open(where, "wb") as fh:
|
||||
for _ in range(seconds):
|
||||
sky.read_samples(int(rate)).astype("complex64").tofile(fh)
|
||||
where.with_suffix(".cf32.json").write_text(json.dumps({
|
||||
"sample_rate": rate, "frequency": a.ACURITE_HZ, "offset": offset,
|
||||
"format": "complex64"}))
|
||||
return where
|
||||
|
||||
|
||||
def test_a_capture_answers_the_way_the_receiver_that_made_it_did(tmp_path):
|
||||
where = captured(tmp_path, seconds=4)
|
||||
replay = wx.Replay(where)
|
||||
assert replay.rate == RATE and replay.offset == OFFSET
|
||||
assert replay.frequency == a.ACURITE_HZ
|
||||
assert len(replay) == 4 * int(RATE)
|
||||
assert replay.seconds == pytest.approx(4.0)
|
||||
assert replay.read_samples(int(RATE)).size == int(RATE)
|
||||
|
||||
|
||||
def test_a_capture_runs_out_rather_than_repeating_itself(tmp_path):
|
||||
replay = wx.Replay(captured(tmp_path, seconds=2))
|
||||
assert replay.read_samples(int(RATE)).size == int(RATE)
|
||||
assert replay.read_samples(int(RATE)).size == int(RATE)
|
||||
assert replay.read_samples(int(RATE)).size == 0
|
||||
|
||||
|
||||
def test_the_settings_are_written_beside_a_capture(tmp_path, monkeypatch):
|
||||
"""A file of samples with no idea what rate it was taken at is nothing.
|
||||
|
||||
Read back at the wrong rate every pulse in it is the wrong length, and
|
||||
nothing will ever decode however good the decoder is.
|
||||
"""
|
||||
import json
|
||||
|
||||
options = wx.WeatherOptions(rate=RATE, offset=OFFSET, messages=True,
|
||||
log=False)
|
||||
monkeypatch.setattr(wx, "open_device", lambda console, opts: Garden6(2))
|
||||
where = tmp_path / "band.cf32"
|
||||
console = Console(width=120, force_terminal=False)
|
||||
with console.capture():
|
||||
wx.listen(console, options, str(tmp_path),
|
||||
book=SensorBook(path=tmp_path / "s.yaml"),
|
||||
save_iq=str(where))
|
||||
beside = json.loads(where.with_suffix(".cf32.json").read_text())
|
||||
assert beside["sample_rate"] == RATE and beside["offset"] == OFFSET
|
||||
assert beside["format"] == "complex64"
|
||||
assert wx.Replay(where).rate == RATE
|
||||
|
||||
|
||||
def test_a_capture_with_no_settings_beside_it_falls_back_rather_than_failing(
|
||||
tmp_path):
|
||||
where = captured(tmp_path, seconds=2)
|
||||
where.with_suffix(".cf32.json").unlink()
|
||||
replay = wx.Replay(where, rate=RATE, offset=OFFSET)
|
||||
assert replay.rate == RATE and replay.settings == {}
|
||||
|
||||
|
||||
def test_the_same_readings_come_out_of_a_capture_as_off_the_air(tmp_path,
|
||||
monkeypatch):
|
||||
"""The only thing stood in for is the dongle, so it has to be the same.
|
||||
|
||||
That is the whole use of a capture: a recording that yields nothing here
|
||||
yields nothing for anybody and the fault is in this program, and one that
|
||||
yields readings here and not on the air is a setting.
|
||||
"""
|
||||
where = captured(tmp_path, seconds=6)
|
||||
console = Console(width=120, force_terminal=False)
|
||||
options = wx.WeatherOptions(rate=RATE, offset=OFFSET, messages=True,
|
||||
log=False, report=False)
|
||||
|
||||
monkeypatch.setattr(wx, "open_device", lambda console, opts: Garden6(6))
|
||||
with console.capture():
|
||||
live = wx.listen(console, options, str(tmp_path),
|
||||
book=SensorBook(path=tmp_path / "a.yaml"))
|
||||
with console.capture():
|
||||
back = wx.listen(console, options, str(tmp_path), device=wx.Replay(where),
|
||||
book=SensorBook(path=tmp_path / "b.yaml"))
|
||||
assert {s.key for s in back.garden.all()} == {s.key for s in live.garden.all()}
|
||||
assert back.messages == live.messages
|
||||
|
||||
|
||||
def test_a_capture_can_be_replayed_from_the_command_line(tmp_path,
|
||||
monkeypatch):
|
||||
from bandsaunter.cli import build_parser, cmd_weather
|
||||
import bandsaunter.cli as cli
|
||||
|
||||
where = captured(tmp_path, seconds=6)
|
||||
monkeypatch.setattr(wx, "load_options",
|
||||
lambda *a, **kw: wx.WeatherOptions(messages=True,
|
||||
log=False,
|
||||
report=False))
|
||||
console = Console(width=120, force_terminal=False)
|
||||
monkeypatch.setattr(cli, "console", console)
|
||||
args = build_parser().parse_args(["weather", "--from-iq", str(where)])
|
||||
with console.capture() as cap:
|
||||
assert cmd_weather(args) == 0
|
||||
out = cap.get()
|
||||
assert "replaying" in out and "Tower 592TXR" in out
|
||||
|
||||
|
||||
def test_replaying_something_that_is_not_a_capture_is_a_message(tmp_path,
|
||||
monkeypatch):
|
||||
from bandsaunter.cli import build_parser, cmd_weather
|
||||
import bandsaunter.cli as cli
|
||||
|
||||
empty = tmp_path / "nothing.cf32"
|
||||
empty.write_bytes(b"")
|
||||
console = Console(width=120, force_terminal=False)
|
||||
monkeypatch.setattr(cli, "console", console)
|
||||
args = build_parser().parse_args(["weather", "--from-iq", str(empty)])
|
||||
with console.capture() as cap:
|
||||
assert cmd_weather(args) == 1
|
||||
assert "no samples" in cap.get()
|
||||
|
||||
|
||||
def test_the_capture_settings_win_over_whatever_was_saved(tmp_path,
|
||||
monkeypatch):
|
||||
"""Read at the wrong rate, every pulse in it is the wrong length."""
|
||||
from bandsaunter.cli import build_parser, cmd_weather
|
||||
import bandsaunter.cli as cli
|
||||
|
||||
where = captured(tmp_path, seconds=3)
|
||||
monkeypatch.setattr(wx, "load_options",
|
||||
lambda *a, **kw: wx.WeatherOptions(
|
||||
rate=1_024_000.0, offset=250_000.0,
|
||||
messages=True, log=False, report=False))
|
||||
console = Console(width=120, force_terminal=False)
|
||||
monkeypatch.setattr(cli, "console", console)
|
||||
args = build_parser().parse_args(["weather", "--from-iq", str(where)])
|
||||
with console.capture() as cap:
|
||||
cmd_weather(args)
|
||||
assert f"{RATE / 1e6:g} MS/s" in cap.get()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Finding out where the sensors actually are
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Above about half a megasample a second there is room to filter, and a
|
||||
# filter is applied -- which is the only circumstance in which the tuning
|
||||
# offset does anything at all. At the default rate nothing after the mixer
|
||||
# is narrower than the band, and turning a number does not change its size.
|
||||
WIDE = 1_024_000.0
|
||||
STEP = WIDE / 4.0
|
||||
|
||||
|
||||
def test_the_tuning_offset_does_nothing_at_the_default_sample_rate():
|
||||
"""Which is worth a test, because the option says so and people read it."""
|
||||
iq = a.modulate(a.tower_frame(0x1A2B, 21.5, 48, "A"), RATE, offset=0.0,
|
||||
noise=0.02)
|
||||
straight = [r.bits for r in a.readings_from(iq, RATE, 0.0)]
|
||||
shifted = [r.bits for r in a.readings_from(iq, RATE, RATE / 4.0)]
|
||||
assert straight and straight == shifted
|
||||
|
||||
|
||||
def test_the_diagnosis_finds_the_offset_that_would_have_worked():
|
||||
"""Rather than asking somebody to try four combinations and report back.
|
||||
|
||||
Which way round a dongle presents its samples is not knowable from here
|
||||
and is perfectly findable, so when a second plainly held something and
|
||||
the configured setting read none of it, the others are tried.
|
||||
"""
|
||||
iq = a.modulate(a.tower_frame(0x1A2B, 21.5, 48, "A"), WIDE, offset=STEP,
|
||||
noise=0.02)
|
||||
looking_ahead = wx.WeatherOptions(rate=WIDE, offset=0.0)
|
||||
assert a.readings_from(iq, WIDE, 0.0) == []
|
||||
assert wx._other_settings(iq, looking_ahead) == f"--offset {STEP:.0f}"
|
||||
|
||||
|
||||
def test_it_does_not_go_looking_when_the_setting_is_already_working():
|
||||
iq = a.modulate(a.tower_frame(0x1A2B, 21.5, 48, "A"), WIDE, offset=STEP,
|
||||
noise=0.02)
|
||||
options = wx.WeatherOptions(rate=WIDE, offset=STEP)
|
||||
assert a.readings_from(iq, WIDE, STEP) != []
|
||||
|
||||
|
||||
def test_the_verdict_says_which_offset_to_use(tmp_path, monkeypatch):
|
||||
step = STEP
|
||||
|
||||
class Misplaced:
|
||||
"""A garden that is not where the receiver is looking."""
|
||||
|
||||
def __init__(self, blocks):
|
||||
self.sky = a.SimulatedSensors(sample_rate=WIDE, offset=step,
|
||||
seed=3)
|
||||
self.left = blocks
|
||||
|
||||
def tune(self, hz, settle=True):
|
||||
return int(hz)
|
||||
|
||||
def read_samples(self, count, flush=False):
|
||||
if self.left <= 0:
|
||||
return np.zeros(0, dtype=np.complex64)
|
||||
self.left -= 1
|
||||
return self.sky.read_samples(count)
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(wx, "open_device", lambda console, opts: Misplaced(25))
|
||||
console = Console(width=140, force_terminal=False)
|
||||
with console.capture() as cap:
|
||||
heard = wx.listen(console, wx.WeatherOptions(rate=WIDE, offset=0.0,
|
||||
diagnose=True, log=False,
|
||||
report=False),
|
||||
str(tmp_path),
|
||||
book=SensorBook(path=tmp_path / "s.yaml"))
|
||||
out = cap.get()
|
||||
assert heard.sensors == 0
|
||||
assert "try this" in out
|
||||
assert f"--offset {step:.0f}" in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The invented garden keeps time
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_the_invented_garden_hands_back_a_second_once_a_second_has_passed():
|
||||
"""It stands in for a dongle, so it waits like one.
|
||||
|
||||
Without the wait a block comes back the moment it is asked for, the
|
||||
garden lives several times faster than the clock its readings are
|
||||
stamped with, and a capture bounded by --seconds comes out however many
|
||||
times too large the machine happens to be worth.
|
||||
"""
|
||||
import time as clock
|
||||
|
||||
sky = a.SimulatedSensors(sample_rate=RATE, realtime=True)
|
||||
began = clock.monotonic()
|
||||
for _ in range(3):
|
||||
sky.read_samples(int(RATE / 10)) # a tenth of a second each
|
||||
took = clock.monotonic() - began
|
||||
assert 0.15 < took < 0.6, f"three tenths of a second took {took:.2f}s"
|
||||
|
||||
|
||||
def test_a_test_garden_does_not_wait_at_all():
|
||||
import time as clock
|
||||
|
||||
sky = a.SimulatedSensors(sample_rate=RATE, realtime=False)
|
||||
began = clock.monotonic()
|
||||
for _ in range(3):
|
||||
sky.read_samples(int(RATE))
|
||||
assert clock.monotonic() - began < 2.0
|
||||
|
||||
|
||||
def test_the_digital_gain_control_after_the_tuner_is_left_off(monkeypatch):
|
||||
"""The tuner's own control does its job; the one after it pumps.
|
||||
|
||||
It winds the gain up through the silence between one burst and the next,
|
||||
which lifts the noise towards the signal and squeezes the very difference
|
||||
the burst detector works on. It matters here in a way it does not for
|
||||
aircraft, where a frame is found by correlating a preamble over a few
|
||||
microseconds rather than by comparing a burst with the quiet around it.
|
||||
The established tools for this band leave it off, and so does this.
|
||||
"""
|
||||
import bandsaunter.device as device
|
||||
|
||||
asked = {}
|
||||
|
||||
class Fake:
|
||||
def __init__(self, **kw):
|
||||
asked.update(kw)
|
||||
|
||||
def open(self):
|
||||
return self
|
||||
|
||||
monkeypatch.setattr(device, "RtlSdrDevice", Fake)
|
||||
console = Console(width=120, force_terminal=False)
|
||||
for gain in ("auto", "40"):
|
||||
wx.open_device(console, wx.WeatherOptions(gain=gain))
|
||||
assert asked["agc"] is False, f"digital AGC on with gain {gain!r}"
|
||||
assert asked["gain"] == gain # the tuner's own is still asked for
|
||||
|
||||
|
||||
def test_the_defaults_are_the_ones_the_established_tools_use():
|
||||
"""250 kS/s, straight at 433.92 MHz, no offset.
|
||||
|
||||
Not a matter of taste: this is the configuration known to receive these
|
||||
sensors on hardware this program has never run on, and differing from it
|
||||
bought nothing and cost everything.
|
||||
"""
|
||||
options = wx.WeatherOptions()
|
||||
assert options.rate == 250_000.0
|
||||
assert options.offset == 0.0
|
||||
assert options.frequency == a.ACURITE_HZ
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue