From 03d3600ecc769aed5886f05f93912bdf4bc82bde Mon Sep 17 00:00:00 2001 From: The Dust Council Date: Mon, 7 Sep 2026 23:50:37 -0700 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg --- README.md | 41 ++++++++++++-- bandsaunter/__init__.py | 2 +- bandsaunter/acurite.py | 23 ++++++++ bandsaunter/ui.py | 13 ++++- bandsaunter/weather.py | 53 +++++++++++++++++ bandsaunter/weatherlog.py | 14 ++++- packaging/bandsaunter.1 | 41 +++++++++++--- packaging/make-man.py | 39 +++++++++++-- tests/test_acurite.py | 57 +++++++++++++++++++ tests/test_weather.py | 116 ++++++++++++++++++++++++++++++++++++++ 10 files changed, 375 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 3ba47c1..acf0bcd 100644 --- a/README.md +++ b/README.md @@ -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 -often, how well — and is the one to look at when something is missing; `every` -is the honest measure of an aerial, because these transmit on a fixed cycle, -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 -when nothing is. +often, how well — and is the one to look at when something is missing. The +second is about the weather, and is the one to look at when nothing is. + +### How well each sensor is heard + +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 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 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. +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 and did not group — usually a continuous transmitter rather than a keyed one, diff --git a/bandsaunter/__init__.py b/bandsaunter/__init__.py index d54cb2e..bd30c7b 100755 --- a/bandsaunter/__init__.py +++ b/bandsaunter/__init__.py @@ -9,7 +9,7 @@ and transcribing speech. # 2026-08-21_02 is the second build made on the 21st. The revision is padded # to two digits so versions sort as text. VERSION_DATE = "2026-09-07" -VERSION_REVISION = 5 +VERSION_REVISION = 6 __version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}" diff --git a/bandsaunter/acurite.py b/bandsaunter/acurite.py index ec0c054..79402ce 100644 --- a/bandsaunter/acurite.py +++ b/bandsaunter/acurite.py @@ -232,6 +232,7 @@ class Reading: at: float = 0.0 # when it arrived, as a clock time copies: int = 1 # how many times it arrived, identically offset: int = 0 # where in the burst its first bit was + snr: float = 0.0 # decibels above the noise; 0 = not known @property def key(self) -> str: @@ -740,6 +741,22 @@ class Burst: def pulses(self) -> int: 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 def length_us(self) -> float: 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) for reading in here.values(): reading.at = when + burst.at + reading.snr = burst.decibels found.append(reading) return confirmed(found) @@ -1333,6 +1351,11 @@ def confirmed(found: list[Reading]) -> list[Reading]: continue first = min(group, key=lambda r: r.at) 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) return sorted(out, key=lambda r: (r.at, r.family, r.sensor)) diff --git a/bandsaunter/ui.py b/bandsaunter/ui.py index 1a82719..776d657 100755 --- a/bandsaunter/ui.py +++ b/bandsaunter/ui.py @@ -886,8 +886,13 @@ class WeatherDisplay: if width >= 92: t.add_column("model", width=16, style="grey62", no_wrap=True) 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") - if width >= 76: + if width >= 84: t.add_column("msgs", width=5, justify="right", style="grey62") t.add_column("ago", width=5, justify="right", style="grey62") for i, station in enumerate(here, 1): @@ -899,9 +904,13 @@ class WeatherDisplay: if width >= 92: row.append(station.model or "") 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 else Text("ok", style="green")) - if width >= 76: + if width >= 84: row.append(f"{station.messages:,}") row.append(_dur(max(0.0, now - station.last))) t.add_row(*row) diff --git a/bandsaunter/weather.py b/bandsaunter/weather.py index 11b5815..07b6912 100644 --- a/bandsaunter/weather.py +++ b/bandsaunter/weather.py @@ -36,6 +36,7 @@ from .settings import Setting, format_value from .weatherlog import logs_in __all__ = ["WeatherOptions", "OPTIONS", "OPTION_GROUPS", "defaults", + "signal_text", "SIGNAL_FAIR", "SIGNAL_GOOD", "in_group", "by_key", "format_option", "describe", "summarise", "load_options", "save_options", "options_path", "logs_in", "Heard", "Station", "Garden", "listen", "open_device", "open_log", @@ -398,6 +399,10 @@ class Station: messages: int = 0 copies: int = 0 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 times: dict = field(default_factory=dict) # name -> when that arrived firsts: dict = field(default_factory=dict) # name -> the first value @@ -416,9 +421,16 @@ class Station: def add(self, reading) -> None: self.model = reading.model or self.model 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.last = max(self.last, reading.at) 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.battery_low = bool(reading.battery_low) if not reading.measures: @@ -451,6 +463,24 @@ class Station: return (self.last - self.first) / (self.messages - 1) \ 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): """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("msgs", 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("last heard", style="grey62", no_wrap=True) 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 "", f"{station.messages:,}", 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]", datetime.fromtimestamp(station.last).strftime("%H:%M:%S") 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]") +# 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: return format_measure(Measure("", float(value), unit), imperial) diff --git a/bandsaunter/weatherlog.py b/bandsaunter/weatherlog.py index 11d8ac6..baf884a 100644 --- a/bandsaunter/weatherlog.py +++ b/bandsaunter/weatherlog.py @@ -62,6 +62,11 @@ class WeatherLog: "id": reading.sensor, "model": reading.model, "msg": reading.message, "copies": reading.copies, "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: body["ch"] = reading.channel if name: @@ -170,7 +175,8 @@ def _reading_from(line: str) -> Reading | None: bits=_bits(str(body.get("hex", ""))), checks=tuple(body.get("checks") or ()), 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: names.append(measure.name) 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: out = csv.writer(fh) 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.channel, "" 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} for name in names: measure = values.get(name) diff --git a/packaging/bandsaunter.1 b/packaging/bandsaunter.1 index eaa599b..d59f6bf 100644 --- a/packaging/bandsaunter.1 +++ b/packaging/bandsaunter.1 @@ -1,5 +1,5 @@ .\" 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 bandsaunter \- scan, record and identify radio signals with an RTL-SDR .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 the temperature, and an outdoor sensor in January is not the one that was on 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 -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: -these transmit on a fixed cycle, so a gap of thirty seconds from a sensor that -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. +When the listening stops, two tables. The first is about reception and is the +one to look at when something is missing. The second is the first, last, +lowest and highest of everything each sensor reported. .PP 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 diff --git a/packaging/make-man.py b/packaging/make-man.py index 895d87c..0204001 100755 --- a/packaging/make-man.py +++ b/packaging/make-man.py @@ -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 the temperature, and an outdoor sensor in January is not the one that was on 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 -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: -these transmit on a fixed cycle, so a gap of thirty seconds from a sensor that -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. +When the listening stops, two tables. The first is about reception and is the +one to look at when something is missing. The second is the first, last, +lowest and highest of everything each sensor reported. .PP 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 diff --git a/tests/test_acurite.py b/tests/test_acurite.py index 07b49a3..f42ea1d 100644 --- a/tests/test_acurite.py +++ b/tests/test_acurite.py @@ -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] assert len(miss.hex.split()) == 7 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) diff --git a/tests/test_weather.py b/tests/test_weather.py index 4d7cbf8..9839c9a 100644 --- a/tests/test_weather.py +++ b/tests/test_weather.py @@ -1541,3 +1541,119 @@ def test_the_defaults_are_the_ones_the_established_tools_use(): assert options.rate == 250_000.0 assert options.offset == 0.0 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