Say how strongly each sensor is being heard, and how much of it arrives

Two numbers rather than one, because "how well is this sensor coming in" is
two questions and they can disagree in a way that is worth seeing.

The first is strength: how far the sensor's burst stood above the noise of the
second it arrived in, in decibels, on every reading and in the log and the
spreadsheet.  The burst detector already worked this out and threw it away --
it is the ratio the per-burst threshold is set from -- so this is carrying a
number through rather than measuring a new one.

What it is not is a power at the aerial, and the docstring says so where
somebody will read it.  A dongle has no reference level and, with the tuner
left on automatic, no fixed gain either; anything in dBm would be invention.
A ratio of two amplitudes off the same receiver in the same second is the
honest quantity, and it is enough for the three things anybody wants a signal
reading for: comparing two sensors now, watching one over an evening, and
pointing an aerial.  A fixed --gain makes it comparable between runs as well,
which the help now says.

It is coloured red, amber or green, it is on the live display as well as the
report, and it is kept on a narrow terminal when other columns are dropped --
because somebody moving a whip about while a number climbs is not doing it on
a wide window, and that is the most useful thing this does.

The second is the share of what a sensor sent that actually arrives, which
comes out of the timing for nothing.  These transmit on a fixed cycle, so the
shortest wait ever seen between two of a sensor's messages is that cycle, and
the average wait is the cycle divided by the fraction getting through: one
over the other is the fraction, with no need to know the model or how often it
is meant to speak.

Read together they say more than either does alone.  A strong signal with a
low share is interference or a collision rather than distance.  A weak signal
at a hundred per cent is a sensor at the edge that is getting through anyway
and is best left alone.

The strongest of the three copies of a message is the one reported, not the
first: they go out milliseconds apart and arrive at whatever the fading does
to each.  A reading with no strength -- an older log, a block with no
measurable noise floor to be a ratio to -- leaves the last known figure alone
rather than overwriting it with a zero.

Full suite 2369 passed, checked against five deliberately broken builds
including the one that reports decibels as a power ratio, which is off by a
factor of two and looks entirely reasonable.  Built as 2026-09-07_06.

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 23:50:37 -07:00
parent 872eadac37
commit 03d3600ecc
10 changed files with 375 additions and 24 deletions

View file

