diff --git a/meshterm/demo/device.py b/meshterm/demo/device.py index 205e32e..9e60921 100644 --- a/meshterm/demo/device.py +++ b/meshterm/demo/device.py @@ -42,6 +42,14 @@ _PART_S = 3600.0 #: The lines of the script still arrive. PLAY_BACKGROUND = True +#: The TX power of each repeater of the world, as a read of its settings shows it. +REMOTE_TX = 22 + +#: The TX power at which a repeater of the world is best heard. It is lower than +#: :data:`REMOTE_TX`. Thus TX optimize finds a lower power for a repeater, with a better +#: signal at its target (refer to :meth:`DemoDevice._tuned`). +REMOTE_TX_BEST = 19 + class DemoDevice(MockDevice): """The simulator, in the demo world (refer to the module docstring).""" @@ -51,10 +59,12 @@ class DemoDevice(MockDevice): super().__init__( seed=hash_seed("device"), optimal_tx=TX_POWER, + optimal_remote_tx=REMOTE_TX_BEST, admin_password=world.ADMIN_PASSWORD, ) now = utcnow() self._tx_power = TX_POWER + self._default_remote_tx = REMOTE_TX self._contacts = [_contact(node) for node in world.NODES if node.added] room = world.node(world.JOINED_ROOM) # The room knows our node as a member. Its posts until now are in the history @@ -229,15 +239,16 @@ class DemoDevice(MockDevice): if relays is None: return TraceResult(target=target, success=False, tx_power=self._tx_power) walked = world.walk(relays, self._rng) - if not walked.success: + snrs = self._tuned(relays, walked.snrs) if walked.success else None + if snrs is None: return TraceResult( target=target, success=False, tx_power=self._tx_power, path_hash_bytes=width ) hops = [ Hop(index, relay.public_key[: 2 * width], snr) - for index, (relay, snr) in enumerate(zip(relays, walked.snrs, strict=False)) + for index, (relay, snr) in enumerate(zip(relays, snrs, strict=False)) ] - hops.append(Hop(len(relays), None, walked.snrs[-1])) + hops.append(Hop(len(relays), None, snrs[-1])) return TraceResult( target=target, success=True, @@ -247,6 +258,37 @@ class DemoDevice(MockDevice): path_hash_bytes=width, ) + def _tuned(self, relays: list[world.Node], snrs: list[float]) -> list[float] | None: + """The readings of a trace, with the TX power that TX optimize set on its relays. + + The world gives each link its SNR at :data:`REMOTE_TX_BEST`. A relay that + transmits at another power is heard less well on the link that it transmits into. + The loss follows the curve of the simulator, which has its peak at the best power + (:meth:`~meshterm.core.connection.MockDevice._expected_remote_snr`). Thus the sweep + of TX optimize finds that peak, and a power far from it loses traces: a reading + under :data:`~meshterm.demo.world.FLOOR_DB` always does. A relay that nobody tuned + keeps the readings of the world. + + Args: + relays: The forced path, as nodes. + snrs: The readings of the world: one for each link, the last one back to us. + + Returns: + The readings, or ``None`` if a weaker link lost the trace. + """ + best = self._expected_remote_snr(self.optimal_remote_tx) + readings = list(snrs) + for index, relay in enumerate(relays): + tx = self._remote_tx_for(relay.public_key) + if tx is None: + continue + snr = readings[index + 1] + self._expected_remote_snr(tx) - best + # Below the floor, the radio cannot decode the packet at all. + if snr < world.FLOOR_DB or self._rng.random() < world.loss_chance(snr): + return None + readings[index + 1] = round(snr * 4) / 4 + return readings + # -- the live traffic --------------------------------------------------------------- async def subscribe_events( # noqa: D102 - inherited docstring diff --git a/tests/test_demo.py b/tests/test_demo.py index 81195f1..4357567 100644 --- a/tests/test_demo.py +++ b/tests/test_demo.py @@ -74,6 +74,26 @@ def test_the_demo_device_traces_a_path() -> None: assert [h.node for h in result.hops] == ["d8", "2a", "d8", None] +def test_a_tuned_repeater_is_heard_best_at_its_best_power() -> None: + """TX optimize finds a peak: far from its best power, a relay loses the target.""" + from meshterm.demo.device import REMOTE_TX_BEST + + async def heard(tx: int) -> list[float]: + device = DemoDevice() + await device.connect() + rosemont = next(c for c in await device.get_contacts() if c.name == "Rosemont") + await device.admin_login(rosemont, world.ADMIN_PASSWORD) + await device.set_remote_tx_power(rosemont, tx) + spec = ",".join(world.node(n).hash for n in ("Rosemont", "Mont-Royal", "Rosemont")) + results = [await device.run_trace("(path)", path=spec) for _ in range(5)] + return [result.hops[1].snr for result in results if result.success] + + best = asyncio.run(heard(REMOTE_TX_BEST)) + far = asyncio.run(heard(REMOTE_TX_BEST + 8)) + assert best + assert not far or max(far) < min(best) + + def test_prepare_writes_a_home(tmp_path: Path) -> None: """The demo home gets a database and the stores of the joined room and the alerts.""" home = tmp_path / "demo"