From bde8582de34dd9312e88e14dc4bc47513605fc78 Mon Sep 17 00:00:00 2001 From: fondef Date: Tue, 15 Sep 2026 15:42:48 -0500 Subject: [PATCH 01/10] add the gamepad reader and the node's settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reader owns the pad on a thread of its own, and nothing outside that file may touch pygame: everything else sees a plain immutable Sample. That is what lets the rest of the node be tested with no hardware, and what keeps SDL's quirks out of the arm logic. Sample.present is the field that matters. A pad that has been unplugged reads as absent rather than as centred, so "the operator let go" and "the pad fell off the desk" stay distinguishable — they call for the same stop, but only one of them is worth logging. Only the display and joystick subsystems are initialised. pygame.init() also opens audio, which on a headless robot fills stderr with ALSA errors for a device this node never uses; the display is needed despite there being no window, because SDL refreshes joystick state during the event pump. Settings are parsed once at startup so a deadzone that would silently make the pad inert stops the node before it joins the dataflow. --- README.md | 1 + dora_teleop/__init__.py | 1 + dora_teleop/config.py | 99 +++++++++ dora_teleop/gamepad.py | 135 ++++++++++++ dora_teleop/log.py | 13 ++ pyproject.toml | 48 +++++ tests/__init__.py | 0 tests/conftest.py | 96 +++++++++ tests/test_config.py | 67 ++++++ uv.lock | 450 ++++++++++++++++++++++++++++++++++++++++ 10 files changed, 910 insertions(+) create mode 100644 README.md create mode 100644 dora_teleop/__init__.py create mode 100644 dora_teleop/config.py create mode 100644 dora_teleop/gamepad.py create mode 100644 dora_teleop/log.py create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_config.py create mode 100644 uv.lock diff --git a/README.md b/README.md new file mode 100644 index 0000000..9116a3a --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# dora-teleop diff --git a/dora_teleop/__init__.py b/dora_teleop/__init__.py new file mode 100644 index 0000000..e20200f --- /dev/null +++ b/dora_teleop/__init__.py @@ -0,0 +1 @@ +"""Human control of a robot arm, from a gamepad or a browser.""" diff --git a/dora_teleop/config.py b/dora_teleop/config.py new file mode 100644 index 0000000..3c12dae --- /dev/null +++ b/dora_teleop/config.py @@ -0,0 +1,99 @@ +"""Everything the node reads from the environment, parsed once at startup. + +Parsing happens here rather than where each value is used, so an unusable +setting stops the node before it joins the dataflow instead of surfacing the +first time somebody picks up the gamepad. +""" + +import os +from dataclasses import dataclass, field + + +def env_float(name: str, default: float) -> float: + """Read a number, naming the variable if it cannot be read as one.""" + value = os.environ.get(name, "").strip() + if not value: + return default + try: + return float(value) + except ValueError: + raise ValueError(f"{name} must be a number, not {value!r}") from None + + +def env_int(name: str, default: int) -> int: + """Read a whole number, naming the variable if it cannot be read as one.""" + value = os.environ.get(name, "").strip() + if not value: + return default + try: + return int(value) + except ValueError: + raise ValueError(f"{name} must be a whole number, not {value!r}") from None + + +@dataclass +class DeadmanConfig: + """How long a source may go quiet before the arm is told to stop. + + Three layers, outermost last. This node re-sends the current velocity every + tick; `jog_ttl_s` is how long it will keep doing so without hearing from the + source; and behind both sits the arm node's own `VELOCITY_COMMAND_TTL_S`, + which the controller enforces whether or not this process is alive. + + Keep them in that order — ours shorter than the controller's — or the + backstop fires before the thing it is backing up. + """ + + jog_ttl_s: float = 0.4 + # How long a gamepad may sit untouched before it gives the arm back, so the + # browser can take over without anyone having to press anything. + gamepad_idle_release_s: float = 2.0 + # How often to ask the arm for the things telemetry does not carry: whether + # the tool is engaged, whether freedrive is on, whether it is ready. + state_poll_s: float = 0.5 + + @classmethod + def from_env(cls) -> "DeadmanConfig": + """Build the timings from the environment.""" + return cls( + jog_ttl_s=env_float("JOG_TTL_S", 0.4), + gamepad_idle_release_s=env_float("GAMEPAD_IDLE_RELEASE_S", 2.0), + state_poll_s=env_float("STATE_POLL_S", 0.5), + ) + + +@dataclass +class Config: + """The node's settings.""" + + # Full-deflection speeds. A stick is analog, so these are the maximum and + # anything less moves proportionally slower — which is the whole reason a + # gamepad beats the console's dial pad. + speed_mm_s: float + speed_deg_s: float + # Stick travel to ignore. A worn stick that does not centre would otherwise + # drive the arm across the table on its own. + deadzone: float + # How much of the curve is cubic: 0 is linear, 1 is fully cubic. Fine + # positioning happens near centre, and expo is what buys resolution there. + expo: float + joystick_index: int + deadman: DeadmanConfig = field(default_factory=DeadmanConfig) + + @classmethod + def from_env(cls) -> "Config": + """Build the configuration from the environment, refusing anything unusable.""" + deadzone = env_float("DEADZONE", 0.12) + if not 0.0 <= deadzone < 1.0: + raise ValueError(f"DEADZONE must be at least 0 and below 1, not {deadzone}") + expo = env_float("EXPO", 0.4) + if not 0.0 <= expo <= 1.0: + raise ValueError(f"EXPO must be between 0 and 1, not {expo}") + return cls( + speed_mm_s=env_float("SPEED_MM_S", 60.0), + speed_deg_s=env_float("SPEED_DEG_S", 30.0), + deadzone=deadzone, + expo=expo, + joystick_index=env_int("JOYSTICK_INDEX", 0), + deadman=DeadmanConfig.from_env(), + ) diff --git a/dora_teleop/gamepad.py b/dora_teleop/gamepad.py new file mode 100644 index 0000000..c569234 --- /dev/null +++ b/dora_teleop/gamepad.py @@ -0,0 +1,135 @@ +"""The gamepad, and the thread that reads it. + +**Nothing outside this file may touch pygame.** The rest of the node sees only +:class:`Sample`, which is a plain immutable reading — that is what lets every +other module be tested without a pad plugged in, and what keeps SDL's quirks +from leaking into the arm logic. + +The reader is a thread because SDL wants to be pumped at its own cadence and +because a pad being unplugged should be noticed within milliseconds rather than +on whenever the dataflow next ticks. It never publishes anything; it fills a +slot, and the event loop drains it. +""" + +import os +import threading +import time +from dataclasses import dataclass, field + +from dora_teleop.log import log + + +@dataclass(frozen=True) +class Sample: + """One reading of the pad. + + ``present`` is the one that matters for safety: a pad that has been + unplugged reads as absent rather than as centred, so the difference between + "the operator let go" and "the operator's pad fell off the desk" is not lost. + """ + + axes: list[float] = field(default_factory=list) + buttons: list[bool] = field(default_factory=list) + present: bool = False + + @property + def active(self) -> bool: + """Whether anything is being touched at all. + + Deliberately raw — no deadzone. This decides whether the pad is asking + for the arm, and :mod:`dora_teleop.mapping` decides how far to move it. + A separate, tighter threshold here would mean a stick could claim the + arm and then not move it. + """ + return self.present and (any(abs(a) > 0.3 for a in self.axes) or any(self.buttons)) + + +ABSENT = Sample() + + +class Gamepad: + """Reads one pad on a thread and keeps the latest reading available.""" + + def __init__(self, index: int, poll_interval_s: float = 0.01): + self.index = index + self.poll_interval_s = poll_interval_s + self._lock = threading.Lock() + self._sample = ABSENT + self._stopping = threading.Event() + self._thread = threading.Thread(target=self._run, name="teleop-gamepad", daemon=True) + + def start(self) -> None: + """Start reading. Never blocks, and never fails for want of a pad.""" + self._thread.start() + + def read(self) -> Sample: + """Return the most recent reading.""" + with self._lock: + return self._sample + + def shutdown(self) -> None: + """Stop reading and let the thread finish.""" + self._stopping.set() + self._thread.join(timeout=1.0) + + def _run(self) -> None: + """Open the pad, read it, and keep trying if it is not there. + + A missing pad is an ordinary state, not an error: the browser is a whole + control path of its own, and the node has no business refusing to start + because nobody has plugged a controller in yet. + """ + # Two subsystems, not all of them. `pygame.init()` also opens audio, + # which on a headless robot means a screenful of ALSA errors in the + # dataflow's stderr for a device this node never uses. The display is + # needed despite there being no window: SDL refreshes joystick state + # during the event pump, and the pump needs the video subsystem. + os.environ.setdefault("SDL_VIDEODRIVER", "dummy") + os.environ.setdefault("SDL_AUDIODRIVER", "dummy") + import pygame + + pygame.display.init() + pygame.joystick.init() + joystick = None + + while not self._stopping.is_set(): + pygame.event.pump() + if joystick is None: + joystick = self._open(pygame) + if joystick is None: + self._store(ABSENT) + self._stopping.wait(1.0) + continue + try: + sample = Sample( + axes=[joystick.get_axis(i) for i in range(joystick.get_numaxes())], + buttons=[bool(joystick.get_button(i)) for i in range(joystick.get_numbuttons())], + present=True, + ) + except Exception as error: # noqa: BLE001 — an unplugged pad raises from inside SDL + log(f"the gamepad went away ({error})") + joystick = None + self._store(ABSENT) + continue + self._store(sample) + self._stopping.wait(self.poll_interval_s) + + def _open(self, pygame): + """Open the configured pad, or return None if it is not there.""" + pygame.joystick.quit() + pygame.joystick.init() + if pygame.joystick.get_count() <= self.index: + return None + joystick = pygame.joystick.Joystick(self.index) + joystick.init() + log(f"gamepad {self.index}: {joystick.get_name()}") + return joystick + + def _store(self, sample: Sample) -> None: + with self._lock: + self._sample = sample + + +def now() -> float: + """Return the clock the deadman runs on: monotonic, so it cannot step backwards.""" + return time.monotonic() diff --git a/dora_teleop/log.py b/dora_teleop/log.py new file mode 100644 index 0000000..752adbd --- /dev/null +++ b/dora_teleop/log.py @@ -0,0 +1,13 @@ +"""Timestamped stderr logging. + +stderr rather than stdout: dora reads a dynamic node's stdout, and a print that +lands there is noise in the wrong stream. +""" + +import sys +import time + + +def log(message: str) -> None: + """Print a timestamped line for the node.""" + print(f"[teleop {time.strftime('%H:%M:%S')}] {message}", file=sys.stderr, flush=True) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c26a890 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,48 @@ +[project] +name = "dora-teleop" +version = "0.1.0" +description = "Dora node giving a human the arm, from a gamepad or a browser" +readme = "README.md" +license = { text = "MIT" } +authors = [{ name = "Liria" }] + +# dora-rs 1.0 requires 3.11; the node uses PEP 604 unions and zip(strict=). +requires-python = ">=3.11" + +dependencies = [ + "dora-rs >= 1.0.1", + "pyarrow >= 17.0", + # pygame-ce rather than evdev: Littlehand targets Linux and macOS, evdev is + # Linux-only, and pygame-ce ships prebuilt wheels for linux-x86_64, + # linux-aarch64 and macos-arm64 — so no dependency here compiles from source. + "pygame-ce >= 2.4", +] + +[dependency-groups] +dev = ["pytest >= 8.1", "ruff >= 0.9"] + +[project.scripts] +dora-teleop = "dora_teleop.main:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["dora_teleop"] + +[tool.ruff] +line-length = 110 + +[tool.ruff.lint] +extend-select = ["D", "I", "N", "RET", "UP"] + +[tool.ruff.lint.pydocstyle] +convention = "numpy" + +[tool.ruff.lint.per-file-ignores] +# Test names are the documentation here; a docstring would only repeat them. +"tests/*" = ["D1"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ca847fb --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,96 @@ +"""Shared fakes. Nothing here needs a gamepad, an arm, a browser or a daemon.""" + +import json + +import pytest + +from dora_teleop.config import Config +from dora_teleop.gamepad import Sample + + +class RecordingNode: + """A dora node that records outputs *with* their metadata. + + `dora.testing.MockNode` drops the metadata dict on `send_output`, and the + metadata is where `request_id` rides. So the tests record it themselves. + """ + + def __init__(self): + self.sent = [] + + def send_output(self, output_id, data, metadata=None): + self.sent.append((output_id, data, dict(metadata or {}))) + + def outputs(self, output_id): + """Return every message sent on ``output_id`` as (value, metadata) pairs.""" + return [(data[0].as_py(), metadata) for name, data, metadata in self.sent if name == output_id] + + def jogs(self): + """Return every velocity sent to the arm, in order.""" + return [row["velocity"] for row, _ in self.outputs("jog")] + + def services(self): + """Return the name of every service called, in order.""" + return [row["service"] for row, _ in self.outputs("request")] + + def ui(self): + """Return every object handed to the bridge, parsed.""" + return [json.loads(value) for value, _ in self.outputs("ui_state")] + + def clear(self): + self.sent.clear() + + +class FakeGamepad: + """Stands in for the reader thread: hands back whatever the test set.""" + + def __init__(self, sample=None): + self.sample = sample or Sample() + self.stopped = False + + def read(self): + return self.sample + + def shutdown(self): + self.stopped = True + + +def stick(axes=None, buttons=None, present=True): + """Build one gamepad reading, padded to a full six axes.""" + values = list(axes or []) + values += [0.0] * (6 - len(values)) + # A trigger at rest reads -1, and a test that forgot to say so would look + # like both triggers held down. + for index in (2, 5): + if axes is None or index >= len(axes): + values[index] = -1.0 + return Sample(axes=values, buttons=list(buttons or []), present=present) + + +def input_event(event_id, value, metadata=None): + """Build the event dict dora hands a node for one input.""" + return {"type": "INPUT", "id": event_id, "value": value, "metadata": dict(metadata or {})} + + +@pytest.fixture(autouse=True) +def clean_env(monkeypatch): + """Run every test against a bare environment, whatever the shell set.""" + for name in ( + "SPEED_MM_S", + "SPEED_DEG_S", + "DEADZONE", + "EXPO", + "JOYSTICK_INDEX", + "JOG_TTL_S", + "GAMEPAD_IDLE_RELEASE_S", + "STATE_POLL_S", + ): + monkeypatch.delenv(name, raising=False) + + +@pytest.fixture +def config(): + """Return the default configuration, with expo off so a test can do the arithmetic.""" + built = Config.from_env() + built.expo = 0.0 + return built diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..b418b31 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,67 @@ +"""Settings: parsed once at startup, and refused there rather than mid-motion.""" + +import pytest + +from dora_teleop.config import Config + + +def test_the_defaults_need_no_environment(): + config = Config.from_env() + + assert config.speed_mm_s == 60.0 + assert config.speed_deg_s == 30.0 + assert config.joystick_index == 0 + + +def test_the_deadman_is_shorter_than_the_arms_own_expiry(): + # The arm node's VELOCITY_COMMAND_TTL_S defaults to 0.5 s and is the + # backstop. Ours has to fire first, or the backstop backs up nothing. + assert Config.from_env().deadman.jog_ttl_s < 0.5 + + +def test_a_blank_variable_falls_back_to_the_default(monkeypatch): + monkeypatch.setenv("SPEED_MM_S", " ") + + assert Config.from_env().speed_mm_s == 60.0 + + +def test_a_speed_that_is_not_a_number_names_itself(monkeypatch): + monkeypatch.setenv("SPEED_MM_S", "brisk") + + with pytest.raises(ValueError, match="SPEED_MM_S"): + Config.from_env() + + +def test_a_joystick_index_that_is_not_whole_names_itself(monkeypatch): + monkeypatch.setenv("JOYSTICK_INDEX", "1.5") + + with pytest.raises(ValueError, match="JOYSTICK_INDEX"): + Config.from_env() + + +@pytest.mark.parametrize("value", ["1.0", "-0.1", "2"]) +def test_a_deadzone_outside_the_stick_is_refused_at_startup(monkeypatch, value): + # A deadzone of 1 would silently make the pad inert; catching it here beats + # finding out when somebody picks it up. + monkeypatch.setenv("DEADZONE", value) + + with pytest.raises(ValueError, match="DEADZONE"): + Config.from_env() + + +@pytest.mark.parametrize("value", ["-0.1", "1.1"]) +def test_an_expo_outside_the_curve_is_refused_at_startup(monkeypatch, value): + monkeypatch.setenv("EXPO", value) + + with pytest.raises(ValueError, match="EXPO"): + Config.from_env() + + +def test_the_timings_can_be_tuned_without_touching_the_speeds(monkeypatch): + monkeypatch.setenv("JOG_TTL_S", "0.25") + monkeypatch.setenv("GAMEPAD_IDLE_RELEASE_S", "5") + + config = Config.from_env() + + assert config.deadman.jog_ttl_s == 0.25 + assert config.deadman.gamepad_idle_release_s == 5.0 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..8fdacc5 --- /dev/null +++ b/uv.lock @@ -0,0 +1,450 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version < '3.12'", +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "dora-rs" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pyarrow" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/fc/9dd2e1cc4da1caf46496353da6c12f7b7c90512186d828f1d587dc6deddc/dora_rs-1.0.1.tar.gz", hash = "sha256:8cef7fa133d93e7ca46581a9efef6b45b5768cecef5ee6b9e22632978b8fe8a2", size = 1690155, upload-time = "2026-09-03T12:21:54.217Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/8b/2040480f8393da850f544347a389dab6bf99c84131da9cb5a2273829a76f/dora_rs-1.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:56fb3daf26a978e6f535b0d6b04ac3abd362aff62de6771cd74c2dee06207cb8", size = 24759810, upload-time = "2026-09-03T12:21:42.346Z" }, + { url = "https://files.pythonhosted.org/packages/b9/4c/c563464b74cd31d801fe37c693751005717c80834210c1fa61cb44d7bd34/dora_rs-1.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:61fd33ba5a5cfa01ba4aed4c75bda490b5ad0e5d7180bde7b075d17e0c782486", size = 24061908, upload-time = "2026-09-03T12:21:29.714Z" }, + { url = "https://files.pythonhosted.org/packages/da/20/125f14f222c2eab928b23d310eeed61c93e9805d30575d21f05df285fb84/dora_rs-1.0.1-cp311-abi3-manylinux_2_28_armv7l.whl", hash = "sha256:6b0075de47b8310bf3801f2ce70b13b6925ebe1c8a7b04c9084bcd11688506ea", size = 22804362, upload-time = "2026-09-03T12:21:32.351Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1b/98e0ce52006fbd6d91f33037c5a76ab12edf8c74a01062af39c4dab4c403/dora_rs-1.0.1-cp311-abi3-manylinux_2_28_i686.whl", hash = "sha256:7f1377dda91435ca9d69ecce88a621052c172c2eb07f9c0aa0c1634d6bbfc057", size = 25245130, upload-time = "2026-09-03T12:21:37.702Z" }, + { url = "https://files.pythonhosted.org/packages/b8/80/f931008dec0806d3446de9d762cb2f16883530857e0941d36449b554b84b/dora_rs-1.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a28d634659a23dc8df9fbd40ad093f712e202821060f949f3dba5af8f7f36377", size = 24887127, upload-time = "2026-09-03T12:21:39.886Z" }, + { url = "https://files.pythonhosted.org/packages/cf/43/01a610cb1f696410698ffb9a6e6d5f2dd3c6ae22ad63aed366c00136b3ae/dora_rs-1.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3255d040778b44542f3d03288268a22f85253db45099d60d33776b7ef92ac1c6", size = 29797596, upload-time = "2026-09-03T12:21:44.594Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f5/6668702f8a107c2a701bc8368d14c18d1d36b620c8c7cbc5ac2d83c58cf7/dora_rs-1.0.1-cp311-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:0febdd43c0c0b511c60def3691d83d69a54ed8543207af3bb238989f7a1f5c0b", size = 28151873, upload-time = "2026-09-03T12:21:46.781Z" }, + { url = "https://files.pythonhosted.org/packages/06/51/0141e6fe69a7444115d0e9e56bbed234cd86035d3d5588f61d56e0778a61/dora_rs-1.0.1-cp311-abi3-musllinux_1_2_i686.whl", hash = "sha256:f774a046e6d869b4e40b9c9fea5d1a46175c3d83179b44d1f3310c788562b72e", size = 29301986, upload-time = "2026-09-03T12:21:49.576Z" }, + { url = "https://files.pythonhosted.org/packages/04/b2/5032b628e1e46ef76180860a6f14e32b940d2979971e931630c6e10e3316/dora_rs-1.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d8c8affc29e401558ac83136055750b9de10ac3d3a98855b59866b0c08260dfa", size = 29999168, upload-time = "2026-09-03T12:21:51.803Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3b/bb7c131dd2778909f4ec4d3e0151a7fa342a38ea8fa3ca64d2ef87d93f2f/dora_rs-1.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:fbca33bf87e77fb9532e3bbeff023adb0e9b8e713d6f9106f114e0b0408666f5", size = 22396575, upload-time = "2026-09-03T12:21:55.601Z" }, +] + +[[package]] +name = "dora-teleop" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "dora-rs" }, + { name = "pyarrow" }, + { name = "pygame-ce" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "dora-rs", specifier = ">=1.0.1" }, + { name = "pyarrow", specifier = ">=17.0" }, + { name = "pygame-ce", specifier = ">=2.4" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.1" }, + { name = "ruff", specifier = ">=0.9" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" }, + { url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" }, + { url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" }, + { url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" }, + { url = "https://files.pythonhosted.org/packages/79/e5/8fb89cd46d14e35699d13bf943a5f5f441ecee8667120a1f6105ab89e349/numpy-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:66a78fe4556c60aceda5916f9eacd638b18e9e681016ec302dcb4682d6d4d034", size = 16991061, upload-time = "2026-09-06T16:25:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/2f/06/9dc9e48b5e5e941c8b10350c5ff2d721da42a20517d911d15544246775ff/numpy-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92f30e89b8ee0ecf363033576c422b2f58fed6a80bed0aa48dff6d14c654663e", size = 12003676, upload-time = "2026-09-06T16:25:03.475Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2a/98282aa5b8f58b1157d440bb6282eed47e3632a5de53a714fbab17e659fe/numpy-2.5.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f9a2353b37a1a9e78fd82b27ad7e2a32a2d036604d18f02b05e3136c62ca3b09", size = 5439695, upload-time = "2026-09-06T16:25:05.978Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f9/b6533d777be9d6ffd29dc1be0867e563e6e8cc9a220ff1b716adc317f060/numpy-2.5.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:ccbc4665079665c3cf3bab4db9f6b095370cd6437d66be549b6c2a1fd19e1958", size = 6779395, upload-time = "2026-09-06T16:25:08.599Z" }, + { url = "https://files.pythonhosted.org/packages/73/85/735720d04ec197c5dcfacdfc9922667c7f1f5f496a279b7ba4d7c74c4cc7/numpy-2.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c76d5dde9f445058f83d0c02af00557a4db91de9a9a57c0df87d1535001d654b", size = 15681750, upload-time = "2026-09-06T16:25:11.173Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1b/3b16a9bc514a440a7a0883684111dcb1ef1aee960af2ca95da8fc775f124/numpy-2.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321", size = 16708577, upload-time = "2026-09-06T16:25:14.171Z" }, + { url = "https://files.pythonhosted.org/packages/69/c4/386f397831b07328b639c96c5b62719346cf4baf07c68d927239752b1534/numpy-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd4cb9ad3c7889b9b3fe0a9a9fb5d2ed26f9879bff2608d9f01aed147a20d231", size = 17042047, upload-time = "2026-09-06T16:25:17.582Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3e/a700ecbf36e85ae8328fd3b0e12eeddc22ed6358a64cb2bd913e0d195d65/numpy-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1302b90c0e52281681b2975adfe8a860cb7b12216a27b4b0b4207c44bf7bccf0", size = 18465724, upload-time = "2026-09-06T16:25:20.949Z" }, + { url = "https://files.pythonhosted.org/packages/41/ee/38e785e88a4045f6ad1d1f2808dcdfafdca48c760260c0587bf171e29fc9/numpy-2.5.3-cp313-cp313-win32.whl", hash = "sha256:1c80eabb4035ecf4ca9cd49cde8a9fdd69a729e63e6474887d1523ade7aa277f", size = 6129003, upload-time = "2026-09-06T16:25:23.664Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ec/100f2b1794ede74a9b3d7ec6b9736927f56713414c1dfe19ab6c383494bf/numpy-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:71cad2b2a7451ab79d8f5e71b453485b6775963d5cf794179144a7463fe6e8ec", size = 12560965, upload-time = "2026-09-06T16:25:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/80/b1/7dc825ca94c12acebbce4c37caa5e198695eb31424bc579679f32b1bb49d/numpy-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:8e4dd766076855b5ff7ea52fa5f07ce26286726e0f8bff446b7739d02e6ea204", size = 10482343, upload-time = "2026-09-06T16:25:29.772Z" }, + { url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" }, + { url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" }, + { url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" }, + { url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" }, + { url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" }, + { url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" }, + { url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" }, + { url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" }, + { url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" }, + { url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" }, + { url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/45/56/78194492883ff5eec90423fe56a3a44b154da047d88a6307f629713c584f/numpy-2.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058", size = 16996531, upload-time = "2026-09-06T16:26:37.287Z" }, + { url = "https://files.pythonhosted.org/packages/11/39/dd55c0af90bbab564b09ae3b0aa60ec5c02b900fa4f1ba23440525c8b32d/numpy-2.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be", size = 12012569, upload-time = "2026-09-06T16:26:40.707Z" }, + { url = "https://files.pythonhosted.org/packages/b6/51/04f67d32e4862b281b1cb84ceeaed3421189a84fb6fb51a391cd6d5009f7/numpy-2.5.3-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5", size = 5448498, upload-time = "2026-09-06T16:26:43.435Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c9/25b4dc0dd1344ec26c7319e84fd4e9809d2b5628f4e12decd618036e5178/numpy-2.5.3-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90", size = 6783026, upload-time = "2026-09-06T16:26:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c7/29285be1e5232a6e7ee3268a33c85843f5a8ee93350c6465cddd66ebbf76/numpy-2.5.3-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159", size = 15697322, upload-time = "2026-09-06T16:26:49.415Z" }, + { url = "https://files.pythonhosted.org/packages/55/49/bbad5335fb4996a16881f853ff3e0ba582f01720e55c89b1c06b8fc42a90/numpy-2.5.3-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab", size = 16708995, upload-time = "2026-09-06T16:26:53.127Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e9/1df35483760b04a65ea44669f89dc64f30e5aca098b48ceb8b1310b0e0fe/numpy-2.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2", size = 17052508, upload-time = "2026-09-06T16:26:56.464Z" }, + { url = "https://files.pythonhosted.org/packages/b8/99/66e54da8265cc8be8a7382bf96edce17aaa2837d6f484432025932a3caa5/numpy-2.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7", size = 18468224, upload-time = "2026-09-06T16:26:59.966Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/b5e90a91c115168d793dfd2ad9c69c438c2fe7a13a437e770bc5b078e732/numpy-2.5.3-cp315-cp315-win32.whl", hash = "sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034", size = 6179919, upload-time = "2026-09-06T16:27:03.166Z" }, + { url = "https://files.pythonhosted.org/packages/37/ea/780748fd3985109075514ef8fc64cd25f943e40dde13a6d59141eb268fc8/numpy-2.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae", size = 12697656, upload-time = "2026-09-06T16:27:06.153Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/407be69a2a87c8cab64d95975a8977a426a29e138f07e276ec258f0fe4e5/numpy-2.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5", size = 10767601, upload-time = "2026-09-06T16:27:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/a97ffb01e41d50a32a9177aef942a4d0e389a3daf451d04e5f38ef6afb87/numpy-2.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a", size = 17090092, upload-time = "2026-09-06T16:27:12.907Z" }, + { url = "https://files.pythonhosted.org/packages/d1/24/136c02f2c2af9a067a84d0c3aa10c99012c0476fa5066732fa4a4202557d/numpy-2.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832", size = 12129429, upload-time = "2026-09-06T16:27:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6c/b47582d6597789bf946d5efbeb6b9e56fd8bcbd5efc6fbf51dbe1ea31eb3/numpy-2.5.3-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0", size = 5565452, upload-time = "2026-09-06T16:27:19.868Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/ef3cc6da73774202d4deae16bb321fd8298a4e0561e3539f8c4be237d916/numpy-2.5.3-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997", size = 6876736, upload-time = "2026-09-06T16:27:22.232Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/e3813329498596cb842703dcacac1741612ed9fb9c4e6a3e0c7e2ebbc597/numpy-2.5.3-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85", size = 15745777, upload-time = "2026-09-06T16:27:25.181Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9e/4e7a07fd0776dc2210cdacf2010be8665194d094defc10c419d7dea794cc/numpy-2.5.3-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4", size = 16746949, upload-time = "2026-09-06T16:27:28.576Z" }, + { url = "https://files.pythonhosted.org/packages/91/db/01674c0e20335057813a00c2ebd546ed25bff9ed7914f9bced00f8c55d94/numpy-2.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c", size = 17108994, upload-time = "2026-09-06T16:27:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/45/7a/584c5e71f8d378e57cac0b033891ed65c683ef90573ba4854e8c28203db0/numpy-2.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0", size = 18512266, upload-time = "2026-09-06T16:27:35.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d2/4e1014173aa3c55e6a756e0e567290743a6ab33a288460374d7ef6bcd239/numpy-2.5.3-cp315-cp315t-win32.whl", hash = "sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469", size = 6330292, upload-time = "2026-09-06T16:27:38.149Z" }, + { url = "https://files.pythonhosted.org/packages/6c/b0/ff5658a58199b7bcaad87bf260eef6713d9d42cca4e028f935b4fc5fbac6/numpy-2.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551", size = 12884918, upload-time = "2026-09-06T16:27:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pyarrow" +version = "25.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/e3/27f57f80141379d60defe6703eb50a707325706f07fedfd1312c7a751995/pyarrow-25.0.1.tar.gz", hash = "sha256:9150a83248bfed9813ea3c3af74c3856c1984d444aa28e58bf7733b9750ddf6a", size = 1201653, upload-time = "2026-08-10T12:40:53.904Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/8b/0d23b47702fcfe8b3618d5292035099675c5a1c48258932350c08020f7b5/pyarrow-25.0.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:51093dd9e10325fbdb3c10a2ae7c4806e5c822d94e74ae4938b26524a3323fee", size = 35946180, upload-time = "2026-08-10T12:37:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/d8/17/707d17a5476c55a9541fde0db8213ac30979a792864d72415f176ba50c45/pyarrow-25.0.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:eb6203482ff3746a5632303a7279ae0b5a304c46985b49ed1378cb350ea6728d", size = 37644787, upload-time = "2026-08-10T12:37:25.795Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b2/cdc98ecf1a6408280bc3a6a07054cdd99a3f4670acc0545d383ce113e87d/pyarrow-25.0.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:880523be3d29efcf83d3998835d206118ccf35e3871dbd2fb60408cf6b007a80", size = 46834633, upload-time = "2026-08-10T12:37:33.604Z" }, + { url = "https://files.pythonhosted.org/packages/c8/6e/d3fafc41f378b2c65be43b827798c0fae42049a641c8526633ed3eb573e2/pyarrow-25.0.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:25f8720bf6387d5dc2ebd2622112de630760419e4b66134405dd24110d15f37e", size = 50065507, upload-time = "2026-08-10T12:37:40.565Z" }, + { url = "https://files.pythonhosted.org/packages/d5/12/8d0698954b8c3001844a898e0a6900bebe83d7ee40c11195174c5122f324/pyarrow-25.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4facd65742a024a4a366328a1d2292062d72d6e023c1b7dda8d4c37544933a25", size = 49955690, upload-time = "2026-08-10T12:37:46.644Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/1ecb936ac6409e90a34d58eea1c7cec09a9ae6d2141b9e49ad01a2b1ea47/pyarrow-25.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aa0559502e1cd6254d6814614085dd9c5a3dd0419362978a936a3f68a9e5c3df", size = 53128198, upload-time = "2026-08-10T12:37:52.531Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1c/5236033550633c9b7377b2a53660b2bbb06cb06dc09c4356332d67643ca1/pyarrow-25.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:62cd0d785b8aa6675ee355f9fc02252a340f4441257c42674937826fd7594325", size = 27857263, upload-time = "2026-08-10T12:37:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e2/9ab15b88cbfac28e16419ce5439ec29234c5172cb8259301b4ba639bdec0/pyarrow-25.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:df961f2e7ae9cf496459259d798652c70625f6c080650d6952f8c04053c58ee9", size = 35861559, upload-time = "2026-08-10T12:38:02.567Z" }, + { url = "https://files.pythonhosted.org/packages/58/79/a0036dbe1eabe1f73127427342f1d99982584c4a2cde2651d6c93499c6f6/pyarrow-25.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:cc4aa407fde9fc660be3939e49ea31f50f3e9fec17c0ec63159f7711edd3efc9", size = 37628383, upload-time = "2026-08-10T12:38:09.083Z" }, + { url = "https://files.pythonhosted.org/packages/13/49/d93a57d375f4bf0cf82913dd6bb54acafde83dd993be2282c81ac5616cad/pyarrow-25.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:4340f0ba6c1d2e13f21658de1d7c662ca2545018568d0030a1e9afca159d87e3", size = 46820190, upload-time = "2026-08-10T12:38:15.458Z" }, + { url = "https://files.pythonhosted.org/packages/60/c9/711ca85d79f1ec98f29a5eae2b051e25b4ecec5de3e3c0e2d5c5dcb15664/pyarrow-25.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5389cdf79447ed1515c9e31620e6e1e2302249564d603f2ad727d4f6d313e4c3", size = 50102437, upload-time = "2026-08-10T12:38:22.487Z" }, + { url = "https://files.pythonhosted.org/packages/80/53/8fb8359ff17cfb6263a1cf3ebf7caec9fe197de118719e84fcb1d0618026/pyarrow-25.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d51592cb7561e87877c506113e7adbf1342ab579e6c21f0ef44b8ba41cb74c80", size = 49942424, upload-time = "2026-08-10T12:38:28.755Z" }, + { url = "https://files.pythonhosted.org/packages/e8/83/4e5ae02a9341571b18a6fca380ac7a58ce6ddae7ab3c060208c0a1e79f02/pyarrow-25.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6109c94d8b9f3b17a041daca16cacb2f651ad8f1ef70a4232c2c0f37a23da2a8", size = 53144206, upload-time = "2026-08-10T12:38:34.862Z" }, + { url = "https://files.pythonhosted.org/packages/65/ee/197cbf47e49f83e6ebeb946a5259a48a638dea27ac774db42fe78022179d/pyarrow-25.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:8858d7bfc22e3f51529aeaa4077225029724623e4595dc9eff8c793935c34140", size = 27953934, upload-time = "2026-08-10T12:38:39.808Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8d/8f271a7a034c834910ec925d56fa4b29733b1380f5289419f5aaa3b02777/pyarrow-25.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:c7c534ec03c358a76ea3e505e74c1b6aef290af90c444dfd092dbfe23e755b85", size = 35855328, upload-time = "2026-08-10T12:38:45.489Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cd/5bac242f4e841b9971d5eb94fdfe2577e2b70be983e27401e72055786037/pyarrow-25.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:dda9470024204d7bbf2042b47c6e8a0e47a3eeb8e34405882dfaea6577e0c153", size = 37622415, upload-time = "2026-08-10T12:38:51.107Z" }, + { url = "https://files.pythonhosted.org/packages/63/1f/96d03b4e1506524f7087adb0fd6b2f69f0c9c7aaff1ec36d8030082e15a5/pyarrow-25.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:44a9120ce5bd81936b8ab9a88076e3fd47c2c6838e0e43630fed83626aca81d9", size = 46813813, upload-time = "2026-08-10T12:38:57.773Z" }, + { url = "https://files.pythonhosted.org/packages/98/d6/33a411115b61dbfc16ad6ad73e71730f6fea654ee3667673bc53ab0e2fe7/pyarrow-25.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:0befcf816e45a1af33ac775a9970b749e4868a230c7372f0ae5e932bee27039f", size = 50104452, upload-time = "2026-08-10T12:39:04.579Z" }, + { url = "https://files.pythonhosted.org/packages/33/ae/b1b97c9ca87f9f9ddbb5230c798df94eccce61bd79b9b45458c69a478588/pyarrow-25.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f89685964f46e4216103c75483aac0c0692a5f72212d7ca835adba5ede56ce3", size = 49951343, upload-time = "2026-08-10T12:39:11.8Z" }, + { url = "https://files.pythonhosted.org/packages/98/9e/a112df5cfd5a68cb1d9fc31cfe38c28d5aec9f10865ce37ecef2e4450873/pyarrow-25.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6943e2fe7954d29d84de45d29d34c8dc36ce96570e67d89aa9976e650a4a9138", size = 53144784, upload-time = "2026-08-10T12:39:20.503Z" }, + { url = "https://files.pythonhosted.org/packages/31/24/97e8bd98f1e3b07e2ba08bcdff690674fbe16d69a7d2712cc3884665e615/pyarrow-25.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:31e49a7888fcdf3a835da33ae777f6bb9a866334e5a789282fc26dcf426f7f15", size = 27870159, upload-time = "2026-08-10T12:39:26.161Z" }, + { url = "https://files.pythonhosted.org/packages/36/4c/b525824ad3094076919273cd97db61fb3d78252dee76fa3b8dc8f76774aa/pyarrow-25.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:bf0b672390cdcb640d7288f96b826d71ff4e9abb254a86c89890baf51a29cee6", size = 35885255, upload-time = "2026-08-10T12:39:32.366Z" }, + { url = "https://files.pythonhosted.org/packages/08/62/448bb0e940de41aec31d1a956e63ad9c54afdf122a103cc3ab20c2a3ce33/pyarrow-25.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:38a9a4b4b9613380e200641891495a56c3d5a98a092db4a870af9975e220471d", size = 37644461, upload-time = "2026-08-10T12:39:38.142Z" }, + { url = "https://files.pythonhosted.org/packages/6e/9a/13587e38bd4806fd218f50fd13b8903fab60588a699ff0c406372e5b4043/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b726ad7e7b669be982b0c71c07fe4b037d654354130da79a7902a669e93a66b", size = 46877146, upload-time = "2026-08-10T12:39:43.722Z" }, + { url = "https://files.pythonhosted.org/packages/8d/61/1c5d1229fa21da4cff5365e41e57177aaac57c563c727f35419b8513d1c1/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:9171748cdf796972d85a4b60157c279913e242992e350c90c7450182a9838b2a", size = 50131616, upload-time = "2026-08-10T12:39:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/43/20/291e1d65cc0b09aa19f03cf25cf51a2f5fa94b5db315178f2d254ed5cad4/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b7a296aac7a71fa0886c08e155ddb6c636a50013f801f6178daafa0f9e726188", size = 50008879, upload-time = "2026-08-10T12:39:56.891Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7c/1b7c9ec28e76576337e4f97b31141c9a181b89b6d1d6221e9d8205621a58/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0fe7c8b6c03969b49c8c66182e4a18e3819ab92d07cfab5d8370c531b9369ef0", size = 53170864, upload-time = "2026-08-10T12:40:04.918Z" }, + { url = "https://files.pythonhosted.org/packages/b7/75/f3d789dc06011a765d14d86bda799cf72ac1d715b6a6edecaa0d73d95062/pyarrow-25.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:f729cfdbd36fd99d543b67a914d2de044c84ebe45be8b34902b299b608c15c8f", size = 28620729, upload-time = "2026-08-10T12:40:51.41Z" }, + { url = "https://files.pythonhosted.org/packages/fc/05/647a8ee6f7c2662feb6921315617bc04dcd6034763fb61b1199720bf6162/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:59a2de54c0cbd954da861eee4d1d330f8e909c45b53455baef696380f2c55033", size = 36130288, upload-time = "2026-08-10T12:40:11.014Z" }, + { url = "https://files.pythonhosted.org/packages/93/f8/c9ee997554d7bea94520667dd1933f109ac1da3ee3556d2b49381e023484/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:35935cd5de130aa5cf4dea052a63e6bf2e17006c35c3a468194242b9b2bf5956", size = 37762187, upload-time = "2026-08-10T12:40:16.592Z" }, + { url = "https://files.pythonhosted.org/packages/a2/08/a28c01c7fe9e96e8233ce2d13df1d402f4f999f848f51d2daacd6bb4c036/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f3831aaa25c67a99f99dc8b05873cb9d64560390372e2aa197ce9dd4a3f06a44", size = 46888003, upload-time = "2026-08-10T12:40:23.242Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b9/58612e977d28dc58c878448866838369ee8da2f1e7cc8ed2c84b952aafee/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6a1fdfc6659b6b19022f2e50627fb5cf7156a66c46bf4299379955cbe742382a", size = 50079036, upload-time = "2026-08-10T12:40:29.169Z" }, + { url = "https://files.pythonhosted.org/packages/72/13/66e1402dcc860e1dc2760b1e0292c9a569b62b3bccab69def1b3e907d006/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:169d3429d5be7c752125890620f75a60776d38b0035eddae939651640822332e", size = 50040226, upload-time = "2026-08-10T12:40:35.186Z" }, + { url = "https://files.pythonhosted.org/packages/78/10/3f1a5497a7ef732ab0f03ecca3e66d89d9c0f57fdc61b4794c456b781f01/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:119297a6dc197e45d9c6d4415f7814a67ffa36c180d26f68c154c58067ae782d", size = 53149035, upload-time = "2026-08-10T12:40:41.454Z" }, + { url = "https://files.pythonhosted.org/packages/93/c0/37d4a7e8e2f7a6076283673d5298018ca26478b934c6ee369e10505ab32c/pyarrow-25.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4288f27577352d608ca08553b0865e4a9b3aa14820c5d95b53337218d609835b", size = 28753071, upload-time = "2026-08-10T12:40:46.623Z" }, +] + +[[package]] +name = "pygame-ce" +version = "2.5.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/2d/0f942ec31d558a6a1f2fd0df9965ff0055f165ed5b8d36f6509b1f3768a2/pygame_ce-2.5.8.tar.gz", hash = "sha256:3c8e69088ead310037972c391306ea58e74d7296b35d1890067749235fd554ba", size = 5300831, upload-time = "2026-08-09T11:39:49.226Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/9b/884fb7367951e74e7f77061bb08e893272794c07b1212dc61ba258c3c71e/pygame_ce-2.5.8-cp311-cp311-macosx_10_11_universal2.whl", hash = "sha256:9e98223c8874177c8441923a8f60da76b1de9d7f2450602a4c7ef24c15e84f1a", size = 16588698, upload-time = "2026-08-09T11:38:07.009Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7d/b3f3a58cd09e97dbbedde419bedb7ead1f7e4d3cd6465b00bdf56766ded9/pygame_ce-2.5.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a7b2a54548926eaf535ac574cfa6528f96bd1e2f524a20fea514b2922dd68bcc", size = 12060566, upload-time = "2026-08-09T11:38:09.516Z" }, + { url = "https://files.pythonhosted.org/packages/ca/8b/7ba5dbf40ff461984327539a6895ada8ab2eac4c035238f97a0fd9da9866/pygame_ce-2.5.8-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ff283d1bee65e208cf2f0da7f2f4e3d0993d1787938898de2872fbefab1f786b", size = 12599420, upload-time = "2026-08-09T11:38:12.101Z" }, + { url = "https://files.pythonhosted.org/packages/05/02/c315738725cd08d2ff4cc8f8dd29638e69e70f200d3610bcd291cbb886b8/pygame_ce-2.5.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:33e1b5a78cd6d25c2c8f00c09cdc0ba8000eec81b53dc7d8d7323d8319691d69", size = 12155284, upload-time = "2026-08-09T11:38:14.456Z" }, + { url = "https://files.pythonhosted.org/packages/17/3d/b9d57ebab57fdef946626ae275b111c077b83fe0d0ccc3a2ec12b17523d2/pygame_ce-2.5.8-cp311-cp311-win32.whl", hash = "sha256:b919b49b8a41564f7d0ad43f6dc97a66f29d3956fb592581f4abd62442644c9c", size = 9122893, upload-time = "2026-08-09T11:38:16.789Z" }, + { url = "https://files.pythonhosted.org/packages/e7/68/54cbebea295bbd9598604e3b4c3ef2aff75df1612482c2d102963151096c/pygame_ce-2.5.8-cp311-cp311-win_amd64.whl", hash = "sha256:0f75f6cf17607e380687aa5551c46aa830c8037b2950c71e48be8891ddc737da", size = 9666898, upload-time = "2026-08-09T11:38:19.213Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8a/79129cc0c755d639d180a582869e4c7a0d5106dbf611262bcf5d554ce59d/pygame_ce-2.5.8-cp311-cp311-win_arm64.whl", hash = "sha256:8e7931bd6b8ffc1474e7b3d5717e18bf8ff3eed95ab9042f1b9a0907e9ff6519", size = 10212252, upload-time = "2026-08-09T11:38:21.537Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2a/9c48c56cb36baa49d8732fc41896d2d8a6027bf94155aa1a5663fd72f7ff/pygame_ce-2.5.8-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0ae2141c81595b4b13b780d325b9ea0016c6647f8b3a6004278b00765a96664d", size = 16570732, upload-time = "2026-08-09T11:38:24.118Z" }, + { url = "https://files.pythonhosted.org/packages/a6/83/f4979079f5a8f729b0f488e987db1227086816c26e1b985d61fc9a60605f/pygame_ce-2.5.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e401643af9455cb69f8cb77050c325f290314da0051e9ab82c5ccaf8017c314a", size = 12048869, upload-time = "2026-08-09T11:38:26.733Z" }, + { url = "https://files.pythonhosted.org/packages/8b/38/9961f6543bfb4ecc4dbeee0fdb57edbd633945dc143df9c70ee476416cc4/pygame_ce-2.5.8-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:6d4839f127b1b66f1dfe7e0dca3d2233b6f3a63077690081d1996e24087c6055", size = 12587614, upload-time = "2026-08-09T11:38:29.143Z" }, + { url = "https://files.pythonhosted.org/packages/1e/07/e139faa8911d48991e161e356e652be2a642303f3baf6a57c7ba4a1f0f49/pygame_ce-2.5.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf9c8b4376077efec6ae31443172ae98545f278d0e4426852e81c28ab1f5d7c2", size = 12147342, upload-time = "2026-08-09T11:38:32.166Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e6/238b48936733ed1908486df072137e7eef61fd26de4620d54e438b69d509/pygame_ce-2.5.8-cp312-cp312-win32.whl", hash = "sha256:91d2434d8cbdb7cd04c9b37e76a8154641da25f4e274265806407f495fe6e889", size = 9126160, upload-time = "2026-08-09T11:38:34.812Z" }, + { url = "https://files.pythonhosted.org/packages/81/13/2bba0ebe563047ae9de68dc05656dc0bfa9394c60970548b06d2f2e106c5/pygame_ce-2.5.8-cp312-cp312-win_amd64.whl", hash = "sha256:2cfe8afe1e7955780f0bd4f01d65f1b4903f561407052081cfcec31e80957c3c", size = 9668595, upload-time = "2026-08-09T11:38:37.279Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2e/871161f0546d549fa458609029771d7a36c1e7758e164a3b2c2ed61b507b/pygame_ce-2.5.8-cp312-cp312-win_arm64.whl", hash = "sha256:6c48a3ad5f7102e076055a3c817c12e932ee5c9b831080db2ea9754236b0490d", size = 10213955, upload-time = "2026-08-09T11:38:39.559Z" }, + { url = "https://files.pythonhosted.org/packages/8a/06/f2e2d9fe3eb2dc1fde30b90e250274e85ba355af729892ac71ba653924a1/pygame_ce-2.5.8-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:56441a9bb75c2461750dc0e6e4a46e3833b3cf6339bfbaf16f93ecac037510f3", size = 16562516, upload-time = "2026-08-09T11:38:42.311Z" }, + { url = "https://files.pythonhosted.org/packages/4d/fe/4be67df98bb05f7b024900f80fe4d07a72cec7022262d17c449b2a9f6034/pygame_ce-2.5.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e3183bc0d808e739ca2400df45a5bfae10704ccbcacab0f04f9adc9027e86427", size = 12045702, upload-time = "2026-08-09T11:38:44.903Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8f/f7d283799aaa2208c1276f085d514f2ac37ce1bb5df940c7cbac7e6a6320/pygame_ce-2.5.8-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:841aecccb498419367936bea0b1b0c6e9d635dc81c41ef26ec1769158f1ab871", size = 12585829, upload-time = "2026-08-09T11:38:47.507Z" }, + { url = "https://files.pythonhosted.org/packages/27/4d/03fd52c7f958b8e929757a118cada9aee16ae9d4fd84f2682ee0cbd3941d/pygame_ce-2.5.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5bb966b610e161a8f6d908b4c62ef3a1f3e922bc7bed73bd70a3e15dc780b90e", size = 12145623, upload-time = "2026-08-09T11:38:49.87Z" }, + { url = "https://files.pythonhosted.org/packages/6e/61/c02613190a3256a656258bb4f677aecc09c3cb1f5521490ce7d5fb6308dd/pygame_ce-2.5.8-cp313-cp313-win32.whl", hash = "sha256:cab944d76af71e707803856b4db984ca90d9d6453e470e89771ed963c11ed912", size = 9123635, upload-time = "2026-08-09T11:38:52.452Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/da9186e5b88714c16fdb23bc4ba0bca4a75c21e3a5cf9607765773f68d22/pygame_ce-2.5.8-cp313-cp313-win_amd64.whl", hash = "sha256:f495b0eb7a5c54c59da58e964bc7f68073c3f43cf307729fd48309104a04c190", size = 9668342, upload-time = "2026-08-09T11:38:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/55/d3/78136bc51be25afbd7954c22488860e80f32d9a885f6667fed8aac7df0d6/pygame_ce-2.5.8-cp313-cp313-win_arm64.whl", hash = "sha256:dea22b4d4c418bbd0bd9853ae797ecfa882436d2684ceb6063ba8b520adc8500", size = 10211021, upload-time = "2026-08-09T11:38:57.529Z" }, + { url = "https://files.pythonhosted.org/packages/4d/e6/8e83904cf4184223419345a78c905e7c1ace10228befd87eb497ba015c8c/pygame_ce-2.5.8-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:7ec4efa6b57a194f5ad51b92211cc74b485320df951b0cc870235b2b889b9b6d", size = 16571019, upload-time = "2026-08-09T11:39:00.084Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f3/4f90a0b5e86635d741111084eaa4d4c65fcab2f461edb8c9efa502e4c630/pygame_ce-2.5.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb91d0bb0b5e2a4da61910d752769d4580cacffb4b7338cee57c932351c67e7b", size = 12051251, upload-time = "2026-08-09T11:39:02.561Z" }, + { url = "https://files.pythonhosted.org/packages/99/7e/b0c4f5d43261e8707353ef40cf6620d46d42f43a502c236e93ce6ea82e7b/pygame_ce-2.5.8-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68bd9c4ac42bb2549c034d56399404f16c87c56714db721ed975a759798a3d16", size = 12585932, upload-time = "2026-08-09T11:39:04.989Z" }, + { url = "https://files.pythonhosted.org/packages/dd/62/06f0ceb7f5a154e0071a4ce81a16de47b9f3b85034fa391686d489d15be5/pygame_ce-2.5.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0027bd2255cbb39789d9c7ff95a5f040f56d1cfeb09504497447c0ee06169a24", size = 12147357, upload-time = "2026-08-09T11:39:07.433Z" }, + { url = "https://files.pythonhosted.org/packages/05/57/14d6b40318af71cf20e4c750866795424e018f79c5a5cf3a9a74a8717c4c/pygame_ce-2.5.8-cp314-cp314-win32.whl", hash = "sha256:c5285d444e4b789ef95522bbd2b90433163ad2b89e0ae4891236fde88a027fbd", size = 9255121, upload-time = "2026-08-09T11:39:09.723Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/92d32a9bf9b78e9ef10cec7224e2f00efc957757505822af8dc4225e9b41/pygame_ce-2.5.8-cp314-cp314-win_amd64.whl", hash = "sha256:b4c2e28c201240199356952e6bd0221969d2eb8a1a15c3454d77db209bad7891", size = 9833757, upload-time = "2026-08-09T11:39:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/5a/26/4024483d4a3161ed8fb8d3f3f4957580af31d468e0c31df7e6038f45ccdc/pygame_ce-2.5.8-cp314-cp314-win_arm64.whl", hash = "sha256:b23034594412456504822d3088cf5f292f63bbe415eac29735340a59999e4116", size = 10410381, upload-time = "2026-08-09T11:39:14.417Z" }, + { url = "https://files.pythonhosted.org/packages/fd/11/0b47f80b261d6379c86b9cc3fa405af5bd62af10506de7e28c2275d67b86/pygame_ce-2.5.8-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:0a4e0804d53bae8d9aa5192fc2259b40dd084b48ea273dd7dd6e1c12b73303c4", size = 16571139, upload-time = "2026-08-09T11:39:16.979Z" }, + { url = "https://files.pythonhosted.org/packages/f1/95/694cd02641a0c0956b6c5919b56599af302524ac133551b654f550f28919/pygame_ce-2.5.8-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d7f03d38b3693c014dd47d4e281620b6756669129e545af1cd608a9484582846", size = 12051154, upload-time = "2026-08-09T11:39:19.873Z" }, + { url = "https://files.pythonhosted.org/packages/da/5e/bed22b0d07d9e96bb9a448acc9da7a235f046280264c33476f0c0753d302/pygame_ce-2.5.8-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:d084b79535f3529aa157edd9401f4f33e5082f318d287029291d6596bb225105", size = 12585976, upload-time = "2026-08-09T11:39:22.555Z" }, + { url = "https://files.pythonhosted.org/packages/21/ce/8c167ba5ba736e372730ae29ef0f8cb2e62a88580e647ae987bc112eb1cb/pygame_ce-2.5.8-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9cbf6e2648ae6d76aa8ece82187d240300cfd9f4c90860e1e466f78cf0ab14a", size = 12147466, upload-time = "2026-08-09T11:39:24.935Z" }, + { url = "https://files.pythonhosted.org/packages/63/92/c99ed51479f2d2a5d7fc9d5aa3b880e4c0fcc87f30cb95ecf325f35dd2fd/pygame_ce-2.5.8-cp315-cp315-win32.whl", hash = "sha256:312a01ff5439a0bd2e55b56683518454da4cbe4cad493d0580e9c49f6aacc2d8", size = 9254928, upload-time = "2026-08-09T11:39:29.525Z" }, + { url = "https://files.pythonhosted.org/packages/87/41/404836598d666ffe02681fd0c29f013daaa8baf18e5de5f7a5d4870b6105/pygame_ce-2.5.8-cp315-cp315-win_amd64.whl", hash = "sha256:5b789aa7e4239be025c9de8428c2c422f0811f0e3dd702b574d836fd9ae040eb", size = 9833657, upload-time = "2026-08-09T11:39:31.897Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a2/7a844f772c6f0967e75d49cb10602fbb878b9daee1cc080d2b9597bcc873/pygame_ce-2.5.8-cp315-cp315-win_arm64.whl", hash = "sha256:28c4fed3870e3edf72ea7ea359bb5f64c55db0fb9cb3bdb26b4b674b4b4bcff4", size = 10410231, upload-time = "2026-08-09T11:39:34.184Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b8/525dfcfda50ea9e98cd4097d6d0beabcfa0a6007475c04dab2021077c6de/pygame_ce-2.5.8-pp311-pypy311_pp73-macosx_10_15_universal2.whl", hash = "sha256:0aa908c9fce4d508205e0b9cdc0f7338066da63322f6bcaa9a9c8aff9155a87e", size = 16485912, upload-time = "2026-08-09T11:39:37.277Z" }, + { url = "https://files.pythonhosted.org/packages/bc/33/d02386a4314be22bc6198d93c2d99f372c0c8c7b71fc9c390cf36d5c5c99/pygame_ce-2.5.8-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:894faf17699ea7f14eabd9161ef532c1d248e40f568dab9240fc68b4c1ea85dc", size = 12008829, upload-time = "2026-08-09T11:39:39.717Z" }, + { url = "https://files.pythonhosted.org/packages/f5/26/a14abbac97c681385dbb01d5c44c867992dbf2265a784af35141c485813e/pygame_ce-2.5.8-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0d0c4b2b38e1946ea7a7c625ea317e237573e8731e4a56055eb4361b3c2daa0f", size = 12538786, upload-time = "2026-08-09T11:39:42.37Z" }, + { url = "https://files.pythonhosted.org/packages/28/6c/2a8d9ca41d40e2d30e940107a019bbc17de518948c360789f59a439d689a/pygame_ce-2.5.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9ea02f653f76d496870766e1fb8419b6a6aebd81d40265057672f1f6d63d3509", size = 12102335, upload-time = "2026-08-09T11:39:44.64Z" }, + { url = "https://files.pythonhosted.org/packages/47/b5/1068d4d24891b17fcb6cf3556b1de8d5821a2bc44ebbea2b25dbd767ae2b/pygame_ce-2.5.8-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2f2f1c03dc8d4460779f9e55f959a7c7e523101b3530f60f3208e5ca65a6216", size = 9635346, upload-time = "2026-08-09T11:39:46.968Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" }, + { url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" }, + { url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" }, + { url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" }, + { url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" }, +] From c9cb4b8c9ec0a9cf48a237b59072cc0e9788ee59 Mon Sep 17 00:00:00 2001 From: fondef Date: Tue, 15 Sep 2026 15:42:53 -0500 Subject: [PATCH 02/10] add the axis mapping Pure functions, no state and no hardware, because this is the part that has to be right and the cheapest to check. Analog and blended: how fast the arm moves depends on how far the stick is pushed, and several axes move at once. That is the whole reason a pad beats the web console's four-sector dial pad, which can only ask for one axis at a time at one fixed speed. The deadzone rescales what is left of the travel rather than just clipping it, so the arm starts from zero just outside the band instead of jumping to a fraction of full speed the instant the stick leaves it. Expo bends the response because fine positioning happens near centre, where a linear stick gives almost no resolution. Up on the stick sends the arm along +X, matching the console's dial, so the two agree about which way is which. --- dora_teleop/mapping.py | 123 +++++++++++++++++++++++++++++++++++++++++ tests/test_mapping.py | 110 ++++++++++++++++++++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 dora_teleop/mapping.py create mode 100644 tests/test_mapping.py diff --git a/dora_teleop/mapping.py b/dora_teleop/mapping.py new file mode 100644 index 0000000..93969b9 --- /dev/null +++ b/dora_teleop/mapping.py @@ -0,0 +1,123 @@ +"""Turning stick deflection into a velocity the arm understands. + +Pure functions, no state and no hardware, because this is the part that has to +be right and the part that is cheapest to check. Everything here is in the +arm node's units: `[vx, vy, vz, wx, wy, wz]`, mm/s and deg/s, in the robot's +**base** frame — the same frame `tcp_pose` reports. + +The screen convention comes from the old web console, so the two agree: stick +up is +X, stick right is +Y. +""" + +from dataclasses import dataclass + +# The arm takes six numbers; these name them. +VX, VY, VZ, WX, WY, WZ = range(6) + + +@dataclass(frozen=True) +class Layout: + """Which axis and button index does what, in SDL's numbering. + + The defaults are the layout SDL reports for an Xbox-style pad, which is also + what it reports for most PlayStation-style ones. A pad that disagrees is a + change here rather than a new environment variable: an index nobody can + verify from the outside is worse than a line of code somebody can read. + """ + + left_x: int = 0 + left_y: int = 1 + left_trigger: int = 2 + right_x: int = 3 + right_y: int = 4 + right_trigger: int = 5 + + yaw_left: int = 4 # LB + yaw_right: int = 5 # RB + grasp: int = 0 # A + release: int = 1 # B + clear: int = 2 # X + freedrive: int = 3 # Y + estop: int = 6 # Back + + +LAYOUT = Layout() + + +def deadzone(value: float, width: float) -> float: + """Ignore travel near centre, and rescale what is left to the full range. + + Rescaling matters: without it the arm jumps to `width` of full speed the + instant the stick leaves the dead band, which feels like a broken control + rather than a gentle one. + """ + if width <= 0.0: + return value + size = abs(value) + if size < width: + return 0.0 + scaled = (size - width) / (1.0 - width) + return scaled if value > 0 else -scaled + + +def expo(value: float, factor: float) -> float: + """Bend the response so small deflections move the arm slowly. + + Fine positioning happens near centre, and a linear stick gives it almost no + resolution there. `factor` is how much of the curve is cubic: 0 is linear. + """ + return (1.0 - factor) * value + factor * value**3 + + +def trigger(value: float) -> float: + """Return a trigger's travel as 0 at rest and 1 fully pressed. + + SDL reports a trigger from -1 to 1, unlike a stick, and a trigger that has + never been touched sometimes reads 0 instead of -1 until it first moves. + Clamping keeps that from reading as half pressed. + """ + return max(0.0, min(1.0, (value + 1.0) / 2.0)) + + +def axis(values: list[float], index: int) -> float: + """Read one axis, treating a pad that does not report it as centred.""" + return values[index] if index < len(values) else 0.0 + + +def pressed(buttons: list[bool], index: int) -> bool: + """Read one button, treating a pad that does not report it as unpressed.""" + return bool(buttons[index]) if index < len(buttons) else False + + +def velocity(axes: list[float], buttons: list[bool], config, layout: Layout = LAYOUT) -> list[float]: + """Map one gamepad reading to a base-frame velocity. + + Translation comes from the left stick and the triggers, rotation from the + right stick and the bumpers. Several axes move at once on purpose: a stick + is analog and blended, which is exactly what the console's four-sector dial + pad could not do. + """ + + def shaped(value: float) -> float: + return expo(deadzone(value, config.deadzone), config.expo) + + out = [0.0] * 6 + # Up on the stick is negative in SDL, and up should send the arm away from + # the operator, along +X. + out[VX] = shaped(-axis(axes, layout.left_y)) * config.speed_mm_s + out[VY] = shaped(axis(axes, layout.left_x)) * config.speed_mm_s + + lift = trigger(axis(axes, layout.right_trigger)) - trigger(axis(axes, layout.left_trigger)) + out[VZ] = shaped(lift) * config.speed_mm_s + + out[WX] = shaped(axis(axes, layout.right_x)) * config.speed_deg_s + out[WY] = shaped(-axis(axes, layout.right_y)) * config.speed_deg_s + + yaw = pressed(buttons, layout.yaw_right) - pressed(buttons, layout.yaw_left) + out[WZ] = yaw * config.speed_deg_s + return out + + +def is_moving(velocity_vector: list[float]) -> bool: + """Whether this velocity would actually drive the arm.""" + return any(velocity_vector) diff --git a/tests/test_mapping.py b/tests/test_mapping.py new file mode 100644 index 0000000..86a7274 --- /dev/null +++ b/tests/test_mapping.py @@ -0,0 +1,110 @@ +"""Stick deflection to base-frame velocity: units, signs and the shaping curve.""" + +import pytest + +from dora_teleop import mapping +from dora_teleop.mapping import VX, VY, VZ, WX, WY, WZ +from tests.conftest import stick + + +def test_a_stick_at_rest_asks_for_nothing(config): + velocity = mapping.velocity(stick().axes, [], config) + + assert velocity == [0.0] * 6 + assert not mapping.is_moving(velocity) + + +def test_pushing_the_stick_up_moves_the_arm_along_positive_x(config): + # Up is negative in SDL, and up should send the arm away from the operator. + velocity = mapping.velocity(stick(axes=[0.0, -1.0]).axes, [], config) + + assert velocity[VX] == pytest.approx(config.speed_mm_s) + assert velocity[VY] == 0.0 + + +def test_pushing_the_stick_right_moves_the_arm_along_positive_y(config): + velocity = mapping.velocity(stick(axes=[1.0, 0.0]).axes, [], config) + + assert velocity[VY] == pytest.approx(config.speed_mm_s) + + +def test_the_right_trigger_lifts_and_the_left_one_lowers(config): + up = mapping.velocity(stick(axes=[0, 0, -1.0, 0, 0, 1.0]).axes, [], config) + down = mapping.velocity(stick(axes=[0, 0, 1.0, 0, 0, -1.0]).axes, [], config) + + assert up[VZ] == pytest.approx(config.speed_mm_s) + assert down[VZ] == pytest.approx(-config.speed_mm_s) + + +def test_both_triggers_together_cancel(config): + velocity = mapping.velocity(stick(axes=[0, 0, 1.0, 0, 0, 1.0]).axes, [], config) + + assert velocity[VZ] == 0.0 + + +def test_an_untouched_trigger_does_not_read_as_half_pressed(config): + # SDL reports a trigger from -1 to 1, and one that has never moved sometimes + # reads 0 until it does. Treating that as half travel would lift the arm. + assert mapping.trigger(-1.0) == 0.0 + assert mapping.trigger(0.0) == pytest.approx(0.5) + assert mapping.trigger(1.0) == 1.0 + + +def test_the_right_stick_turns_the_tool_in_degrees_per_second(config): + velocity = mapping.velocity(stick(axes=[0, 0, -1, 1.0, -1.0, -1]).axes, [], config) + + assert velocity[WX] == pytest.approx(config.speed_deg_s) + assert velocity[WY] == pytest.approx(config.speed_deg_s) + + +def test_the_bumpers_yaw_in_opposite_directions(config): + right = mapping.velocity(stick().axes, [False] * 5 + [True], config) + left = mapping.velocity(stick().axes, [False] * 4 + [True, False], config) + + assert right[WZ] == pytest.approx(config.speed_deg_s) + assert left[WZ] == pytest.approx(-config.speed_deg_s) + + +def test_several_axes_move_at_once(config): + # The whole reason a pad beats the console's four-sector dial: the dial can + # only ever ask for one axis. + velocity = mapping.velocity(stick(axes=[1.0, -1.0, -1, 0, 0, 1.0]).axes, [], config) + + assert velocity[VX] > 0 + assert velocity[VY] > 0 + assert velocity[VZ] > 0 + + +def test_drift_inside_the_deadzone_is_ignored(config): + # A worn stick that does not centre would otherwise drive the arm across the + # table with nobody touching it. + velocity = mapping.velocity(stick(axes=[config.deadzone / 2, 0.0]).axes, [], config) + + assert velocity == [0.0] * 6 + + +def test_motion_starts_from_zero_just_outside_the_deadzone(config): + # Without rescaling, the arm would jump to a fraction of full speed the + # instant the stick left the dead band. + just_outside = mapping.deadzone(config.deadzone + 1e-9, config.deadzone) + + assert just_outside == pytest.approx(0.0, abs=1e-6) + + +def test_the_deadzone_still_reaches_full_speed_at_full_deflection(config): + assert mapping.deadzone(1.0, config.deadzone) == pytest.approx(1.0) + assert mapping.deadzone(-1.0, config.deadzone) == pytest.approx(-1.0) + + +def test_expo_slows_small_deflections_without_capping_large_ones(): + assert mapping.expo(0.5, 0.0) == pytest.approx(0.5) + assert mapping.expo(0.5, 1.0) == pytest.approx(0.125) + assert mapping.expo(1.0, 1.0) == pytest.approx(1.0) + assert mapping.expo(-0.5, 1.0) == pytest.approx(-0.125) + + +def test_a_pad_reporting_fewer_axes_is_read_as_centred_not_as_an_error(config): + velocity = mapping.velocity([0.0, 0.0], [], config) + + assert velocity[VX] == 0.0 + assert velocity[WZ] == 0.0 From 04f0d570680e976f742900013a6672a1039c97ed Mon Sep 17 00:00:00 2001 From: fondef Date: Tue, 15 Sep 2026 15:43:00 -0500 Subject: [PATCH 03/10] add the lease that decides who drives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two sources want one arm and the arm cannot take both. Putting the decision in one place means there is exactly one answer to "why did it not move" rather than one answer per input path. The model is ported from the monolithic repo's RobotService lease rather than replaced with last-writer-wins, because the console is already built on it: its jog pad is gated on owning the arm and it draws an owner chip. A simpler scheme would mean rewriting the page to match the node instead of the other way round. The gamepad takes the arm implicitly, on the first nudge of a stick, so nobody has to reach for a browser to use the thing in their hands — and gives it back after sitting untouched, so a pad left on the bench cannot lock the browser out forever. The web takes and releases explicitly, and may preempt: an operator who can see the arm is better placed than this code to decide they should have it. --- dora_teleop/arbiter.py | 78 ++++++++++++++++++++++++++++++ tests/test_arbiter.py | 106 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 dora_teleop/arbiter.py create mode 100644 tests/test_arbiter.py diff --git a/dora_teleop/arbiter.py b/dora_teleop/arbiter.py new file mode 100644 index 0000000..38196df --- /dev/null +++ b/dora_teleop/arbiter.py @@ -0,0 +1,78 @@ +"""Who is allowed to drive the arm. + +Two sources want one arm, and the arm cannot take both. This is the whole of +that decision, in one place, so there is exactly one answer to "why did it not +move" rather than one per input path. + +The model comes from the monolithic repo's `RobotService` lease, and is kept +because the old web console is built on it: its jog pad is gated on owning the +arm and it renders an owner chip. A simpler last-writer-wins scheme would mean +rewriting the console to match the node rather than the other way round. + +The arm node itself has no idea any of this exists. A lease held here binds only +what goes through this node — another node publishing `request` to the arm will +preempt an operator mid-motion, and nothing here can stop it. +""" + +from dora_teleop.log import log + +GAMEPAD = "gamepad" +WEB = "web" + + +class Arbiter: + """The lease on the arm, and the rules for handing it between sources.""" + + def __init__(self, idle_release_s: float): + self.idle_release_s = idle_release_s + self._owner: str | None = None + self._last_activity = 0.0 + + @property + def owner(self) -> str | None: + """Who holds the arm, or None if it is free.""" + return self._owner + + def take(self, who: str, now: float, preempt: bool = False) -> bool: + """Give ``who`` the arm if it is theirs to take, and say whether it worked. + + A free arm goes to whoever asks. A held one stays held unless the caller + asked to preempt, which the console's TAKE CONTROL button offers + deliberately: an operator who can see the arm is better placed than this + code to decide they should have it. + """ + if self._owner not in (None, who) and not preempt: + return False + if self._owner != who: + log(f"{who} has the arm" + (f", preempting {self._owner}" if self._owner else "")) + self._owner = who + self._last_activity = now + return True + + def release(self, who: str) -> None: + """Give the arm back, if this source had it.""" + if self._owner == who: + log(f"{who} released the arm") + self._owner = None + + def note_activity(self, who: str, now: float) -> None: + """Record that the holder is still there, so the idle timer does not fire.""" + if self._owner == who: + self._last_activity = now + + def expire_idle_gamepad(self, now: float) -> None: + """Hand the arm back after the pad has sat untouched long enough. + + Only the gamepad expires. It takes the arm implicitly, on the first + nudge of a stick, so it has to give it back the same way or a pad left + on the bench would lock the browser out forever. The web takes the arm + by pressing a button and gives it back by pressing another. + """ + if self._owner != GAMEPAD: + return + if now - self._last_activity >= self.idle_release_s: + self.release(GAMEPAD) + + def may_drive(self, who: str) -> bool: + """Whether this source's jog should reach the arm.""" + return self._owner == who diff --git a/tests/test_arbiter.py b/tests/test_arbiter.py new file mode 100644 index 0000000..db4eecd --- /dev/null +++ b/tests/test_arbiter.py @@ -0,0 +1,106 @@ +"""The lease: who may drive the arm, and how it changes hands.""" + +from dora_teleop.arbiter import GAMEPAD, WEB, Arbiter + + +def build(idle_release_s=2.0): + return Arbiter(idle_release_s) + + +def test_a_free_arm_goes_to_whoever_asks(): + arbiter = build() + + assert arbiter.take(WEB, now := 100.0) is True + assert arbiter.owner == WEB + assert arbiter.may_drive(WEB) + assert now == 100.0 + + +def test_a_held_arm_is_not_handed_over_for_the_asking(): + arbiter = build() + arbiter.take(GAMEPAD, 100.0) + + assert arbiter.take(WEB, 101.0) is False + assert arbiter.owner == GAMEPAD + + +def test_a_non_owner_may_not_drive(): + arbiter = build() + arbiter.take(GAMEPAD, 100.0) + + assert arbiter.may_drive(WEB) is False + + +def test_preempting_takes_a_held_arm(): + # The console offers this deliberately: an operator who can see the arm is + # better placed than this code to decide they should have it. + arbiter = build() + arbiter.take(GAMEPAD, 100.0) + + assert arbiter.take(WEB, 101.0, preempt=True) is True + assert arbiter.owner == WEB + + +def test_taking_an_arm_you_already_hold_is_not_a_conflict(): + arbiter = build() + arbiter.take(WEB, 100.0) + + assert arbiter.take(WEB, 101.0) is True + + +def test_releasing_frees_the_arm(): + arbiter = build() + arbiter.take(WEB, 100.0) + + arbiter.release(WEB) + + assert arbiter.owner is None + + +def test_releasing_an_arm_somebody_else_holds_does_nothing(): + arbiter = build() + arbiter.take(GAMEPAD, 100.0) + + arbiter.release(WEB) + + assert arbiter.owner == GAMEPAD + + +def test_an_idle_gamepad_hands_the_arm_back(): + # A pad left on the bench would otherwise lock the browser out forever. + arbiter = build(idle_release_s=2.0) + arbiter.take(GAMEPAD, 100.0) + + arbiter.expire_idle_gamepad(102.0) + + assert arbiter.owner is None + + +def test_a_gamepad_still_being_used_keeps_the_arm(): + arbiter = build(idle_release_s=2.0) + arbiter.take(GAMEPAD, 100.0) + + arbiter.note_activity(GAMEPAD, 101.5) + arbiter.expire_idle_gamepad(102.0) + + assert arbiter.owner == GAMEPAD + + +def test_the_web_never_expires_on_its_own(): + # It took the arm by pressing a button and gives it back by pressing another. + arbiter = build(idle_release_s=2.0) + arbiter.take(WEB, 100.0) + + arbiter.expire_idle_gamepad(999.0) + + assert arbiter.owner == WEB + + +def test_activity_from_a_non_owner_does_not_extend_anyone_else(): + arbiter = build(idle_release_s=2.0) + arbiter.take(GAMEPAD, 100.0) + + arbiter.note_activity(WEB, 101.9) + arbiter.expire_idle_gamepad(102.0) + + assert arbiter.owner is None From 9f56b2b3341897fdcf9b76961a4d80da5c05c093 Mon Sep 17 00:00:00 2001 From: fondef Date: Tue, 15 Sep 2026 15:43:00 -0500 Subject: [PATCH 04/10] add the translation to and from the web console The console's telemetry keys are a contract: app.js reads arm_state, owner, estop, vacuum, freedrive, position and joints by name, so renaming one breaks the page silently. They are reproduced rather than improved for that reason, vacuum included, which is the console's name for whatever tool is bolted on. The two vocabularies do not line up and this is where they are reconciled. The arm node reports a driver's lifecycle; the console draws an operator's screen. The ordering in arm_state is the ordering of what an operator most needs to know: a latched e-stop outranks everything, because the arm may well read ready and saying so would invite somebody to push it. A message this node does not recognise is dropped rather than refused. The console and the node version independently, and a page from a newer build saying something we have not learned yet must not take the arm down. --- dora_teleop/ui.py | 170 ++++++++++++++++++++++++++++++++++++++++++++++ tests/test_ui.py | 138 +++++++++++++++++++++++++++++++++++++ 2 files changed, 308 insertions(+) create mode 100644 dora_teleop/ui.py create mode 100644 tests/test_ui.py diff --git a/dora_teleop/ui.py b/dora_teleop/ui.py new file mode 100644 index 0000000..c780779 --- /dev/null +++ b/dora_teleop/ui.py @@ -0,0 +1,170 @@ +"""Translation between the web console and the dataflow. + +The console was written against the monolithic repo's REST API and reads a +telemetry snapshot by key. Those keys are the contract here: `app.js` does +`t.owner === "web"` and `t.estop` directly, so renaming one breaks the page +silently. They are reproduced rather than improved for that reason. + +The two vocabularies do not line up, which is most of what this file is for. The +arm node reports `connecting|ready|moving|recovering|unavailable`; the console +draws `idle|moving|jogging|freedrive|estop|stopped|disconnected`. Neither is +wrong — one describes a driver's lifecycle, the other an operator's screen — and +:func:`arm_state` is where the two are reconciled. +""" + +from dataclasses import dataclass, field + +# What the arm node publishes on `robot_state`. +CONNECTING = "connecting" +READY = "ready" +MOVING = "moving" +RECOVERING = "recovering" +UNAVAILABLE = "unavailable" + +# What the console draws. +UI_IDLE = "idle" +UI_MOVING = "moving" +UI_JOGGING = "jogging" +UI_FREEDRIVE = "freedrive" +UI_ESTOP = "estop" +UI_STOPPED = "stopped" +UI_DISCONNECTED = "disconnected" + +# An arm the node cannot speak for is `disconnected` whatever the reason, because +# that is the only thing an operator can act on. `recovering` is its own case: +# the arm is there and answering, it just will not move yet. +ARM_STATE = { + CONNECTING: UI_DISCONNECTED, + UNAVAILABLE: UI_DISCONNECTED, + RECOVERING: UI_STOPPED, + READY: UI_IDLE, + MOVING: UI_MOVING, +} + +POSE_KEYS = ("x", "y", "z", "roll", "pitch", "yaw") + + +@dataclass +class Command: + """One thing the console asked for.""" + + type: str + velocity: list[float] = field(default_factory=list) + action: str = "" + preempt: bool = False + enabled: bool = False + row: dict = field(default_factory=dict) + + +def command_of(payload: dict) -> Command | None: + """Read one console message, or None if it is not one we serve. + + A message we do not recognise is dropped rather than refused. The console + and this node version independently, and a page from a newer build saying + something we have not learned yet must not take the arm down. + """ + if not isinstance(payload, dict): + return None + kind = payload.get("type") + if not isinstance(kind, str): + return None + + if kind == "jog": + velocity = payload.get("velocity") or [] + if len(velocity) != 6 or not all(isinstance(v, (int, float)) for v in velocity): + return None + return Command(type="jog", velocity=[float(v) for v in velocity]) + if kind in ("jog_stop", "estop", "reset", "home"): + return Command(type=kind) + if kind == "control": + return Command( + type="control", + action=str(payload.get("action", "")), + preempt=bool(payload.get("preempt", False)), + ) + if kind == "gripper": + return Command(type="gripper", enabled=bool(payload.get("vacuum", False))) + if kind == "freedrive": + return Command(type="freedrive", enabled=bool(payload.get("enabled", False))) + if kind == "move": + row = {key: payload[key] for key in POSE_KEYS if isinstance(payload.get(key), (int, float))} + if not all(key in row for key in ("x", "y", "z")): + return None + if isinstance(payload.get("speed"), (int, float)): + row["speed"] = float(payload["speed"]) + return Command(type="move", row=row) + return None + + +@dataclass +class ArmView: + """What this node currently believes about the arm. + + Assembled from four places — `robot_state`, `tcp_pose`, `joint_pose` and the + answers to `get_state` — because no single one of them carries everything + the console draws. Whatever has not arrived yet stays at its default, so a + page that loads before the arm is reachable renders as disconnected rather + than as a stale pose. + """ + + state: str = CONNECTING + pose: list[float] = field(default_factory=list) + joints: list[float] = field(default_factory=list) + connected: bool = False + ready: bool = False + tool_engaged: bool = False + freedrive_on: bool = False + estop: bool = False + + def take_get_state(self, row: dict) -> None: + """Absorb a `get_state` answer, which carries what telemetry does not.""" + self.connected = bool(row.get("connected", False)) + self.ready = bool(row.get("ready", False)) + self.tool_engaged = bool(row.get("tool_engaged", False)) + self.freedrive_on = bool(row.get("freedrive", False)) + + +def arm_state(view: ArmView, jogging: bool) -> str: + """Return the state the console should draw. + + Order matters, and it is the order of what an operator most needs to know. + A latched e-stop wins over everything: the arm may well read `ready`, and + saying so would invite somebody to push it. Freedrive outranks idle because + an arm that can be pushed by hand is not the same as one holding station. + """ + if view.estop: + return UI_ESTOP + if view.state in (CONNECTING, UNAVAILABLE): + return ARM_STATE[view.state] + if view.freedrive_on: + return UI_FREEDRIVE + if jogging and view.state == MOVING: + return UI_JOGGING + return ARM_STATE.get(view.state, UI_DISCONNECTED) + + +def snapshot(view: ArmView, owner: str | None, jogging: bool, timestamp: float) -> dict: + """Build the telemetry object the console reads. + + Every key here is read by name in `app.js`. `vacuum` is the console's name + for the mounted tool whatever it actually is, kept because renaming it would + mean editing the page for no gain. + """ + return { + "type": "telemetry", + "ts": timestamp, + "connected": view.connected, + "ready": view.ready, + "arm_state": arm_state(view, jogging), + "owner": owner or "none", + "estop": view.estop, + "vacuum": view.tool_engaged, + "freedrive": view.freedrive_on, + "position": dict(zip(POSE_KEYS, view.pose, strict=False)) if view.pose else None, + "joints": list(view.joints), + } + + +def error(message: str) -> dict: + """Build the message the console shows as a toast.""" + return {"type": "error", "detail": message} diff --git a/tests/test_ui.py b/tests/test_ui.py new file mode 100644 index 0000000..ee6c5c8 --- /dev/null +++ b/tests/test_ui.py @@ -0,0 +1,138 @@ +"""Translation both ways: what the console asks for, and what it is shown.""" + +from dora_teleop import ui +from dora_teleop.arbiter import GAMEPAD, WEB + +# --- what the console asks for --- + + +def test_a_jog_carries_its_six_numbers(): + command = ui.command_of({"type": "jog", "velocity": [1, 2, 3, 4, 5, 6]}) + + assert command.type == "jog" + assert command.velocity == [1.0, 2.0, 3.0, 4.0, 5.0, 6.0] + + +def test_a_jog_of_the_wrong_length_is_not_a_jog(): + assert ui.command_of({"type": "jog", "velocity": [1, 2, 3]}) is None + + +def test_a_jog_carrying_something_that_is_not_a_number_is_not_a_jog(): + assert ui.command_of({"type": "jog", "velocity": [1, 2, 3, 4, 5, "fast"]}) is None + + +def test_a_message_we_have_never_heard_of_is_dropped_not_refused(): + # The console and this node version independently; a page from a newer build + # must not take the arm down by saying something we have not learned yet. + assert ui.command_of({"type": "hologram"}) is None + assert ui.command_of({}) is None + assert ui.command_of("not even an object") is None + + +def test_taking_control_carries_whether_it_may_preempt(): + command = ui.command_of({"type": "control", "action": "take", "preempt": True}) + + assert (command.action, command.preempt) == ("take", True) + + +def test_the_gripper_button_says_which_way(): + assert ui.command_of({"type": "gripper", "vacuum": True}).enabled is True + assert ui.command_of({"type": "gripper", "vacuum": False}).enabled is False + + +def test_a_move_needs_a_position(): + assert ui.command_of({"type": "move", "x": 1.0, "y": 2.0}) is None + + command = ui.command_of({"type": "move", "x": 250.0, "y": 0.0, "z": 200.0, "speed": 40}) + + assert command.row == {"x": 250.0, "y": 0.0, "z": 200.0, "speed": 40.0} + + +# --- what the console is shown --- + + +def test_an_arm_that_cannot_be_reached_reads_as_disconnected(): + view = ui.ArmView(state=ui.UNAVAILABLE) + + assert ui.arm_state(view, jogging=False) == ui.UI_DISCONNECTED + + +def test_an_arm_still_starting_up_reads_as_disconnected(): + view = ui.ArmView(state=ui.CONNECTING) + + assert ui.arm_state(view, jogging=False) == ui.UI_DISCONNECTED + + +def test_a_latched_estop_outranks_everything_else(): + # The arm may well read ready, and saying so would invite somebody to push it. + view = ui.ArmView(state=ui.READY, estop=True, freedrive_on=True) + + assert ui.arm_state(view, jogging=True) == ui.UI_ESTOP + + +def test_freedrive_outranks_idle(): + # An arm that can be pushed by hand is not one holding station. + view = ui.ArmView(state=ui.READY, freedrive_on=True) + + assert ui.arm_state(view, jogging=False) == ui.UI_FREEDRIVE + + +def test_a_moving_arm_we_are_jogging_reads_as_jogging(): + view = ui.ArmView(state=ui.MOVING) + + assert ui.arm_state(view, jogging=True) == ui.UI_JOGGING + assert ui.arm_state(view, jogging=False) == ui.UI_MOVING + + +def test_a_recovering_arm_is_there_but_will_not_move(): + view = ui.ArmView(state=ui.RECOVERING) + + assert ui.arm_state(view, jogging=False) == ui.UI_STOPPED + + +def test_a_state_we_do_not_know_reads_as_disconnected_rather_than_as_ready(): + view = ui.ArmView(state="dancing") + + assert ui.arm_state(view, jogging=False) == ui.UI_DISCONNECTED + + +def test_the_snapshot_carries_every_key_the_page_reads_by_name(): + view = ui.ArmView(state=ui.READY, pose=[1, 2, 3, 4, 5, 6], joints=[0] * 6, connected=True) + + snapshot = ui.snapshot(view, WEB, jogging=False, timestamp=123.0) + + for key in ( + "type", + "ts", + "connected", + "ready", + "arm_state", + "owner", + "estop", + "vacuum", + "freedrive", + "position", + "joints", + ): + assert key in snapshot + assert snapshot["position"] == {"x": 1, "y": 2, "z": 3, "roll": 4, "pitch": 5, "yaw": 6} + + +def test_an_arm_with_no_pose_yet_reports_none_rather_than_a_stale_one(): + snapshot = ui.snapshot(ui.ArmView(), None, jogging=False, timestamp=0.0) + + assert snapshot["position"] is None + assert snapshot["owner"] == "none" + + +def test_the_owner_is_named_so_the_page_can_gate_its_jog_pad(): + assert ui.snapshot(ui.ArmView(), GAMEPAD, False, 0.0)["owner"] == "gamepad" + assert ui.snapshot(ui.ArmView(), WEB, False, 0.0)["owner"] == "web" + + +def test_get_state_fills_in_what_telemetry_does_not_carry(): + view = ui.ArmView() + + view.take_get_state({"connected": True, "ready": True, "tool_engaged": True, "freedrive": True}) + + assert (view.connected, view.ready, view.tool_engaged, view.freedrive_on) == (True, True, True, True) From 56b86699bd1a38855afb8183b0a932c60591132b Mon Sep 17 00:00:00 2001 From: fondef Date: Tue, 15 Sep 2026 15:43:06 -0500 Subject: [PATCH 05/10] add the event loop and the deadman MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loop owns the one decision this node exists to make: whether a velocity reaches the arm this tick, and from whom. An arm under a velocity command keeps moving until told otherwise, so the question is never whether to start but whether anything has happened that means it must stop. Every path through _wanted_velocity that is not an owner actively asking for motion returns a stop, so a case nobody thought of fails by halting rather than by carrying on. The default has to be still. Three ways a source goes away, all ending in the same place. It goes quiet, and the watchdog fires after JOG_TTL_S. It goes away outright — the pad unplugged, the last browser gone — and the arm stops at once, without waiting out the timer, because an unplugged pad is not a centred pad. Or the process itself goes away, and one all-zero velocity is sent before it does: a motion lives in the controller, not here. The stop is sent once, not every tick. A stream of zeroes would keep the arm node believing a jog is live and hold robot_state at moving forever. E-STOP is not gated on the lease, from either source. Somebody reaching for the stop is the one moment when asking who owns the arm is the wrong question. --- dora_teleop/main.py | 365 ++++++++++++++++++++++++++++++++++++++++++ tests/test_deadman.py | 215 +++++++++++++++++++++++++ 2 files changed, 580 insertions(+) create mode 100644 dora_teleop/main.py create mode 100644 tests/test_deadman.py diff --git a/dora_teleop/main.py b/dora_teleop/main.py new file mode 100644 index 0000000..8062b76 --- /dev/null +++ b/dora_teleop/main.py @@ -0,0 +1,365 @@ +"""The dora event loop: routing, the deadman, and nothing that can block. + +The loop owns the one decision this node exists to make — whether a velocity +reaches the arm this tick, and from whom. Everything else is delegated: +:mod:`dora_teleop.gamepad` owns the hardware, :mod:`dora_teleop.mapping` turns a +reading into a velocity, :mod:`dora_teleop.arbiter` says who is allowed to, and +:mod:`dora_teleop.ui` speaks the console's language. + +**The safety-critical part is `_drive`.** An arm moving under a velocity command +keeps moving until told otherwise, so the question is never "should it start" +but "has anything happened that means it must stop". Three things mean that: the +source went quiet, the source went away, or this process is going away. All +three end in the same place — one all-zero velocity, sent once. +""" + +import argparse +import json +import time + +import pyarrow as pa +from dora import Node + +from dora_teleop import mapping, ui +from dora_teleop.arbiter import GAMEPAD, WEB, Arbiter +from dora_teleop.config import Config +from dora_teleop.gamepad import Gamepad, now +from dora_teleop.log import log +from dora_teleop.mapping import LAYOUT, pressed + +REQUEST_ID = "request_id" +STOP_VELOCITY = [0.0] * 6 + + +class Teleop: + """Everything the loop remembers between ticks.""" + + def __init__(self, config: Config): + self.config = config + self.arbiter = Arbiter(config.deadman.gamepad_idle_release_s) + self.view = ui.ArmView() + # The velocity the browser last asked for, and when. A jog is a stream; + # what matters is whether the stream is still arriving. + self.web_velocity = STOP_VELOCITY + self.web_seen_at = 0.0 + self.clients = 0 + # Whether the arm is currently being driven by us. Used to send the stop + # once rather than every tick: a stream of zeroes would keep the arm + # node believing a jog is live and hold `robot_state` at `moving`. + self.jogging = False + self.buttons: list[bool] = [] + self.requests = 0 + self.polled_at = 0.0 + + def next_request_id(self, service: str) -> str: + """Mint a correlation id we will recognise coming back.""" + self.requests += 1 + return f"teleop-{service}-{self.requests}" + + +def main() -> None: + """Connect to the dataflow, start reading the pad, and run until dora stops us.""" + args = _parse_args() + config = Config.from_env() + + # Passing the name lets the node also run standalone as a dynamic node; when + # dora spawns it, the id from the dataflow wins. + node = Node(args.name) + state = Teleop(config) + gamepad = Gamepad(config.joystick_index) + gamepad.start() + + try: + for event in node: + if event["type"] == "ERROR": + raise RuntimeError(event["error"]) + if event["type"] != "INPUT": + continue + _handle(node, state, gamepad, event) + finally: + # A motion lives in the arm's controller, not in this process. Leaving + # without zeroing the velocity leaves it travelling until the + # controller's own expiry catches up, which is the one moment nobody is + # watching for it. + _send_jog(node, STOP_VELOCITY) + gamepad.shutdown() + + +def _parse_args() -> argparse.Namespace: + """Read the node name, for when dora did not spawn us.""" + parser = argparse.ArgumentParser(description="Drive a robot arm from a gamepad or a browser.") + parser.add_argument( + "--name", + default="teleop", + help="The name of the node in the dataflow, used when running as a dynamic node.", + ) + return parser.parse_args() + + +def _handle(node: Node, state: Teleop, gamepad: Gamepad, event: dict) -> None: + """Route one input to whatever deals with it.""" + event_id = event["id"] + if event_id == "tick": + _tick(node, state, gamepad) + elif event_id == "ui_command": + _accept_ui_command(node, state, event) + elif event_id == "clients": + _accept_clients(state, event) + elif event_id == "robot_state": + state.view.state = event["value"][0].as_py() + elif event_id == "tcp_pose": + state.view.pose = event["value"].to_pylist() + elif event_id == "joint_pose": + state.view.joints = event["value"].to_pylist() + elif event_id == "response": + _accept_response(node, state, event) + elif event_id == "motion_state": + _accept_motion_state(node, state, event) + else: + log(f"ignoring unexpected input `{event_id}`") + + +# --- the tick, where the arm is actually driven --- + + +def _tick(node: Node, state: Teleop, gamepad: Gamepad) -> None: + """Decide what the arm should be doing, and say so.""" + moment = now() + sample = gamepad.read() + + _read_buttons(node, state, sample) + + # A pad taking the arm the moment a stick moves is what keeps an operator + # from having to reach for a browser to use the thing in their hands. + if sample.active and state.arbiter.owner is None: + state.arbiter.take(GAMEPAD, moment) + if sample.active: + state.arbiter.note_activity(GAMEPAD, moment) + state.arbiter.expire_idle_gamepad(moment) + + _drive(node, state, sample, moment) + _poll_state(node, state, moment) + _publish(node, state) + + +def _drive(node: Node, state: Teleop, sample, moment: float) -> None: + """Send the velocity the owner is asking for, or the stop that ends it. + + A velocity is only re-sent while it is non-zero: the arm node treats a + stream of jogs as one continuous one, and the re-send exists so the + controller's own expiry never fires mid-motion. + """ + velocity = _wanted_velocity(state, sample, moment) + + if mapping.is_moving(velocity): + state.jogging = True + _send_jog(node, velocity) + return + + if state.jogging: + # Once, not every tick. Repeating it would keep the arm node believing a + # jog is live and hold `robot_state` at `moving` forever. + state.jogging = False + _send_jog(node, STOP_VELOCITY) + + +def _wanted_velocity(state: Teleop, sample, moment: float) -> list[float]: + """Return what the arm should be doing, which is usually nothing. + + Every path that is not an owner actively asking for motion returns a stop. + That is deliberate: the default has to be "still", so that a case nobody + thought of fails by halting rather than by carrying on. + """ + owner = state.arbiter.owner + if owner == GAMEPAD: + # An unplugged pad is not a centred pad. Stopping here rather than + # waiting out the TTL is the difference between a dropped controller + # costing 10 ms of travel and costing 400. + if not sample.present: + return STOP_VELOCITY + return mapping.velocity(sample.axes, sample.buttons, state.config) + + if owner == WEB: + # The console refreshes its jog every 150 ms; the browser going quiet, + # or the tab closing, has to stop the arm without anything being sent. + if state.clients == 0: + return STOP_VELOCITY + if moment - state.web_seen_at > state.config.deadman.jog_ttl_s: + return STOP_VELOCITY + return state.web_velocity + + return STOP_VELOCITY + + +def _read_buttons(node: Node, state: Teleop, sample) -> None: + """Fire the services a gamepad button asks for, on the press only. + + Edge-triggered, because these are one-shot requests rather than a held + velocity: holding `grasp` should not send a request every 50 ms. + """ + previous, state.buttons = state.buttons, list(sample.buttons) + if not sample.present: + return + + def tapped(index: int) -> bool: + return pressed(sample.buttons, index) and not pressed(previous, index) + + # The stop is not gated on the lease. Somebody reaching for it is the one + # moment when asking who owns the arm is the wrong question. + if tapped(LAYOUT.estop): + _estop(node, state) + if not state.arbiter.may_drive(GAMEPAD): + return + if tapped(LAYOUT.grasp): + _request(node, state, {"service": "grasp"}) + if tapped(LAYOUT.release): + _request(node, state, {"service": "release"}) + if tapped(LAYOUT.clear): + _request(node, state, {"service": "clear"}) + if tapped(LAYOUT.freedrive): + _request(node, state, {"service": "freedrive", "enabled": not state.view.freedrive_on}) + + +def _poll_state(node: Node, state: Teleop, moment: float) -> None: + """Ask the arm for what telemetry does not carry. + + Whether the tool is engaged and whether freedrive is on only come back from + `get_state`, which the arm node answers off its report cache and so can + answer during a motion. + """ + if moment - state.polled_at < state.config.deadman.state_poll_s: + return + state.polled_at = moment + _request(node, state, {"service": "get_state"}) + + +# --- what the console asks for --- + + +def _accept_ui_command(node: Node, state: Teleop, event: dict) -> None: + """Take one console message and do what it says.""" + raw = event["value"][0].as_py() if len(event["value"]) else None + if raw is None: + return + try: + payload = json.loads(raw) if isinstance(raw, str) else raw + except json.JSONDecodeError: + log("ignoring a console message that was not JSON") + return + + command = ui.command_of(payload) + if command is None: + return + moment = now() + + if command.type == "estop": + _estop(node, state) + return + if command.type == "control": + _control(node, state, command, moment) + return + if command.type == "reset": + # Clearing the latch is this node's business; clearing the controller's + # error is the arm's. Both, in that order, or the console's RESET button + # leaves one of the two still saying stop. + state.view.estop = False + _request(node, state, {"service": "clear"}) + return + + if not state.arbiter.may_drive(WEB): + _post_ui(node, ui.error("take control first")) + return + + if command.type == "jog": + state.web_velocity = command.velocity + state.web_seen_at = moment + state.arbiter.note_activity(WEB, moment) + elif command.type == "jog_stop": + state.web_velocity = STOP_VELOCITY + state.web_seen_at = moment + elif command.type == "home": + _request(node, state, {"service": "home", "wait": False}) + elif command.type == "gripper": + _request(node, state, {"service": "grasp" if command.enabled else "release"}) + elif command.type == "freedrive": + _request(node, state, {"service": "freedrive", "enabled": command.enabled}) + elif command.type == "move": + _request(node, state, {"service": "move_to", "wait": False, **command.row}) + + +def _control(node: Node, state: Teleop, command, moment: float) -> None: + """Hand the arm to the console, or take it back.""" + if command.action == "release": + state.arbiter.release(WEB) + state.web_velocity = STOP_VELOCITY + return + if not state.arbiter.take(WEB, moment, preempt=command.preempt): + _post_ui(node, ui.error(f"the {state.arbiter.owner} has the arm")) + + +def _accept_clients(state: Teleop, event: dict) -> None: + """Track who is watching, because the last one leaving has to stop the arm.""" + row = event["value"][0].as_py() + state.clients = int(row.get("connected", 0)) + if state.clients == 0: + state.arbiter.release(WEB) + state.web_velocity = STOP_VELOCITY + + +# --- what the arm says back --- + + +def _accept_response(node: Node, state: Teleop, event: dict) -> None: + """Absorb one answer, and tell the console about it if it was bad news.""" + row = event["value"][0].as_py() + if row.get("service") == "get_state": + state.view.take_get_state(row) + return + if not row.get("ok", True): + message = row.get("message", "") + log(f"{row.get('service')} failed: {message}") + _post_ui(node, ui.error(message)) + + +def _accept_motion_state(node: Node, state: Teleop, event: dict) -> None: + """Report a motion that failed, which a non-blocking request learns no other way.""" + row = event["value"][0].as_py() + if row.get("state") == "failed": + _post_ui(node, ui.error(row.get("message", "the motion failed"))) + + +# --- talking to the dataflow --- + + +def _estop(node: Node, state: Teleop) -> None: + """Halt the arm, from any source, held lease or not.""" + log("e-stop") + state.view.estop = True + state.jogging = False + state.web_velocity = STOP_VELOCITY + _send_jog(node, STOP_VELOCITY) + _request(node, state, {"service": "stop"}) + + +def _send_jog(node: Node, velocity: list[float]) -> None: + """Send one velocity to the arm. There is no answer to a jog, ever.""" + node.send_output("jog", pa.array([{"velocity": [float(v) for v in velocity]}])) + + +def _request(node: Node, state: Teleop, row: dict) -> None: + """Call one service on the arm under an id we will recognise coming back.""" + node.send_output("request", pa.array([row]), metadata={REQUEST_ID: state.next_request_id(row["service"])}) + + +def _publish(node: Node, state: Teleop) -> None: + """Send the console its picture of the arm.""" + _post_ui(node, ui.snapshot(state.view, state.arbiter.owner, state.jogging, time.time())) + + +def _post_ui(node: Node, payload: dict) -> None: + """Hand one object to the bridge, which broadcasts it to every browser.""" + node.send_output("ui_state", pa.array([json.dumps(payload)]), metadata={"encoding": "json"}) + + +if __name__ == "__main__": + main() diff --git a/tests/test_deadman.py b/tests/test_deadman.py new file mode 100644 index 0000000..9e5054a --- /dev/null +++ b/tests/test_deadman.py @@ -0,0 +1,215 @@ +"""The deadman: every way a source can go away, and the stop that follows it.""" + +import json + +import pyarrow as pa +import pytest + +from dora_teleop import main as loop +from dora_teleop.arbiter import GAMEPAD, WEB +from dora_teleop.config import Config +from dora_teleop.gamepad import Sample +from tests.conftest import FakeGamepad, RecordingNode, input_event, stick + +STOP = [0.0] * 6 +PUSHED = [0.0, -1.0] + + +@pytest.fixture +def clock(monkeypatch): + """Return a clock the test moves by hand, so no test waits out a real timeout.""" + + class Clock: + def __init__(self): + self.t = 1000.0 + + def advance(self, seconds): + self.t += seconds + + moved = Clock() + monkeypatch.setattr("dora_teleop.main.now", lambda: moved.t) + return moved + + +def build(): + config = Config.from_env() + config.expo = 0.0 + return RecordingNode(), loop.Teleop(config), FakeGamepad() + + +def jog_from(node, state, gamepad, count=1): + """Run `count` ticks and return every velocity that reached the arm.""" + for _ in range(count): + loop._tick(node, state, gamepad) + return node.jogs() + + +def web_jog(node, state, velocity): + """Deliver one console jog message.""" + payload = json.dumps({"type": "jog", "velocity": velocity}) + loop._accept_ui_command(node, state, input_event("ui_command", pa.array([payload]))) + + +# --- the gamepad --- + + +def test_a_pushed_stick_takes_the_arm_and_drives_it(clock): + # Nobody should have to reach for a browser to use the pad in their hands. + node, state, gamepad = build() + gamepad.sample = stick(axes=PUSHED) + + jogs = jog_from(node, state, gamepad) + + assert state.arbiter.owner == GAMEPAD + assert jogs[-1][0] > 0 + + +def test_a_gamepad_that_is_unplugged_mid_jog_stops_the_arm(clock): + # An unplugged pad is not a centred pad, and waiting out the TTL here is the + # difference between a dropped controller costing 10 ms of travel and 400. + node, state, gamepad = build() + gamepad.sample = stick(axes=PUSHED) + jog_from(node, state, gamepad) + node.clear() + + gamepad.sample = Sample(present=False) + jogs = jog_from(node, state, gamepad) + + assert jogs == [STOP] + + +def test_a_stick_returning_to_centre_stops_the_arm(clock): + node, state, gamepad = build() + gamepad.sample = stick(axes=PUSHED) + jog_from(node, state, gamepad) + node.clear() + + gamepad.sample = stick() + jogs = jog_from(node, state, gamepad) + + assert jogs == [STOP] + + +def test_a_stick_at_rest_sends_one_zero_not_a_stream(clock): + # A stream of zeroes would keep the arm node believing a jog is live and + # hold robot_state at `moving` forever. + node, state, gamepad = build() + gamepad.sample = stick(axes=PUSHED) + jog_from(node, state, gamepad) + node.clear() + + gamepad.sample = stick() + jogs = jog_from(node, state, gamepad, count=5) + + assert jogs == [STOP] + + +def test_a_held_stick_is_resent_every_tick(clock): + # The arm's controller expires a velocity on its own; the re-send is what + # keeps that backstop from firing mid-motion. + node, state, gamepad = build() + gamepad.sample = stick(axes=PUSHED) + + jogs = jog_from(node, state, gamepad, count=4) + + assert len(jogs) == 4 + assert all(jog[0] > 0 for jog in jogs) + + +# --- the browser --- + + +def test_the_last_browser_leaving_stops_the_arm(clock): + # Closing the tab has to stop the arm, and nothing is sent when it does. + node, state, gamepad = build() + state.clients = 1 + state.arbiter.take(WEB, clock.t) + web_jog(node, state, [10.0, 0, 0, 0, 0, 0]) + jog_from(node, state, gamepad) + node.clear() + + loop._accept_clients(state, input_event("clients", pa.array([{"connected": 0, "event": "close"}]))) + jogs = jog_from(node, state, gamepad) + + assert jogs == [STOP] + assert state.arbiter.owner is None + + +def test_a_browser_that_goes_quiet_stops_the_arm(clock): + # The console refreshes every 150 ms against a 400 ms deadline. + node, state, gamepad = build() + state.clients = 1 + state.arbiter.take(WEB, clock.t) + web_jog(node, state, [10.0, 0, 0, 0, 0, 0]) + jog_from(node, state, gamepad) + node.clear() + + clock.advance(0.5) + jogs = jog_from(node, state, gamepad) + + assert jogs == [STOP] + + +def test_a_browser_that_keeps_refreshing_keeps_the_arm_moving(clock): + node, state, gamepad = build() + state.clients = 1 + state.arbiter.take(WEB, clock.t) + + sent = [] + for _ in range(4): + clock.advance(0.15) + web_jog(node, state, [10.0, 0, 0, 0, 0, 0]) + loop._tick(node, state, gamepad) + sent = node.jogs() + + assert all(jog[0] == pytest.approx(10.0) for jog in sent) + + +def test_a_non_owner_jog_is_ignored(clock): + node, state, gamepad = build() + state.clients = 1 + state.arbiter.take(GAMEPAD, clock.t) + + web_jog(node, state, [10.0, 0, 0, 0, 0, 0]) + loop._tick(node, state, gamepad) + + assert node.jogs() == [] + assert any(message.get("type") == "error" for message in node.ui()) + + +# --- the stop that is never gated --- + + +def test_estop_is_never_gated_by_the_lease(clock): + # Somebody reaching for the stop is the one moment when asking who owns the + # arm is the wrong question. + node, state, _ = build() + state.clients = 1 + state.arbiter.take(GAMEPAD, clock.t) + + payload = json.dumps({"type": "estop"}) + loop._accept_ui_command(node, state, input_event("ui_command", pa.array([payload]))) + + assert node.jogs() == [STOP] + assert "stop" in node.services() + + +def test_the_estop_button_works_without_the_pad_holding_the_arm(clock): + node, state, gamepad = build() + state.arbiter.take(WEB, clock.t) + gamepad.sample = stick(buttons=[False] * 6 + [True]) + + loop._tick(node, state, gamepad) + + assert "stop" in node.services() + + +def test_a_latched_estop_is_cleared_by_reset(clock): + node, state, _ = build() + state.view.estop = True + + payload = json.dumps({"type": "reset"}) + loop._accept_ui_command(node, state, input_event("ui_command", pa.array([payload]))) + + assert state.view.estop is False + assert "clear" in node.services() From 7250396a1f9c6d9265be64cda7800e732fa8b0a5 Mon Sep 17 00:00:00 2001 From: fondef Date: Tue, 15 Sep 2026 15:43:12 -0500 Subject: [PATCH 06/10] add the node manifest, dataflow, CI and docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dora-node.yml is the typed contract a consumer wires against, and the only one they get: no source package is shared between node repositories. The jog, request and ui ports stay untyped, because dora treats a port carrying request_id as pattern-correlated and several Arrow schemas legitimately flow over each — one per service, one per console message. dataflow.yml runs the whole operator stack from checkouts: this node, the WebSocket bridge and the arm. web/ holds a placeholder page, not the real console. It exists so the dataflow has something to serve and so the node can be checked end to end before the Control section is ported across from the monolithic repo. It reads telemetry and can take, release and stop; nothing else. --- .gitea/workflows/validate.yml | 58 ++++++++++++ AGENTS.md | 138 +++++++++++++++++++++++++++ README.md | 151 +++++++++++++++++++++++++++++ dataflow.yml | 65 +++++++++++++ dora-node.yml | 172 ++++++++++++++++++++++++++++++++++ web/index.html | 85 +++++++++++++++++ 6 files changed, 669 insertions(+) create mode 100644 .gitea/workflows/validate.yml create mode 100644 AGENTS.md create mode 100644 dataflow.yml create mode 100644 dora-node.yml create mode 100644 web/index.html diff --git a/.gitea/workflows/validate.yml b/.gitea/workflows/validate.yml new file mode 100644 index 0000000..60d4846 --- /dev/null +++ b/.gitea/workflows/validate.yml @@ -0,0 +1,58 @@ +# A pull request would otherwise run this twice on the same commit: once for the +# branch push and once for the PR. + +name: validate + +on: + push: + branches: [main] + pull_request: + +env: + DORA_VERSION: v1.0.1 + DORA_TARGET: x86_64-unknown-linux-gnu + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install the dora CLI + run: | + curl -fsSL -o dora-cli.tar.gz \ + "https://github.com/dora-rs/dora/releases/download/${DORA_VERSION}/dora-cli-${DORA_TARGET}.tar.gz" + tar xzf dora-cli.tar.gz + mkdir -p "$HOME/.local/bin" + install -m 755 "dora-cli-${DORA_TARGET}/dora" "$HOME/.local/bin/dora" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + rm -rf dora-cli.tar.gz "dora-cli-${DORA_TARGET}" + + - run: dora --version + + # strict_types: true in the dataflow turns a type warning into a non-zero + # exit, so a mismatched annotation fails here rather than at runtime. + - name: Validate the dataflow + run: dora validate dataflow.yml + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + # --locked fails if uv.lock is out of date with pyproject.toml, so a + # dependency change that was never locked cannot reach main. + - run: uv sync --locked --group dev + - run: uv run ruff check . + - run: uv run ruff format --check . + - run: uv run pytest -q + + markdownlint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: DavidAnson/markdownlint-cli2-action@v16 + with: + globs: "*.md" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3b81bad --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,138 @@ +# AGENTS.md + +This file provides guidance to AI coding assistants when working with code in this repository. + +## What this is + +A dora-rs Python node that gives a human control of a robot arm, from a gamepad or from a +web browser, and decides which of the two is driving. + +It is **the piece in the middle and nothing else**. It contains no robot driver — the arm +is [`dora-ulite6`](https://gitea.liria.cl/liria/dora-ulite6), reached through that node's +ordinary `jog` and `request` ports. It contains no web server — the browser is carried by +[`dora-websocket-transceiver`](https://gitea.liria.cl/liria/dora-websocket-transceiver). +Do not add either here. A teleop node that grew its own HTTP server is exactly the +monolith `dora-ulite6` was rewritten to get out of. + +The web console it serves is ported from `Littlehand/monolitic-littlehand-python` +(`littlehand/console/static/`), and **only its Control section**. Perception, calibration, +tasks, chat, ROI, settings and health belong to other nodes; porting them here would +rebuild the monolith inside a node. + +## Build + +```bash +uv sync # runtime deps +uv sync --group dev # plus pytest and ruff +``` + +## Run + +```bash +dora run dataflow.yml --uv +``` + +`dataflow.yml` runs the whole stack — this node, the bridge and the arm — from checkouts +expected next to this one. + +## Test + +```bash +uv run pytest # 66 tests, no gamepad, arm, browser or daemon needed +uv run ruff check . +uv run ruff format --check . +``` + +## Architecture + +Two things carry the design: **what may touch the hardware**, and **what must happen when +a source goes away**. + +- **`dora_teleop/main.py`** — The dora event loop, and the one decision this node exists + to make: whether a velocity reaches the arm this tick, and from whom. **It never + blocks.** `_drive` and `_wanted_velocity` are the safety-critical pair — every path that + is not an owner actively asking for motion returns a stop, so a case nobody thought of + fails by halting rather than by carrying on. Keep it that way: the default has to be + "still". +- **`dora_teleop/gamepad.py`** — The pad, on a thread of its own. **Nothing outside this + file may touch pygame.** Everything else sees a plain immutable `Sample`, which is what + lets the rest be tested with no hardware and keeps SDL's quirks out of the arm logic. + `Sample.present` is the load-bearing field: an unplugged pad reads as *absent*, not as + centred, so "the operator let go" and "the pad fell off the desk" stay distinguishable. +- **`dora_teleop/mapping.py`** — Stick deflection to base-frame velocity. Pure functions, + no state, no hardware, because this is the part that has to be right and the cheapest to + check. Output is always the arm node's units: mm/s and deg/s, base frame. +- **`dora_teleop/arbiter.py`** — The lease. One place that answers "why did it not move", + rather than one answer per input path. Ported from the monolithic repo's `RobotService` + lease because the console's `app.js` is already built on it — it gates its jog pad on + `t.owner === "web"` and draws an owner chip. A simpler last-writer-wins scheme would mean + rewriting the page to match the node rather than the other way round. +- **`dora_teleop/ui.py`** — Translation both ways. The console's telemetry keys are a + contract: `app.js` reads `arm_state`, `owner`, `estop`, `vacuum`, `freedrive`, + `position`, `joints` by name, so renaming one breaks the page silently. `arm_state()` is + where the arm's vocabulary (`connecting|ready|moving|recovering|unavailable`) is + reconciled with the console's (`idle|moving|jogging|freedrive|estop|stopped|disconnected`). +- **`dora_teleop/config.py`** — Every environment variable, parsed once at startup, so an + unusable setting stops the node before it joins the dataflow instead of surfacing the + first time somebody picks up the pad. Errors name the variable. +- **`dora_teleop/log.py`** — Timestamped stderr logging. stderr because dora reads a + dynamic node's stdout. + +## Rules that are not negotiable + +- **The stop is sent once, not every tick.** A stream of zero velocities keeps + `dora-ulite6` believing a jog is live and holds `robot_state` at `moving` forever. +- **E-STOP is never gated on the lease.** From either source, at any time. +- **`JOG_TTL_S` must stay below the arm node's `VELOCITY_COMMAND_TTL_S`** (0.5 s). The + controller-side expiry is the backstop; a backstop that fires first backs up nothing. +- **An unplugged pad or a departed browser stops the arm immediately**, without waiting out + any timer. +- **Do not reintroduce a web server or a robot driver here.** See above. + +## What this node cannot do + +The lease binds only what goes through this node. `dora-ulite6` has no concept of an owner, +so another node publishing `request` will preempt an operator mid-motion and nothing here +can prevent it. Say so rather than implying otherwise. + +`jog` has no feedback channel at all — a malformed one is logged and dropped by the arm +node, with no response and no error output. What this node sends, it learns nothing back +about except through `robot_state` and telemetry. + +## Two manifest rules worth knowing + +`dora-node.yml` is the typed contract a consumer wires against, and the only one they get: +no source package is shared between node repositories. `dora validate` checks a dataflow +against it without running the node, so CI needs neither a gamepad nor a robot. + +`jog`, `request`, `response`, `motion_state` and the `ui_` ports are deliberately +**untyped**. dora treats a port carrying `request_id` as pattern-correlated and skips type +checks on it, and several Arrow schemas legitimately flow over each. Annotating them would +claim a contract neither side keeps. + +## Key APIs + +- **dora Python API** (`dora`): `Node()`, `node.send_output(id, array, metadata)`, event + iteration via `for event in node`. +- **pygame-ce** (`pygame.joystick`): chosen over evdev because Littlehand targets Linux + *and* macOS and evdev is Linux-only, and because it ships prebuilt wheels for + linux-x86_64, linux-aarch64 and macos-arm64 — nothing here compiles from source. Only + the display and joystick subsystems are initialised: `pygame.init()` also opens audio, + which on a headless robot fills stderr with ALSA errors for a device never used. The + display is needed despite there being no window, because SDL refreshes joystick state + during the event pump and the pump needs it. + +## Tools + +- **Issue tracker:** Gitea. Use the Gitea MCP tools (`mcp__gitea__*`) to read and manage + issues, pull requests, and other repository operations. + +## Code style + +- Write docstrings in NumPy style (summary line, then `Parameters`, `Returns`, `Raises` + sections as needed). +- Run `ruff check .` and `ruff format .` before committing. CI enforces both. + +## Rules + +- Do not mention AI tools in commit messages or documentation. diff --git a/README.md b/README.md index 9116a3a..e05b9bd 100644 --- a/README.md +++ b/README.md @@ -1 +1,152 @@ # dora-teleop + +A dora-rs node that hands a robot arm to a human. Pick up a gamepad and move the arm, or +open a browser and move it from there — one of the two at a time, decided here. + +It drives [`dora-ulite6`](https://gitea.liria.cl/liria/dora-ulite6) through that node's +ordinary `jog` and `request` ports, and talks to the browser through +[`dora-websocket-transceiver`](https://gitea.liria.cl/liria/dora-websocket-transceiver). +It contains no web server and no robot driver: it is the piece in the middle that decides +what the arm should be doing, and that decision is the only thing it owns. + +## The gamepad + +Analog and blended — several axes at once, and how fast depends on how far you push. That +is the whole reason a pad beats the web console's four-sector dial pad, which can only ask +for one axis at a time at one fixed speed. + +| Control | Does | Units | +|-------------|-------------------------------------------|-------| +| Left stick | Move along X (up/down) and Y (left/right) | mm/s | +| Triggers | Lift on the right, lower on the left | mm/s | +| Right stick | Roll (left/right) and pitch (up/down) | deg/s | +| Bumpers | Yaw, left and right | deg/s | +| A | Close the tool | — | +| B | Open the tool | — | +| X | Clear a controller error | — | +| Y | Toggle freedrive | — | +| Back | **E-STOP** | — | + +Up on the stick sends the arm away from you, along **+X** in the robot's base frame — +the same convention the web console's dial uses, so the two agree. + +Indices are SDL's, which is what most Xbox- and PlayStation-style pads report. A pad that +disagrees is a change to `Layout` in `dora_teleop/mapping.py` rather than a new +environment variable: an index nobody can verify from outside is worse than a line of code +somebody can read. + +`home` is deliberately **not** on the pad. It is a large unattended motion and belongs +behind a deliberate click, not a thumb resting on a face button. + +## The lease + +Two sources, one arm. Whoever holds the lease drives; the other is ignored. + +- The **gamepad takes it implicitly**, the first time a stick moves, if nobody holds it. + Nobody should have to reach for a browser to use the thing in their hands. +- It **gives it back** after `GAMEPAD_IDLE_RELEASE_S` untouched, so a pad left on the + bench does not lock the browser out forever. +- The **browser takes it explicitly**, with the console's TAKE CONTROL button, and gives + it back with RELEASE. It can preempt a held arm, because an operator who can see the arm + is better placed than this code to decide they should have it. +- **E-STOP is never gated.** From either source, held lease or not. Somebody reaching for + the stop is the one moment when asking who owns the arm is the wrong question. + +**The lease binds only what goes through this node.** `dora-ulite6` has no idea it exists. +Another node publishing `request` will preempt an operator mid-motion and nothing here can +stop it — if that matters for your dataflow, do not wire one. + +## The deadman + +An arm under a velocity command keeps moving until told otherwise, so the question is +never whether to start but whether anything has happened that means it must stop. Three +layers, outermost last: + +1. **Re-send every tick** (50 ms) while a source is driving. +2. **This node's watchdog** (`JOG_TTL_S`, 0.4 s) stops the arm when the owner goes quiet — + and stops it *immediately*, without waiting out the timer, when the gamepad is unplugged + or the last browser disconnects. An unplugged pad is not a centred pad. +3. **The arm's own expiry** (`VELOCITY_COMMAND_TTL_S` in `dora-ulite6`, 0.5 s), enforced by + the controller whether or not this process is alive. + +Keep them in that order. If this node's timer is longer than the controller's, the backstop +fires before the thing it is backing up. + +On shutdown the node sends one all-zero velocity before exiting. A motion lives in the +controller, not in this process. + +The stop is sent **once**, not every tick. A stream of zeroes would keep `dora-ulite6` +believing a jog is live and hold `robot_state` at `moving` forever. + +## Ports + +| Direction | Name | Description | +|-----------|----------------|------------------------------------------------------| +| in | `tick` | 50 ms — re-send, deadman, telemetry | +| in | `ui_command` | One console message, from the bridge's `data_out` | +| in | `clients` | Connect/disconnect from the bridge — the web deadman | +| in | `response` | An answer from the arm | +| in | `motion_state` | How a motion ended | +| in | `robot_state` | The arm's own state | +| in | `tcp_pose` | Tool-tip pose, six float32, mm and degrees | +| in | `joint_pose` | Joint angles, six float32, degrees | +| out | `jog` | `{velocity: [vx, vy, vz, wx, wy, wz]}`, mm/s + deg/s | +| out | `request` | A service call, `request_id` in the metadata | +| out | `ui_state` | The console's telemetry snapshot, as JSON | + +## Environment Variables + +| Variable | Default | Description | +|--------------------------|---------|--------------------------------------------------------| +| `SPEED_MM_S` | `60.0` | Translation speed at full deflection | +| `SPEED_DEG_S` | `30.0` | Rotation speed at full deflection | +| `DEADZONE` | `0.12` | Stick travel near centre to ignore | +| `EXPO` | `0.4` | How much of the curve is cubic; 0 is linear | +| `JOYSTICK_INDEX` | `0` | Which pad to open | +| `JOG_TTL_S` | `0.4` | Owner silence before the arm is stopped | +| `GAMEPAD_IDLE_RELEASE_S` | `2.0` | Untouched pad before it hands the arm back | +| `STATE_POLL_S` | `0.5` | How often to ask the arm what telemetry does not carry | + +A value that cannot be read, a `DEADZONE` outside the stick or an `EXPO` off the curve +stops the node at startup rather than the first time somebody picks up the pad. + +## Usage + +See `dataflow.yml` for the whole stack wired together. The short version: + +```yaml + - id: teleop + git: ssh://git@gitea.liria.cl:5001/liria/dora-teleop.git + tag: v0.1.0 + build: pip install . + path: dora-teleop + inputs: + tick: dora/timer/millis/50 + ui_command: ws-transceiver/data_out + clients: ws-transceiver/clients + response: ulite6/response + motion_state: ulite6/motion_state + robot_state: ulite6/robot_state + tcp_pose: ulite6/tcp_pose + joint_pose: ulite6/joint_pose + outputs: [jog, request, ui_state] +``` + +```bash +dora run dataflow.yml --uv +``` + +**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+, `uv`, and a +gamepad if you want one — the node starts without and says so. + +## Development + +```bash +uv sync --group dev +uv run pytest # 66 tests, no gamepad, arm, browser or daemon needed +uv run ruff check . +uv run ruff format --check . +``` + +`dora_teleop/gamepad.py` is the only file that touches pygame, and the only one that +cannot be tested without hardware. Everything else sees a plain `Sample`. diff --git a/dataflow.yml b/dataflow.yml new file mode 100644 index 0000000..ad55fe8 --- /dev/null +++ b/dataflow.yml @@ -0,0 +1,65 @@ +# The whole operator stack, and what CI validates. +# +# Built from checkouts rather than tags, so `dora validate` resolves each node's +# manifest from its `path:` and checks the wiring against the versions in front +# of you. The sibling repos are expected next to this one, which is how they are +# cloned in practice. +# +# Three nodes, one loop: the bridge carries the browser, this node decides who +# drives, and the arm node moves the arm and reports back. + +strict_types: true + +nodes: + - id: teleop + path: dora-teleop + build: pip install . + inputs: + tick: dora/timer/millis/50 + ui_command: ws-transceiver/data_out + clients: ws-transceiver/clients + response: ulite6/response + motion_state: ulite6/motion_state + robot_state: ulite6/robot_state + tcp_pose: ulite6/tcp_pose + joint_pose: ulite6/joint_pose + outputs: + - jog + - request + - ui_state + env: + SPEED_MM_S: "60" + SPEED_DEG_S: "30" + + - id: ws-transceiver + path: ../dora-websocket-transceiver + build: pip install . + inputs: + data_in: teleop/ui_state + outputs: + - data_out + - clients + env: + WS_PORT: "9000" + STATIC_DIR: "../dora-teleop/web" + HTTP_PORT: "9010" + + - id: ulite6 + path: ../dora-ulite6 + build: pip install . + inputs: + tick: dora/timer/millis/100 + request: teleop/request + jog: teleop/jog + outputs: + - response + - motion_state + - joint_pose + - tcp_pose + - robot_state + env: + ROBOT_IP: "192.168.1.192" + TOOL_TYPE: "vacuum" + # Nothing moves because the node started. An operator asks for the first + # motion, which is the whole point of a teleop stack. + INIT_MODE: "none" diff --git a/dora-node.yml b/dora-node.yml new file mode 100644 index 0000000..bfef78b --- /dev/null +++ b/dora-node.yml @@ -0,0 +1,172 @@ +# Node manifest: the typed contract a consumer wires against. +# +# No source package is shared between node repositories, so this file and its +# type URNs are the only contract a consumer has. `dora validate` checks a +# dataflow against it without running the node, so CI needs neither a gamepad +# nor a robot. +# +# `jog`, `request`, `response`, `motion_state` and the two `ui_` ports are +# deliberately **untyped**. dora treats a port carrying `request_id` as +# pattern-correlated and skips type checks on it, and several Arrow schemas +# legitimately flow over each — one per service, one per console message. +# Annotating them would claim a contract neither side keeps. + +apiVersion: 1 +name: dora-teleop +namespace: liria +description: Hands a robot arm to a human, from a gamepad or a browser +categories: [robot, teleop, input] +keywords: [teleop, gamepad, joystick, jog, arm, manipulator, web-console, operator] + +runtime: python +entrypoint: dora-teleop +# `dora build --uv` / `dora run --uv` rewrites this to `uv pip install .` and +# gives the node its own managed venv under `.dora/python-envs/`, so the +# dependencies it sees at runtime are the ones installed here. +build: pip install . +dora: ">=1.0" + +inputs: + tick: + description: >- + Drives everything time-based: re-sending the held velocity, expiring a + source that has gone quiet, and publishing the console's telemetry. Use 50 + ms. Slower than about 100 ms and the arm node's own velocity expiry starts + firing between ticks, which an operator feels as the arm stuttering. + type: std/core/v1/UInt64 + ui_command: + required: false + description: >- + One message from the web console, as the JSON string the WebSocket bridge + publishes on `data_out`. Wire it from the bridge. A message this node does + not recognise is dropped rather than refused, so a page from a newer build + cannot take the arm down. + clients: + required: false + description: >- + Connect and disconnect events from the WebSocket bridge, as + `{connected, event}`. **This is the browser's deadman.** Without it a tab + closing mid-jog leaves the arm travelling until the arm node's own expiry + catches up, because a socket that went away sends nothing to say so. + response: + required: false + description: >- + An answer from the arm node, correlated by `request_id` in the metadata. A + failure is passed to the console as a toast; a `get_state` answer fills in + what telemetry does not carry. + motion_state: + required: false + description: >- + How a motion ended. Needed because this node sends motions with + `wait: false`, and a non-blocking motion reports its outcome here and + nowhere else. + robot_state: + required: false + description: >- + The arm's own state, one of `connecting`, `ready`, `moving`, `recovering` + or `unavailable`. Translated into the vocabulary the console draws. + type: std/core/v1/String + tcp_pose: + required: false + description: >- + Tool-tip pose, six float32 as `[x, y, z, roll, pitch, yaw]` in mm and + degrees, base frame. Shown in the console's pose readout. + type: std/core/v1/Float32 + joint_pose: + required: false + description: >- + Joint angles, six float32 in degrees. Shown in the console's joint readout. + type: std/core/v1/Float32 + +outputs: + jog: + description: >- + A base-frame velocity for the arm, as `{velocity: [vx, vy, vz, wx, wy, wz]}` + in mm/s and deg/s. Re-sent every tick while a source is driving, and + followed by exactly one all-zero vector when it stops — once, not + repeatedly, because a stream of zeroes would keep the arm node believing a + jog is live. + request: + description: >- + A service call on the arm — `stop`, `grasp`, `release`, `clear`, + `freedrive`, `home`, `move_to`, `get_state` — with `request_id` in the + metadata. + ui_state: + description: >- + The console's telemetry snapshot, as a JSON string for the WebSocket + bridge to broadcast. Also carries error toasts. Every key in it is read by + name in the console's `app.js`. + +env: + SPEED_MM_S: + type: float + default: 60.0 + description: >- + Translation speed at full stick deflection, in mm/s. A stick is analog, so + this is a maximum and anything less moves proportionally slower. Defaults + to 60.0. + SPEED_DEG_S: + type: float + default: 30.0 + description: Rotation speed at full deflection, in deg/s. Defaults to 30.0. + DEADZONE: + type: float + default: 0.12 + description: >- + Stick travel near centre to ignore, as a fraction. A worn stick that does + not centre would otherwise drive the arm on its own. What is left is + rescaled, so motion still starts from zero just outside the band. + Defaults to 0.12. + EXPO: + type: float + default: 0.4 + description: >- + How much of the response curve is cubic, from 0 (linear) to 1. Fine + positioning happens near centre and a linear stick gives it almost no + resolution there. Defaults to 0.4. + JOYSTICK_INDEX: + type: int + default: 0 + description: Which pad to open, when more than one is plugged in. Defaults to 0. + JOG_TTL_S: + type: float + default: 0.4 + description: >- + How long the owning source may go quiet before the arm is stopped. Keep it + **below** the arm node's VELOCITY_COMMAND_TTL_S (0.5 s by default), which + is the controller-side backstop behind this one. Defaults to 0.4. + GAMEPAD_IDLE_RELEASE_S: + type: float + default: 2.0 + description: >- + How long a pad may sit untouched before it hands the arm back, so a + browser can take over without anyone pressing anything. Defaults to 2.0. + STATE_POLL_S: + type: float + default: 0.5 + description: >- + How often to call `get_state` for what telemetry does not carry: whether + the tool is engaged and whether freedrive is on. Defaults to 0.5. + +example: | + - id: teleop + git: ssh://git@gitea.liria.cl:5001/liria/dora-teleop.git + tag: v0.1.0 + build: pip install . + path: dora-teleop + inputs: + tick: dora/timer/millis/50 + ui_command: ws-transceiver/data_out + clients: ws-transceiver/clients + response: ulite6/response + motion_state: ulite6/motion_state + robot_state: ulite6/robot_state + tcp_pose: ulite6/tcp_pose + joint_pose: ulite6/joint_pose + outputs: + - jog + - request + - ui_state + env: + SPEED_MM_S: "60" + SPEED_DEG_S: "30" diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..ad6a905 --- /dev/null +++ b/web/index.html @@ -0,0 +1,85 @@ + + + + + +teleop — placeholder console + + + +