@ -2048,11 +2048,40 @@ mast wind 14.2 km/h 3.1 km/h 0.0 km/h 38.6 km/h
``` ```
The two tables are split on purpose. The first is about reception — who, how The two tables are split on purpose. The first is about reception — who, how
often, how well — and is the one to look at when something is missing; `every` often, how well — and is the one to look at when something is missing. The
is the honest measure of an aerial, because these transmit on a fixed cycle, second is about the weather, and is the one to look at when nothing is.
so thirty seconds from a sensor that sends every sixteen means half of them
are being missed. The second is about the weather, and is the one to look at ### How well each sensor is heard
when nothing is.
Three columns of the first table answer that, and they answer different halves
of it.
**`signal`** is how far the sensor's burst stood above the noise, in decibels,
coloured red below 14 dB, amber below 22, green above. It is a ratio of two
amplitudes off the same receiver in the same second and nothing more — *not* a
power at the aerial, which an RTL-SDR cannot give: it has no reference level,
and on automatic gain it does not have a fixed gain either. What a ratio is
good for is comparing one sensor with another at the same moment, watching one
sensor over an evening, and pointing an aerial. Set `--gain 40` (or any fixed
figure) if you want the numbers comparable between one run and the next;
on automatic they drift with whatever the tuner decided.
It is on the live display too, and kept there even on a narrow terminal,
because moving a whip around while watching a number go up is the single most
useful thing it does.
**`every`** is the average wait between messages, and **`heard`** turns that
into a share. These transmit on a fixed cycle, so the shortest wait ever seen
between two of a sensor's messages *is* that cycle, and the average wait is
the cycle divided by the fraction getting through — one over the other is
therefore the share, without ever needing to know what model it is or how
often it is supposed to speak.
The two are worth reading together, and they can disagree usefully. A strong
signal with a low share is interference or a collision with another
transmitter rather than a range problem. A weak signal at a hundred per cent
is a sensor at the edge that is nonetheless getting through, and is worth
leaving alone.
There is no average. These arrive every sixteen seconds when the sensor is in There is no average. These arrive every sixteen seconds when the sensor is in
range and not at all when it is not, and rain and cold both shorten the range range and not at all when it is not, and rain and cold both shorten the range
@ -2140,6 +2169,8 @@ 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 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 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. problem. Try `--gain 40` if the automatic gain control is not finding them.
Once anything at all is being heard, the `signal` column on the live display
is the thing to watch while moving the aerial about.
**`peak` is well above `noise` and there are no bursts.** Something is there **`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, and did not group — usually a continuous transmitter rather than a keyed one,

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 = 5 VERSION_REVISION = 6
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}" __version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"

View file

@ -232,6 +232,7 @@ class Reading:
at: float = 0.0 # when it arrived, as a clock time at: float = 0.0 # when it arrived, as a clock time
copies: int = 1 # how many times it arrived, identically copies: int = 1 # how many times it arrived, identically
offset: int = 0 # where in the burst its first bit was offset: int = 0 # where in the burst its first bit was
snr: float = 0.0 # decibels above the noise; 0 = not known
@property @property
def key(self) -> str: def key(self) -> str:
@ -740,6 +741,22 @@ class Burst:
def pulses(self) -> int: def pulses(self) -> int:
return len(self.marks) return len(self.marks)
@property
def decibels(self) -> float:
"""How far the burst stood above the noise, in dB.
A ratio of two amplitudes off the same receiver in the same second,
which is the only honest thing to say about strength here. It is not
a power at the aerial and cannot be: a dongle has no reference level,
and with the tuner left on automatic it does not have a fixed gain
either. What it is good for is comparing one sensor with another at
the same moment, watching one sensor over an evening, and pointing an
aerial -- which is most of what anybody wants a signal reading for.
"""
if self.level <= 0.0 or self.level == float("inf"):
return 0.0
return 20.0 * math.log10(self.level)
@property @property
def length_us(self) -> float: def length_us(self) -> float:
return float(sum(self.marks) + sum(self.spaces)) return float(sum(self.marks) + sum(self.spaces))
@ -1313,6 +1330,7 @@ def readings_from(iq: np.ndarray, sample_rate: float, offset: float = 0.0,
here.setdefault((reading.family, reading.bits), reading) here.setdefault((reading.family, reading.bits), reading)
for reading in here.values(): for reading in here.values():
reading.at = when + burst.at reading.at = when + burst.at
reading.snr = burst.decibels
found.append(reading) found.append(reading)
return confirmed(found) return confirmed(found)
@ -1333,6 +1351,11 @@ def confirmed(found: list[Reading]) -> list[Reading]:
continue continue
first = min(group, key=lambda r: r.at) first = min(group, key=lambda r: r.at)
first.copies = len(group) first.copies = len(group)
# The best of the copies, not the first: three copies of a message go
# out a few milliseconds apart and arrive at whatever the fading does
# to each, so the strongest is the fairer answer to how well that
# sensor is being heard.
first.snr = max(r.snr for r in group)
out.append(first) out.append(first)
return sorted(out, key=lambda r: (r.at, r.family, r.sensor)) return sorted(out, key=lambda r: (r.at, r.family, r.sensor))

View file

@ -886,8 +886,13 @@ class WeatherDisplay:
if width >= 92: if width >= 92:
t.add_column("model", width=16, style="grey62", no_wrap=True) t.add_column("model", width=16, style="grey62", no_wrap=True)
t.add_column("readings", overflow="fold") t.add_column("readings", overflow="fold")
if width >= 68:
# Widest-first, but this one is kept on a narrow terminal: it is
# what somebody moving an aerial about is watching, and they are
# not doing it on a wide window.
t.add_column("signal", width=7, justify="right")
t.add_column("batt", width=4, justify="center") t.add_column("batt", width=4, justify="center")
if width >= 76: if width >= 84:
t.add_column("msgs", width=5, justify="right", style="grey62") t.add_column("msgs", width=5, justify="right", style="grey62")
t.add_column("ago", width=5, justify="right", style="grey62") t.add_column("ago", width=5, justify="right", style="grey62")
for i, station in enumerate(here, 1): for i, station in enumerate(here, 1):
@ -899,9 +904,13 @@ class WeatherDisplay:
if width >= 92: if width >= 92:
row.append(station.model or "") row.append(station.model or "")
row.append(self._readings(station, now)) row.append(self._readings(station, now))
if width >= 68:
from .weather import signal_text
row.append(Text.from_markup(signal_text(station.snr)))
row.append(Text("low", style="bold red") if station.battery_low row.append(Text("low", style="bold red") if station.battery_low
else Text("ok", style="green")) else Text("ok", style="green"))
if width >= 76: if width >= 84:
row.append(f"{station.messages:,}") row.append(f"{station.messages:,}")
row.append(_dur(max(0.0, now - station.last))) row.append(_dur(max(0.0, now - station.last)))
t.add_row(*row) t.add_row(*row)

View file

@ -36,6 +36,7 @@ from .settings import Setting, format_value
from .weatherlog import logs_in from .weatherlog import logs_in
__all__ = ["WeatherOptions", "OPTIONS", "OPTION_GROUPS", "defaults", __all__ = ["WeatherOptions", "OPTIONS", "OPTION_GROUPS", "defaults",
"signal_text", "SIGNAL_FAIR", "SIGNAL_GOOD",
"in_group", "by_key", "format_option", "describe", "summarise", "in_group", "by_key", "format_option", "describe", "summarise",
"load_options", "save_options", "options_path", "logs_in", "load_options", "save_options", "options_path", "logs_in",
"Heard", "Station", "Garden", "listen", "open_device", "open_log", "Heard", "Station", "Garden", "listen", "open_device", "open_log",
@ -398,6 +399,10 @@ class Station:
messages: int = 0 messages: int = 0
copies: int = 0 copies: int = 0
battery_low: bool = False battery_low: bool = False
snr: float = 0.0 # dB above the noise, most recently
best_snr: float = 0.0
worst_snr: float = 0.0
closest: float = 0.0 # the shortest wait between two messages
values: dict = field(default_factory=dict) # name -> the latest Measure values: dict = field(default_factory=dict) # name -> the latest Measure
times: dict = field(default_factory=dict) # name -> when that arrived times: dict = field(default_factory=dict) # name -> when that arrived
firsts: dict = field(default_factory=dict) # name -> the first value firsts: dict = field(default_factory=dict) # name -> the first value
@ -416,9 +421,16 @@ class Station:
def add(self, reading) -> None: def add(self, reading) -> None:
self.model = reading.model or self.model self.model = reading.model or self.model
self.channel = reading.channel or self.channel self.channel = reading.channel or self.channel
if self.last and reading.at > self.last:
step = reading.at - self.last
self.closest = min(self.closest or step, step)
self.first = self.first or reading.at self.first = self.first or reading.at
self.last = max(self.last, reading.at) self.last = max(self.last, reading.at)
self.messages += 1 self.messages += 1
if reading.snr:
self.snr = reading.snr
self.best_snr = max(self.best_snr, reading.snr)
self.worst_snr = min(self.worst_snr or reading.snr, reading.snr)
self.copies += max(1, reading.copies) self.copies += max(1, reading.copies)
self.battery_low = bool(reading.battery_low) self.battery_low = bool(reading.battery_low)
if not reading.measures: if not reading.measures:
@ -451,6 +463,24 @@ class Station:
return (self.last - self.first) / (self.messages - 1) \ return (self.last - self.first) / (self.messages - 1) \
if self.messages > 1 else 0.0 if self.messages > 1 else 0.0
@property
def share(self) -> float:
"""Roughly what fraction of what it sent is arriving, or zero.
These transmit on a fixed cycle, so the shortest wait ever seen
between two of its messages is that cycle, and the average wait is
the cycle divided by the share that gets through. One over the other
is therefore the share, without ever having to be told what model it
is or how often it is supposed to speak.
It wants a few messages before it means anything, and it is a floor
rather than a figure: a sensor that has never once been heard twice
in a row looks worse than it is.
"""
if self.messages < 4 or not self.closest or not self.gap:
return 0.0
return min(1.0, self.closest / self.gap)
def span(self, name: str): def span(self, name: str):
"""First, last, lowest and highest of one quantity, or None. """First, last, lowest and highest of one quantity, or None.
@ -1137,6 +1167,8 @@ def report(console, garden: Garden, book=None, imperial: bool = False) -> None:
t.add_column("ch", style="grey62", justify="center") t.add_column("ch", style="grey62", justify="center")
t.add_column("msgs", justify="right") t.add_column("msgs", justify="right")
t.add_column("every", style="grey62", justify="right") t.add_column("every", style="grey62", justify="right")
t.add_column("signal", justify="right")
t.add_column("heard", style="grey62", justify="right")
t.add_column("battery") t.add_column("battery")
t.add_column("last heard", style="grey62", no_wrap=True) t.add_column("last heard", style="grey62", no_wrap=True)
for station in stations: for station in stations:
@ -1145,6 +1177,8 @@ def report(console, garden: Garden, book=None, imperial: bool = False) -> None:
station.sensor, station.model or "", station.channel or "", station.sensor, station.model or "", station.channel or "",
f"{station.messages:,}", f"{station.messages:,}",
f"{station.gap:.0f} s" if station.gap else "", f"{station.gap:.0f} s" if station.gap else "",
signal_text(station.snr, station.best_snr),
f"{station.share * 100:.0f}%" if station.share else "",
"[red]low[/red]" if station.battery_low else "[green]ok[/green]", "[red]low[/red]" if station.battery_low else "[green]ok[/green]",
datetime.fromtimestamp(station.last).strftime("%H:%M:%S") datetime.fromtimestamp(station.last).strftime("%H:%M:%S")
if station.last else "") if station.last else "")
@ -1188,5 +1222,24 @@ def report(console, garden: Garden, book=None, imperial: bool = False) -> None:
f"came from a model this cannot read[/grey62]") f"came from a model this cannot read[/grey62]")
# What counts as a strong signal, in decibels above the noise. A burst has to
# clear the detector's gate by some margin to be sliced at all, which is
# already seven or eight dB, so these begin above that rather than at zero.
SIGNAL_FAIR = 14.0
SIGNAL_GOOD = 22.0
def signal_text(latest: float, best: float = 0.0) -> str:
"""One sensor's strength, coloured so an aerial can be aimed by it."""
if not latest:
return ""
colour = ("red" if latest < SIGNAL_FAIR else
"yellow" if latest < SIGNAL_GOOD else "green")
out = f"[{colour}]{latest:.0f} dB[/{colour}]"
if best and best - latest >= 6.0:
out += f" [grey62](best {best:.0f})[/grey62]"
return out
def _shown(value: float, unit: str, imperial: bool) -> str: def _shown(value: float, unit: str, imperial: bool) -> str:
return format_measure(Measure("", float(value), unit), imperial) return format_measure(Measure("", float(value), unit), imperial)

View file

@ -62,6 +62,11 @@ class WeatherLog:
"id": reading.sensor, "model": reading.model, "id": reading.sensor, "model": reading.model,
"msg": reading.message, "copies": reading.copies, "msg": reading.message, "copies": reading.copies,
"hex": _hex(reading.bits)} "hex": _hex(reading.bits)}
if reading.snr:
# Decibels above the noise floor of the second it arrived in. A
# ratio, not a power: see Reading.decibels for why that is the
# only honest thing to record here.
body["snr"] = round(reading.snr, 1)
if reading.channel: if reading.channel:
body["ch"] = reading.channel body["ch"] = reading.channel
if name: if name:
@ -170,7 +175,8 @@ def _reading_from(line: str) -> Reading | None:
bits=_bits(str(body.get("hex", ""))), bits=_bits(str(body.get("hex", ""))),
checks=tuple(body.get("checks") or ()), checks=tuple(body.get("checks") or ()),
at=float(body.get("t", 0.0) or 0.0), at=float(body.get("t", 0.0) or 0.0),
copies=int(body.get("copies", 1) or 1)) copies=int(body.get("copies", 1) or 1),
snr=float(body.get("snr", 0.0) or 0.0))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -193,7 +199,8 @@ def write_csv(path, readings, book=None, imperial: bool = False) -> Path:
if measure.name not in names: if measure.name not in names:
names.append(measure.name) names.append(measure.name)
heads = ["time", "unix", "name", "key", "model", "sensor", "channel", heads = ["time", "unix", "name", "key", "model", "sensor", "channel",
"battery"] + [_column(n, readings, imperial) for n in names] "battery", "signal (dB)"] \
+ [_column(n, readings, imperial) for n in names]
with open(path, "w", encoding="utf8", newline="") as fh: with open(path, "w", encoding="utf8", newline="") as fh:
out = csv.writer(fh) out = csv.writer(fh)
out.writerow(heads) out.writerow(heads)
@ -205,7 +212,8 @@ def write_csv(path, readings, book=None, imperial: bool = False) -> Path:
reading.key, reading.model, reading.sensor, reading.key, reading.model, reading.sensor,
reading.channel, reading.channel,
"" if reading.battery_low is None else "" if reading.battery_low is None else
("low" if reading.battery_low else "ok")] ("low" if reading.battery_low else "ok"),
f"{reading.snr:.1f}" if reading.snr else ""]
values = {m.name: m for m in reading.measures} values = {m.name: m for m in reading.measures}
for name in names: for name in names:
measure = values.get(name) measure = values.get(name)

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_05" "User Commands" .TH BANDSAUNTER 1 "2026-09-07" "bandsaunter 2026-09-07_06" "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
@ -2313,13 +2313,40 @@ failing. A transmitter running ten per cent fast is therefore read
correctly and never noticed, which matters: these are unlocked and drift with 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 temperature, and an outdoor sensor in January is not the one that was on
the fence in July. the fence in July.
.SS How well each sensor is heard
Three columns say so, and they answer different halves of the question.
.TP
.B signal
How far the sensor's burst stood above the noise, in decibels, coloured red
below 14, amber below 22 and green above. A ratio of two amplitudes off the
same receiver in the same second and nothing more \[em] not a power at the
aerial, which an RTL-SDR cannot give, having no reference level and, on
automatic gain, no fixed gain either. What a ratio is good for is comparing
one sensor with another, watching one over an evening, and pointing an aerial.
Use a fixed
.B \-\-gain
if the figures are to be compared between one run and the next. It is on the
live display as well, and kept there on a narrow terminal, because watching a
number climb while moving a whip about is the most useful thing it does.
.TP
.B every
The average wait between messages.
.TP
.B heard
What share of what the sensor sent is arriving. These transmit on a fixed
cycle, so the shortest wait ever seen between two of a sensor's messages is
that cycle, and the average wait is the cycle divided by the share getting
through; one over the other is the share, without needing to know the model or
how often it is supposed to speak.
.PP
The two are worth reading together and can disagree usefully. A strong signal
with a low share is interference or a collision rather than a range problem; a
weak signal at a hundred per cent is a sensor at the edge that is getting
through anyway.
.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 and is the
how often, how well \[em] and is the one to look at when something is missing: one to look at when something is missing. The second is the first, last,
these transmit on a fixed cycle, so a gap of thirty seconds from a sensor that lowest and highest of everything each sensor reported.
sends every sixteen means half of them are being missed, and that is an aerial
problem rather than a weather one. The second is the first, last, lowest and
highest of everything each sensor reported.
.PP .PP
There is no average, deliberately. These arrive every sixteen seconds when the There is no average, deliberately. These arrive every sixteen seconds when the
sensor is in range and not at all when it is not, and rain and cold both sensor is in range and not at all when it is not, and rain and cold both

View file

@ -1425,13 +1425,40 @@ failing. A transmitter running ten per cent fast is therefore read
correctly and never noticed, which matters: these are unlocked and drift with 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 temperature, and an outdoor sensor in January is not the one that was on
the fence in July. the fence in July.
.SS How well each sensor is heard
Three columns say so, and they answer different halves of the question.
.TP
.B signal
How far the sensor's burst stood above the noise, in decibels, coloured red
below 14, amber below 22 and green above. A ratio of two amplitudes off the
same receiver in the same second and nothing more \[em] not a power at the
aerial, which an RTL-SDR cannot give, having no reference level and, on
automatic gain, no fixed gain either. What a ratio is good for is comparing
one sensor with another, watching one over an evening, and pointing an aerial.
Use a fixed
.B \-\-gain
if the figures are to be compared between one run and the next. It is on the
live display as well, and kept there on a narrow terminal, because watching a
number climb while moving a whip about is the most useful thing it does.
.TP
.B every
The average wait between messages.
.TP
.B heard
What share of what the sensor sent is arriving. These transmit on a fixed
cycle, so the shortest wait ever seen between two of a sensor's messages is
that cycle, and the average wait is the cycle divided by the share getting
through; one over the other is the share, without needing to know the model or
how often it is supposed to speak.
.PP
The two are worth reading together and can disagree usefully. A strong signal
with a low share is interference or a collision rather than a range problem; a
weak signal at a hundred per cent is a sensor at the edge that is getting
through anyway.
.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 and is the
how often, how well \[em] and is the one to look at when something is missing: one to look at when something is missing. The second is the first, last,
these transmit on a fixed cycle, so a gap of thirty seconds from a sensor that lowest and highest of everything each sensor reported.
sends every sixteen means half of them are being missed, and that is an aerial
problem rather than a weather one. The second is the first, last, lowest and
highest of everything each sensor reported.
.PP .PP
There is no average, deliberately. These arrive every sixteen seconds when the There is no average, deliberately. These arrive every sixteen seconds when the
sensor is in range and not at all when it is not, and rain and cold both sensor is in range and not at all when it is not, and rain and cold both

View file

@ -1118,3 +1118,60 @@ def test_the_hex_of_what_was_read_is_reported_so_it_can_be_worked_out_by_hand():
miss = a.near_misses("1111" + frame)[0] miss = a.near_misses("1111" + frame)[0]
assert len(miss.hex.split()) == 7 assert len(miss.hex.split()) == 7
assert all(len(byte) == 2 for byte in miss.hex.split()) assert all(len(byte) == 2 for byte in miss.hex.split())
# ---------------------------------------------------------------------------
# How strongly a sensor was heard
# ---------------------------------------------------------------------------
def test_the_strength_of_a_reading_follows_the_strength_of_the_signal():
"""Four times the amplitude is twelve decibels, and has to come out so.
This is a ratio of two amplitudes off the same receiver in the same
second and nothing more: not a power at the aerial, which a dongle with
no reference level and an automatic gain cannot give. But a ratio that
tracks the signal correctly is enough to compare two sensors, watch one
over an evening, and point an aerial, which is what it is for.
"""
frame = a.tower_frame(0x1A2B, 21.5, 48, "A")
seen = {}
for amplitude in (2.0, 0.5, 0.125):
iq = a.modulate(frame, 250_000.0, amplitude=amplitude, noise=0.02)
got = a.readings_from(iq, 250_000.0)
assert got, f"not heard at {amplitude}"
seen[amplitude] = got[0].snr
assert seen[2.0] - seen[0.5] == pytest.approx(12.0, abs=1.5)
assert seen[0.5] - seen[0.125] == pytest.approx(12.0, abs=1.5)
def test_a_nearer_sensor_reads_stronger_than_a_further_one():
loud = keyed(a.tower_frame(0x1A2B, 21.5, 48, "A"), amplitude=1.0)
faint = keyed(a.tower_frame(0x0C41, 3.2, 91, "B"), amplitude=0.1)
got = {r.sensor: r.snr
for r in a.readings_from(block_of((0.05, loud), (0.5, faint)),
RATE, offset=OFFSET)}
assert set(got) == {"1A2B", "0C41"}
assert got["1A2B"] - got["0C41"] == pytest.approx(20.0, abs=4.0)
def test_the_strongest_of_the_three_copies_is_the_one_reported():
"""They go out milliseconds apart and arrive at whatever fading does to
each, so the best of them is the fairer answer to how well it is heard."""
frame = a.tower_frame(0x1A2B, 21.5, 48, "A")
quiet = a.modulate(frame, 250_000.0, amplitude=0.12, noise=0.0,
repeats=1, lead_us=0.0)
loud = a.modulate(frame, 250_000.0, amplitude=1.0, noise=0.0,
repeats=1, lead_us=0.0)
# On a noise floor, because a ratio needs something to be a ratio to:
# a block of literal silence has no strength to report and says so.
block = block_of((0.04, quiet), (0.5, loud), rate=250_000.0, offset=0.0)
got = a.readings_from(block, 250_000.0)
assert len(got) == 1 and got[0].copies == 2
assert got[0].snr > 25.0 # the loud copy, not the quiet one
def test_a_burst_with_no_measured_level_reports_no_strength():
"""Rather than minus infinity, or a number made up to fill the column."""
assert a.Burst().decibels == 0.0
assert a.Burst(level=float("inf")).decibels == 0.0
assert a.Burst(level=10.0).decibels == pytest.approx(20.0)

View file

@ -1541,3 +1541,119 @@ def test_the_defaults_are_the_ones_the_established_tools_use():
assert options.rate == 250_000.0 assert options.rate == 250_000.0
assert options.offset == 0.0 assert options.offset == 0.0
assert options.frequency == a.ACURITE_HZ assert options.frequency == a.ACURITE_HZ
# ---------------------------------------------------------------------------
# How well each sensor is being heard
# ---------------------------------------------------------------------------
def loud(strength, sensor=0x1A2B, at=1_000.0):
got = reading(sensor=sensor, at=at)
got.snr = strength
return got
def test_a_station_keeps_the_latest_the_best_and_the_worst_strength():
garden = wx.Garden()
for i, strength in enumerate((30.0, 18.0, 24.0)):
garden.add(loud(strength, at=1_000.0 + i * 16))
station = garden.stations["tower/1A2B"]
assert station.snr == 24.0 # the latest, which is what is shown
assert station.best_snr == 30.0
assert station.worst_snr == 18.0
def test_a_reading_with_no_strength_does_not_wipe_the_one_before_it():
"""Logs written before this existed read back with nothing in them."""
garden = wx.Garden()
garden.add(loud(30.0, at=1_000.0))
garden.add(reading(at=1_016.0)) # snr 0, meaning unknown
assert garden.stations["tower/1A2B"].snr == 30.0
def test_what_share_of_a_sensors_messages_is_arriving_is_worked_out():
"""The shortest wait between two of its messages is its cycle; the
average wait is that cycle divided by the share that gets through."""
garden = wx.Garden()
# Sends every 16 s; every other one arrives.
for i, at in enumerate((0.0, 16.0, 48.0, 64.0, 96.0, 112.0)):
garden.add(loud(30.0, at=1_000.0 + at))
station = garden.stations["tower/1A2B"]
assert station.closest == pytest.approx(16.0)
assert station.share == pytest.approx(16.0 / station.gap, abs=0.01)
assert 0.6 < station.share < 0.8
def test_a_sensor_heard_every_time_is_reported_as_heard_every_time():
garden = wx.Garden()
for i in range(8):
garden.add(loud(30.0, at=1_000.0 + i * 16.0))
assert garden.stations["tower/1A2B"].share == pytest.approx(1.0)
def test_the_share_says_nothing_until_there_is_something_to_say():
garden = wx.Garden()
for i in range(3):
garden.add(loud(30.0, at=1_000.0 + i * 16.0))
assert garden.stations["tower/1A2B"].share == 0.0
@pytest.mark.parametrize("strength,colour", [
(8.0, "red"), (18.0, "yellow"), (34.0, "green"),
])
def test_the_strength_is_coloured_so_an_aerial_can_be_aimed_by_it(strength,
colour):
assert colour in wx.signal_text(strength)
assert f"{strength:.0f} dB" in wx.signal_text(strength)
def test_no_strength_is_shown_as_nothing_rather_than_as_zero():
assert wx.signal_text(0.0) == ""
def test_a_signal_well_below_its_best_says_what_its_best_was():
"""Which is the difference between a sensor that has moved and one that
was always like that."""
assert "best 34" in wx.signal_text(20.0, 34.0)
assert "best" not in wx.signal_text(32.0, 34.0)
def test_the_strength_survives_the_log_and_reaches_the_spreadsheet(tmp_path,
book):
log = wl.WeatherLog(tmp_path / "weather_x.jsonl")
log.append(loud(27.4, at=1_000.0))
log.close()
back = wl.read_logs([log.path])
assert back[0].snr == pytest.approx(27.4)
where = wl.write_csv(tmp_path / "w.csv", back, book)
head, row = where.read_text().splitlines()[:2]
assert "signal (dB)" in head
assert row.split(",")[head.split(",").index("signal (dB)")] == "27.4"
def test_an_older_log_without_strengths_still_reads(tmp_path, book):
log = wl.WeatherLog(tmp_path / "weather_x.jsonl")
log.append(reading(at=1_000.0)) # nothing to record
log.close()
assert "snr" not in log.path.read_text().splitlines()[1]
assert wl.read_logs([log.path])[0].snr == 0.0
def test_the_report_says_how_strong_and_how_complete(book):
garden = wx.Garden()
for i in range(8):
garden.add(loud(31.0, at=1_000.0 + i * 16.0))
out = rendered(garden, book)
assert "31 dB" in out and "100%" in out
def test_the_display_keeps_the_strength_on_a_narrow_terminal(book):
"""It is what somebody moving an aerial about is watching, and they are
not doing it on a wide window."""
garden = wx.Garden()
now = time.time()
garden.add(loud(29.0, at=now))
for width in (68, 84, 120):
out = shown(garden, book, width=width, now=now)
assert "29 dB" in out, f"lost at {width} columns"
assert max(len(line) for line in out.splitlines()) <= width