teleop — placeholder console

+

+ Not the real console. This page exists so the dataflow has something to serve and so the + node can be checked end to end before the Control section is ported across from the + monolithic repo. It reads telemetry and can take, release and stop — nothing else. +

+ +
+ +
+ + + + +
+ + + + + + From 8a7bfb02e0bd6151619c6aa1c088699c1c3cba3c Mon Sep 17 00:00:00 2001 From: fondef Date: Tue, 15 Sep 2026 15:47:36 -0500 Subject: [PATCH 07/10] add a probe that shows what the gamepad is doing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The axis and button indices in mapping.py are SDL's documented layout rather than an observed one, and until now the only way to find a pad that numbers things differently was to watch a real arm move the wrong way. `dora-teleop-probe` prints the raw axes, the buttons it sees and the velocity that would be sent, live, with no dataflow and no arm — it cannot move anything. It goes through the same Gamepad and velocity() the node runs, so what it prints is what the arm would get; that is the whole reason it is worth having, and the reason it must not grow its own copy of the mapping. Every axis is listed, including ones the layout does not use, because an axis marked unused that moves when a stick is pushed is exactly the finding this is for. The centred bar makes a worn stick that does not return to zero obvious. --- AGENTS.md | 8 ++- README.md | 42 +++++++++++++- dora_teleop/probe.py | 132 +++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + tests/test_probe.py | 81 ++++++++++++++++++++++++++ 5 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 dora_teleop/probe.py create mode 100644 tests/test_probe.py diff --git a/AGENTS.md b/AGENTS.md index 3b81bad..1744c0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ expected next to this one. ## Test ```bash -uv run pytest # 66 tests, no gamepad, arm, browser or daemon needed +uv run pytest # 76 tests, no gamepad, arm, browser or daemon needed uv run ruff check . uv run ruff format --check . ``` @@ -77,6 +77,12 @@ a source goes away**. first time somebody picks up the pad. Errors name the variable. - **`dora_teleop/log.py`** — Timestamped stderr logging. stderr because dora reads a dynamic node's stdout. +- **`dora_teleop/probe.py`** — `dora-teleop-probe`: shows the raw axes, the buttons and the + velocity that would be sent, with no dataflow and no arm. It exists because the indices + in `mapping.py` are SDL's documented layout rather than an observed one, and a pad that + disagrees is otherwise found by watching a real arm move the wrong way. It goes through + the same `Gamepad` and `velocity()` the node runs, so what it prints is what the arm + would get — keep it that way, or it stops being evidence. ## Rules that are not negotiable diff --git a/README.md b/README.md index e05b9bd..45a2d8c 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,46 @@ somebody can read. `home` is deliberately **not** on the pad. It is a large unattended motion and belongs behind a deliberate click, not a thumb resting on a face button. +### Checking your pad + +```bash +uv run dora-teleop-probe +``` + +Prints the raw axes, the buttons it sees, and the velocity this node would send — live, +with no dataflow and no arm. **It cannot move anything.** There is no dora node and no +connection to the robot; it only reads. + +```text + axes (6 reported) + 0 left stick X +0.600 ----------|-----#---- + 1 left stick Y -0.800 --#-------|---------- + 2 left trigger -1.000 #---------|---------- + ... + buttons (6 reported) + 5:yaw right + + velocity this would send to the arm + vx +38.89 mm/s forward / back <-- + vy +23.53 mm/s left / right <-- + vz +20.39 mm/s up / down <-- + wz +30.00 deg/s yaw <-- + + moving: yes +``` + +Run this before trusting the table above. The indices are SDL's documented layout, not +observed ones, and a pad that numbers its axes differently is much better found here than +by watching an arm move the wrong way. Two things to look for: + +- **Every axis is listed, including ones the layout does not use** — an axis marked + `unused` that moves when you push a stick means the layout needs changing. +- **A bar that does not sit centred with the stick released** is a worn pad announcing + itself, and a reason to raise `DEADZONE`. + +It goes through the same `Gamepad` and `velocity()` the node runs, so what it prints is +what the arm would be sent. + ## The lease Two sources, one arm. Whoever holds the lease drives; the other is ignored. @@ -143,7 +183,7 @@ gamepad if you want one — the node starts without and says so. ```bash uv sync --group dev -uv run pytest # 66 tests, no gamepad, arm, browser or daemon needed +uv run pytest # 76 tests, no gamepad, arm, browser or daemon needed uv run ruff check . uv run ruff format --check . ``` diff --git a/dora_teleop/probe.py b/dora_teleop/probe.py new file mode 100644 index 0000000..a99d96f --- /dev/null +++ b/dora_teleop/probe.py @@ -0,0 +1,132 @@ +"""Show what the pad is doing, with no dataflow and no arm. + +`dora-teleop-probe` opens the gamepad and prints, live: the raw axes SDL +reports, the buttons it sees pressed, and the velocity this node would send. + +It exists because the axis and button indices in :mod:`dora_teleop.mapping` are +SDL's documented layout rather than an observed one, and a pad that disagrees is +otherwise discovered by watching a real arm move the wrong way. It goes through +:class:`~dora_teleop.gamepad.Gamepad` and :func:`~dora_teleop.mapping.velocity` +— the same code the node runs — so what it prints is what the arm would get. + +Nothing here can move anything. There is no dora node and no connection to the +arm; this only reads. +""" + +import argparse +import time + +from dora_teleop import mapping +from dora_teleop.config import Config +from dora_teleop.gamepad import Gamepad +from dora_teleop.mapping import LAYOUT + +# What each velocity component is, in the order the arm takes them. +COMPONENTS = ( + ("vx", "mm/s", "forward / back"), + ("vy", "mm/s", "left / right"), + ("vz", "mm/s", "up / down"), + ("wx", "deg/s", "roll"), + ("wy", "deg/s", "pitch"), + ("wz", "deg/s", "yaw"), +) + +CLEAR = "\033[H\033[J" + + +def axis_names(layout=LAYOUT) -> dict[int, str]: + """Name each axis index the layout uses.""" + return { + layout.left_x: "left stick X", + layout.left_y: "left stick Y", + layout.left_trigger: "left trigger", + layout.right_x: "right stick X", + layout.right_y: "right stick Y", + layout.right_trigger: "right trigger", + } + + +def button_names(layout=LAYOUT) -> dict[int, str]: + """Name each button index the layout uses.""" + return { + layout.grasp: "grasp", + layout.release: "release", + layout.clear: "clear", + layout.freedrive: "freedrive", + layout.yaw_left: "yaw left", + layout.yaw_right: "yaw right", + layout.estop: "E-STOP", + } + + +def bar(value: float, width: int = 21) -> str: + """Draw a centred bar for a value between -1 and 1. + + Seeing the bar sit off-centre with the stick released is how a worn pad + announces itself, which is most of the point of looking at raw numbers. + """ + middle = width // 2 + position = middle + round(max(-1.0, min(1.0, value)) * middle) + cells = ["-"] * width + cells[middle] = "|" + cells[position] = "#" + return "".join(cells) + + +def render(sample, velocity: list[float], config: Config) -> str: + """Build the whole display for one reading.""" + lines = ["dora-teleop probe — Ctrl-C to stop", ""] + + if not sample.present: + lines += [ + " no gamepad found.", + "", + f" looking for joystick index {config.joystick_index};", + " plug one in and this will pick it up.", + ] + return "\n".join(lines) + + names = axis_names() + lines.append(f" axes ({len(sample.axes)} reported)") + for index, value in enumerate(sample.axes): + name = names.get(index, "unused") + lines.append(f" {index} {name:<15} {value:+.3f} {bar(value)}") + + labels = button_names() + held = [f"{index}:{labels.get(index, 'unused')}" for index, down in enumerate(sample.buttons) if down] + lines += ["", f" buttons ({len(sample.buttons)} reported)", f" {', '.join(held) or 'none pressed'}"] + + lines += ["", " velocity this would send to the arm"] + for (name, units, meaning), value in zip(COMPONENTS, velocity, strict=True): + marker = " <--" if value else "" + lines.append(f" {name} {value:+8.2f} {units:<6} {meaning:<14}{marker}".rstrip()) + + moving = "yes" if mapping.is_moving(velocity) else "no — the arm would be told to hold still" + lines += ["", f" moving: {moving}"] + return "\n".join(lines) + + +def main() -> None: + """Open the pad and print what it is doing until interrupted.""" + parser = argparse.ArgumentParser(description="Show what the gamepad is doing. Moves nothing.") + parser.add_argument("--hz", type=float, default=10.0, help="Redraw rate. Defaults to 10.") + args = parser.parse_args() + + config = Config.from_env() + gamepad = Gamepad(config.joystick_index) + gamepad.start() + + try: + while True: + sample = gamepad.read() + velocity = mapping.velocity(sample.axes, sample.buttons, config) if sample.present else [0.0] * 6 + print(CLEAR + render(sample, velocity, config), flush=True) + time.sleep(1.0 / args.hz) + except KeyboardInterrupt: + print() + finally: + gamepad.shutdown() + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index c26a890..ac8c21d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dev = ["pytest >= 8.1", "ruff >= 0.9"] [project.scripts] dora-teleop = "dora_teleop.main:main" +dora-teleop-probe = "dora_teleop.probe:main" [build-system] requires = ["hatchling"] diff --git a/tests/test_probe.py b/tests/test_probe.py new file mode 100644 index 0000000..279ad01 --- /dev/null +++ b/tests/test_probe.py @@ -0,0 +1,81 @@ +"""The probe: what it draws, without a pad or a terminal.""" + +from dora_teleop import mapping, probe +from dora_teleop.gamepad import Sample +from tests.conftest import stick + + +def test_a_centred_stick_puts_the_marker_in_the_middle(): + drawn = probe.bar(0.0) + + assert drawn[len(drawn) // 2] == "#" + + +def test_the_bar_leans_the_way_the_stick_is_pushed(): + left = probe.bar(-1.0) + right = probe.bar(1.0) + + assert left.index("#") < left.index("|") + assert right.index("#") > right.index("|") + + +def test_a_reading_beyond_full_deflection_stays_on_the_bar(): + # SDL has been known to report slightly outside -1..1 on a worn stick. + assert "#" in probe.bar(-4.0) + assert "#" in probe.bar(4.0) + + +def test_no_gamepad_says_so_and_names_the_index_it_looked_for(config): + config.joystick_index = 2 + + drawn = probe.render(Sample(present=False), [0.0] * 6, config) + + assert "no gamepad found" in drawn + assert "index 2" in drawn + + +def test_every_axis_the_pad_reports_is_shown_even_the_unused_ones(config): + # A pad whose sticks land on indices we do not use is exactly what this is + # for, so nothing may be hidden. + sample = Sample(axes=[0.0] * 8, buttons=[], present=True) + + drawn = probe.render(sample, [0.0] * 6, config) + + assert "unused" in drawn + assert "axes (8 reported)" in drawn + + +def test_a_pressed_button_is_named(config): + sample = stick(buttons=[True]) + + drawn = probe.render(sample, [0.0] * 6, config) + + assert "grasp" in drawn + + +def test_nothing_pressed_says_so_rather_than_showing_an_empty_line(config): + drawn = probe.render(stick(), [0.0] * 6, config) + + assert "none pressed" in drawn + + +def test_the_velocity_is_shown_in_the_units_the_arm_takes(config): + velocity = mapping.velocity(stick(axes=[0.0, -1.0]).axes, [], config) + + drawn = probe.render(stick(axes=[0.0, -1.0]), velocity, config) + + assert "mm/s" in drawn + assert "deg/s" in drawn + assert f"{config.speed_mm_s:+8.2f}" in drawn + + +def test_a_still_arm_is_reported_as_still(config): + drawn = probe.render(stick(), [0.0] * 6, config) + + assert "told to hold still" in drawn + + +def test_a_moving_arm_is_reported_as_moving(config): + drawn = probe.render(stick(axes=[0.0, -1.0]), [10.0, 0, 0, 0, 0, 0], config) + + assert "moving: yes" in drawn From e9fa4bf36b30a1de70dae0f84cf6e9e1f17b4855 Mon Sep 17 00:00:00 2001 From: fondef Date: Tue, 15 Sep 2026 16:51:30 -0500 Subject: [PATCH 08/10] fix the node manifest and make the example dataflow self-contained MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems, both found by the validate job once it could finally run. categories named `teleop` and `input`, neither of which dora defines. The set is fixed — sensor, actuator, robot, transform, filter, ml-inference, llm, speech, communication, recorder, visualization, simulator, debug — and an unknown variant makes the whole manifest unparseable, so every type annotation in it was being ignored. dataflow.yml spanned three repositories through sibling paths, which meant dora could not resolve the other nodes' manifests and reported their typed outputs as unannotated. It also could not be validated in CI, which clones one repo. The house pattern is a self-contained dataflow, and this now follows it: teleop plus a bench stand-in for the arm and the browser, both from this checkout. The real three-node wiring moves to README.md, where a consumer would look for it. The bench answers every service, publishes plausible telemetry, and prints the velocities it is sent, so the node can be driven from a real gamepad with no robot in the room. It is not a simulator and must not become one: it never integrates a velocity or moves a pose. --- AGENTS.md | 5 ++ README.md | 28 +++++++++-- dataflow.yml | 62 ++++++++--------------- dora-node.yml | 2 +- examples/bench.py | 110 +++++++++++++++++++++++++++++++++++++++++ examples/dora-node.yml | 42 ++++++++++++++++ 6 files changed, 204 insertions(+), 45 deletions(-) create mode 100644 examples/bench.py create mode 100644 examples/dora-node.yml diff --git a/AGENTS.md b/AGENTS.md index 1744c0a..7bec0de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,6 +77,11 @@ a source goes away**. first time somebody picks up the pad. Errors name the variable. - **`dora_teleop/log.py`** — Timestamped stderr logging. stderr because dora reads a dynamic node's stdout. +- **`examples/bench.py`** — A stand-in for the arm *and* the browser, so `dataflow.yml` + is self-contained and CI can validate it with no sibling clones. **It is not a + simulator** and must not become one: it never integrates a velocity or moves a pose. Its + job is to close the loop so this node can be watched, not to stand in for + `dora-ulite6` — anything needing the arm to actually respond needs the arm. - **`dora_teleop/probe.py`** — `dora-teleop-probe`: shows the raw axes, the buttons and the velocity that would be sent, with no dataflow and no arm. It exists because the indices in `mapping.py` are SDL's documented layout rather than an observed one, and a pad that diff --git a/README.md b/README.md index 45a2d8c..175edd6 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ stops the node at startup rather than the first time somebody picks up the pad. ## Usage -See `dataflow.yml` for the whole stack wired together. The short version: +The real stack is three nodes: this one, the WebSocket bridge and the arm. ```yaml - id: teleop @@ -172,12 +172,34 @@ See `dataflow.yml` for the whole stack wired together. The short version: outputs: [jog, request, ui_state] ``` +Point `ws-transceiver`'s `data_in` at `teleop/ui_state` and its `STATIC_DIR` at this +repo's `web/`, and wire `ulite6`'s `request` and `jog` from this node. + +**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+, `uv`, and a +gamepad if you want one — the node starts without and says so. + +### Running it with no arm and no browser + +`dataflow.yml` in this repo is **not** that stack. It is this node plus a bench stand-in, +so you can drive the real thing from a real gamepad with no robot in the room: + ```bash dora run dataflow.yml --uv ``` -**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+, `uv`, and a -gamepad if you want one — the node starts without and says so. +The bench answers every service, publishes plausible telemetry, and prints each velocity +it is sent: + +```text +jog 1 [ +38.89, +23.53, +0.00, +0.00, +0.00, +0.00] +jog 2 [ +38.89, +23.53, +0.00, +0.00, +0.00, +0.00] +jog 3 [ +0.00, +0.00, +0.00, +0.00, +0.00, +0.00] <- stop +``` + +It is **not a simulator**: it never integrates the velocity or moves the pose, and a jog +is printed and forgotten. It closes the loop so this node's behaviour can be watched — +the lease changing hands, the deadman firing when you let go — not so the arm can be +pretended at. ## Development diff --git a/dataflow.yml b/dataflow.yml index ad55fe8..162cef5 100644 --- a/dataflow.yml +++ b/dataflow.yml @@ -1,12 +1,13 @@ -# The whole operator stack, and what CI validates. +# The repo's own example, and what CI validates. # -# Built from checkouts rather than tags, so `dora validate` resolves each node's -# manifest from its `path:` and checks the wiring against the versions in front -# of you. The sibling repos are expected next to this one, which is how they are -# cloned in practice. +# Self-contained on purpose: teleop plus a bench stand-in for the arm and the +# browser, both built from this checkout, so `dora validate` resolves every +# manifest from `path:` and CI needs no gamepad, no robot and no sibling clones. # -# Three nodes, one loop: the bridge carries the browser, this node decides who -# drives, and the arm node moves the arm and reports back. +# This is **not** the real stack. The bench answers services and publishes +# telemetry but never moves anything — it exists so the node can be driven from +# a real gamepad with no arm in the room. README.md carries the three-node +# wiring a consumer actually uses. strict_types: true @@ -16,13 +17,11 @@ nodes: build: pip install . inputs: tick: dora/timer/millis/50 - ui_command: ws-transceiver/data_out - clients: ws-transceiver/clients - response: ulite6/response - motion_state: ulite6/motion_state - robot_state: ulite6/robot_state - tcp_pose: ulite6/tcp_pose - joint_pose: ulite6/joint_pose + clients: bench/clients + response: bench/response + robot_state: bench/robot_state + tcp_pose: bench/tcp_pose + joint_pose: bench/joint_pose outputs: - jog - request @@ -31,35 +30,16 @@ nodes: SPEED_MM_S: "60" SPEED_DEG_S: "30" - - id: ws-transceiver - path: ../dora-websocket-transceiver - build: pip install . + - id: bench + path: examples/bench.py inputs: - data_in: teleop/ui_state - outputs: - - data_out - - clients - env: - WS_PORT: "9000" - STATIC_DIR: "../dora-teleop/web" - HTTP_PORT: "9010" - - - id: ulite6 - path: ../dora-ulite6 - build: pip install . - inputs: - tick: dora/timer/millis/100 - request: teleop/request + tick: dora/timer/millis/200 jog: teleop/jog + request: teleop/request + ui_state: teleop/ui_state outputs: - response - - motion_state - - joint_pose - - tcp_pose - robot_state - env: - ROBOT_IP: "192.168.1.192" - TOOL_TYPE: "vacuum" - # Nothing moves because the node started. An operator asks for the first - # motion, which is the whole point of a teleop stack. - INIT_MODE: "none" + - tcp_pose + - joint_pose + - clients diff --git a/dora-node.yml b/dora-node.yml index bfef78b..f89ef20 100644 --- a/dora-node.yml +++ b/dora-node.yml @@ -15,7 +15,7 @@ apiVersion: 1 name: dora-teleop namespace: liria description: Hands a robot arm to a human, from a gamepad or a browser -categories: [robot, teleop, input] +categories: [robot] keywords: [teleop, gamepad, joystick, jog, arm, manipulator, web-console, operator] runtime: python diff --git a/examples/bench.py b/examples/bench.py new file mode 100644 index 0000000..f770284 --- /dev/null +++ b/examples/bench.py @@ -0,0 +1,110 @@ +"""A bench stand-in for the arm and the browser, so teleop can run with neither. + +Run by `dataflow.yml`. It answers every service, publishes plausible telemetry, +and prints the velocities it is sent — which is enough to drive the node from a +real gamepad and watch what a real arm would have been told to do. + +**It is not a simulator.** It does not integrate the velocity, move a pose, or +model the arm in any way; a jog is printed and forgotten. Its job is to close +the loop so the node's own behaviour can be watched, not to stand in for +`dora-ulite6`. Anything that needs the arm to actually respond needs the arm. +""" + +import json + +import pyarrow as pa +from dora import Node + +REQUEST_ID = "request_id" + +# Somewhere in front of the robot, tool pointing down. Fixed, because a moving +# pose here would look like the arm was responding, which it is not. +POSE = [250.0, 0.0, 200.0, 180.0, 0.0, 0.0] +JOINTS = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0] + + +def main() -> None: + """Answer whatever teleop asks and report what it would have moved.""" + node = Node("bench") + announced = False + tool_engaged = False + freedrive = False + jogs = 0 + shown = None + + for event in node: + if event["type"] != "INPUT": + continue + + if event["id"] == "tick": + if not announced: + announced = True + node.send_output("robot_state", pa.array(["ready"])) + node.send_output("tcp_pose", pa.array(POSE, type=pa.float32()), {"units_position": "mm"}) + node.send_output("joint_pose", pa.array(JOINTS, type=pa.float32()), {"units": "deg"}) + # No browser is attached to this dataflow, and saying so is what + # keeps teleop from believing one went away mid-jog. + node.send_output("clients", pa.array([{"connected": 0, "event": "close"}])) + + elif event["id"] == "jog": + velocity = event["value"][0].as_py()["velocity"] + jogs += 1 + moving = any(velocity) + shown = ", ".join(f"{v:+7.2f}" for v in velocity) + print(f"jog {jogs:5d} [{shown}] {'' if moving else '<- stop'}", flush=True) + + elif event["id"] == "request": + row = event["value"][0].as_py() + service = row.get("service", "") + request_id = event["metadata"].get(REQUEST_ID, "") + if service == "grasp": + tool_engaged = True + elif service == "release": + tool_engaged = False + elif service == "freedrive": + freedrive = bool(row.get("enabled", True)) + if service != "get_state": + print(f"service {service} ({request_id})", flush=True) + node.send_output( + "response", + pa.array([_answer(service, tool_engaged, freedrive)]), + {REQUEST_ID: request_id}, + ) + + elif event["id"] == "ui_state": + # No browser is attached, so this is where the console's picture of + # the arm would go. Printing it on change rather than on every tick + # makes the things worth watching — the lease moving, the arm + # starting and stopping — visible without 20 lines a second. + snapshot = json.loads(event["value"][0].as_py()) + if snapshot.get("type") != "telemetry": + continue + current = (snapshot["arm_state"], snapshot["owner"], snapshot["estop"]) + if current != shown: + shown = current + state, owner, estop = current + print( + f"console would show: {state}, owner {owner}" + (", E-STOPPED" if estop else ""), + flush=True, + ) + + +def _answer(service: str, tool_engaged: bool, freedrive: bool) -> dict: + """Build the response row the arm node would have sent.""" + if service == "get_state": + return { + "ok": True, + "service": service, + "message": "ready", + "connected": True, + "ready": True, + "tool_engaged": tool_engaged, + "freedrive": freedrive, + "pose": POSE, + "joints": JOINTS, + } + return {"ok": True, "service": service, "message": f"{service} accepted by the bench"} + + +if __name__ == "__main__": + main() diff --git a/examples/dora-node.yml b/examples/dora-node.yml new file mode 100644 index 0000000..5e4199e --- /dev/null +++ b/examples/dora-node.yml @@ -0,0 +1,42 @@ +# Manifest for the bench stand-in. +# +# dora resolves a node's manifest from its `path:`, so without this file the +# bench would be validated against the teleop node's own contract and every port +# would look wrong. + +apiVersion: 1 +name: bench +namespace: liria +description: Bench stand-in for the arm and the browser, so teleop can run with neither + +runtime: python +entrypoint: bench.py +dora: ">=1.0" + +inputs: + tick: + description: Drives the telemetry the arm node would publish. + type: std/core/v1/UInt64 + jog: + description: A velocity teleop wants the arm to move at. Printed, then forgotten. + request: + description: A service call. Every one is answered ok. + ui_state: + description: >- + The console snapshot teleop publishes. Accepted and dropped: no browser is + attached to this dataflow. + +outputs: + response: + description: One answer per request, under the id it arrived with. + robot_state: + description: Reports `ready` once, so teleop stops drawing the arm as disconnected. + type: std/core/v1/String + tcp_pose: + description: A fixed tool-tip pose. Fixed on purpose — nothing here integrates a velocity. + type: std/core/v1/Float32 + joint_pose: + description: Fixed joint angles, for the same reason. + type: std/core/v1/Float32 + clients: + description: Always zero connected, since the WebSocket bridge is not in this dataflow. From fb145458501ca9f147a19d4be397cd328fc4d749 Mon Sep 17 00:00:00 2001 From: fondef Date: Tue, 15 Sep 2026 17:08:31 -0500 Subject: [PATCH 09/10] enable debug inspection on the example dataflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dora topic echo` and `dora topic hz` only read the wire when the dataflow asks for it. Turning it on here means the velocities this node sends can be watched live, and their cadence measured, without the bench printing anything and without a robot — which is most of what testing this node with only a gamepad needs. Measured with it: the get_state poll runs at 1.88 Hz against a configured STATE_POLL_S of 0.5. --- dataflow.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/dataflow.yml b/dataflow.yml index 162cef5..ac2067d 100644 --- a/dataflow.yml +++ b/dataflow.yml @@ -11,6 +11,12 @@ strict_types: true +# Lets `dora topic echo` and `dora topic hz` read what is on the wire, so the +# velocities this node sends can be watched without the bench printing them — +# and without a robot. Costs nothing when nobody is looking. +debug: + enable_debug_inspection: true + nodes: - id: teleop path: dora-teleop From ac2540629e05fc035df245e8cc826e8ca48e878e Mon Sep 17 00:00:00 2001 From: fondef Date: Tue, 15 Sep 2026 17:21:49 -0500 Subject: [PATCH 10/10] docs: explain what debug inspection is for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dataflow turned on `enable_debug_inspection` without the README saying why anyone would want it, which made it a line of configuration nobody could act on. Documents the three commands it buys — topic list, topic echo, topic hz — and that they need a coordinator rather than `dora run`. hz is the one that checks the timings this node promises, so it names the measurement to expect. --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.md b/README.md index 175edd6..380f338 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,32 @@ is printed and forgotten. It closes the loop so this node's behaviour can be wat the lease changing hands, the deadman firing when you let go — not so the arm can be pretended at. +### Watching the wire + +`dataflow.yml` sets `debug: enable_debug_inspection: true`, so dora will let you read what +is actually on the wire without the bench printing anything. Run it under a coordinator +rather than `dora run`: + +```bash +dora up +dora start dataflow.yml --uv --name teleop-bench +``` + +Then, in another terminal: + +```bash +dora topic list -d teleop-bench # every edge in the graph +dora topic echo -d teleop-bench teleop/jog # every velocity, live +dora topic echo -d teleop-bench teleop/ui_state # what the console would draw +dora topic hz -d teleop-bench teleop/jog --duration 10 # the re-send cadence +``` + +`hz` is the one that checks the timings this node promises. The `get_state` poll measures +at 1.88 Hz against a configured `STATE_POLL_S` of 0.5, and with a pad held over the +deadzone `teleop/jog` should sit near the 50 ms tick. + +Tear it down with `dora stop --name teleop-bench && dora down`. + ## Development ```bash