diff --git a/.gitea/workflows/validate.yml b/.gitea/workflows/validate.yml new file mode 100644 index 0000000..d85f789 --- /dev/null +++ b/.gitea/workflows/validate.yml @@ -0,0 +1,63 @@ +# Two gates, neither of which needs a robot: the dataflow still matches the +# typed contract in dora-node.yml, and the node's own tests pass. +name: validate + +# A pull request would otherwise run this twice on the same commit: once for the +# branch push and once for the PR. +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. + - name: Install + run: uv sync --locked --group dev + + - run: uv run ruff 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/.github/workflows/markdownlint.yml b/.github/workflows/markdownlint.yml deleted file mode 100644 index 4067a80..0000000 --- a/.github/workflows/markdownlint.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: markdownlint - -on: - push: - paths: - - "**.md" - - ".markdownlint.json" - pull_request: - paths: - - "**.md" - - ".markdownlint.json" - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: "22" - - run: npm install -g markdownlint-cli2 - - run: markdownlint-cli2 "**/*.md" diff --git a/.gitignore b/.gitignore index df85934..49d23c3 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ build/ .coverage .ruff_cache/ out/ +.dora/ diff --git a/AGENTS.md b/AGENTS.md index d8d9021..28a3f19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,56 +4,199 @@ This file provides guidance to AI coding assistants when working with code in th ## What this is -A dora-rs Python node that interfaces with a UFactory Lite6 robot arm. On each tick it reads joint angles -and TCP pose from the robot and publishes them as dora outputs. It also accepts JSON commands -(home, move_to, move_to_pose, vacuum_on/off) via a `command` input. A FastAPI REST server with a web UI -runs in a background thread for manual control. The robot control wrapper lives in `dora_ulite6/main.py` -as the `ULite6Helper` class, and the dora event loop is the `main()` function in the same file. +A dora-rs Python node that drives a UFactory Lite6 arm. Every operation is a **service** +— one request, one response — and the arm's joint angles, tool pose and connection state +are published as ordinary topics. + +There is deliberately **no action pattern** here: no goals, no feedback, no cancel. A +motion is a service that takes a `wait` flag, and its lifecycle is published as **the +arm's state** (`motion_state`) rather than as a per-goal stream. That is the right model +because the arm is one resource: there is never more than one motion, so a new one +preempts the old, and a state describes that exactly where an action pattern would be +modelling concurrency that cannot physically exist. Do not reintroduce +goal/feedback/result. + +The service pattern is **dora's own**, not a ROS2 port. The correlation key `request_id` +is defined in `dora-message`, and dora's runtime treats a port carrying it as +pattern-correlated: several Arrow schemas may legitimately flow through it, one per +service, and runtime type checks are skipped for those messages. That is why `request` +and `response` in `dora-node.yml` are deliberately untyped, and annotating them would be +a lie. + +dora's `ros2:` service bridge is one consumer of the same convention, so a ROS2 front end +is a dataflow change rather than a code change. Do not add a ROS2 dependency here. + +It served a `command` JSON input and a `status` output through `v0.2.0`, and ran a +FastAPI REST server with a web UI through `v0.1.2`. Both remain reachable at those tags. + +The driver layer is ported from the +[littlehand](https://gitea.liria.cl/Littlehand/monolitic-littlehand-python) robot layer +(`littlehand/robot/`), which was written against this same controller. Fixes made there +belong here, and vice versa. ## Build ```bash -uv pip install -e . +uv sync # runtime deps +uv sync --group dev # plus pytest and ruff ``` -Requirements: Python >=3.8, uv, and network access to the UFactory Lite6 robot. +`dora build --uv` / `dora run --uv` rewrites the manifest's `build: pip install .` to +`uv pip install .` and gives the node its own managed venv under +`.dora/python-envs//`, so what it imports at runtime is what was installed at +build time. + +**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+, `uv`, and +network access to the arm's controller. ## Run ```bash -dora up -dora start dataflow.yml +dora run dataflow.yml --uv ``` -Robot behavior is configured through environment variables (see README.md for the full list: -ROBOT_IP, DEFAULT_SPEED, DEFAULT_UNITS, API_HOST, API_PORT, VACUUM_ENABLED, INIT_MODE, etc.). +Everything is configured through environment variables — see README.md for the full list. + +## Test + +```bash +uv run pytest # 86 tests, no robot and no dataflow needed +uv run ruff check . +``` ## Architecture -- **`dora_ulite6/main.py`** -- Single-file implementation containing: - - `ULite6Helper`: Wraps the xArm Python SDK for motion control (go_home, move_to_pose, - get_current_position, get_joint_angles_deg, get_pose_matrix, vacuum gripper, status, reset). - - `MoveToRequest` / `MoveToPoseRequest`: Pydantic models for API validation. - - `create_api()`: FastAPI app factory with REST endpoints and embedded HTML/JS web UI for manual control. - - `main()`: Dora event loop. Starts FastAPI in a daemon thread, then processes `tick` (publishes - joint_pose and tcp_pose) and `command` (executes robot actions and publishes status) inputs. -- **`dora_ulite6/__init__.py`** -- Package marker. -- **`pyproject.toml`** -- Package metadata, dependencies, and entry point. -- **`dataflow.yml`** -- Example dora dataflow using the git: source format. +Two things carry the design. **Who may touch the SDK**, and **why the arm gets threads of +its own**. + +The xArm SDK blocks: a move waits out the motion, and a read from a controller whose +cable was pulled blocks until its own socket timeout. One thread would therefore have to +choose between carrying out a motion and reporting where the arm is, and since a motion +service does not answer until it finishes, telemetry is the only thing a caller can watch +while it runs. So: + +- **`dora_ulite6/main.py`** — The dora event loop, and nothing else. Routes `request`, + `jog` and `tick`; reads the `request_id` out of the event metadata; publishes what the + arm threads produced. **It never calls anything that can block**, `get_state` included — + that one is allowed here only because it reads the report cache. A request it cannot + parse is refused here, under its own id: a caller waiting on a request_id must get + exactly one response, never silence. It builds the `Node` before starting the arm + threads, so `INIT_MODE` cannot move a robot before this process has joined the + dataflow, and so the arm can be told whether this is a restart. +- **`dora_ulite6/control.py`** — `Control`: both arm threads and the mailboxes the loop + drains. The **control thread** runs one job at a time and is allowed to block: startup, + the `INIT_MODE` move, and every service including the motions that take seconds. Its + wait between connection attempts serves the queue (`_serve_while_down`) rather than + sleeping through it: a request is answered exactly once whatever the arm is doing, and + an arm that is down is an outcome to report, not a reason to go quiet. A motion is + refused there rather than held, since a held one would fire unattended the moment the + link came back. The + **reporter thread** never blocks: it reads the report cache at the telemetry cadence, + so `tcp_pose` keeps flowing right through a move. **Nothing outside this file may call + the driver**, except `stop` and `read_report`, both written for another thread. Same + lesson, same shape as `zed::Capture` in the ZED node. + + The **stopper thread** exists because a stop fits nowhere else: it must not queue + behind the motion it aborts, and it must not run on the event loop either, since the + SDK's `emergency_stop` re-commands the controller for up to three seconds. Requests to + it coalesce. + + `start_motion` and `start_jog` are the two preemption doors, both over + `_preempt_motions`: mark the running motion and any still waiting, ask the stopper to + halt the arm, publish `accepted`, and acknowledge the caller when `wait` is false. + `_stopped_for_preempt` is only ever *set* there and cleared by the motion that consumes + it — preempting a motion that had not started stops nothing, and must not swallow a + clear owed by an earlier preemption that did. + + `shutdown` halts the arm **first** and then joins, returning whether the control thread + really came back. A motion lives in the controller, not in this process. +- **`dora_ulite6/driver.py`** — `ULite6`: the arm over the vendor SDK. Ported from + littlehand's `UFactoryRobot`. Its whole point is that a command either moved the arm or + raised: the SDK returns a code for a motion the controller quietly refused, `wait_move` + calls that refusal finished, and `arm.error_code` is the report stream's stale copy — so + errors are read live (`_live_error_code`), arrival is confirmed (`_check_arrived`) and a + move is refused before it is commanded if the straight-line path leaves the joint + envelope (`_check_path_reachable`). `read_report` is the deliberate exception: a cache + read, no round trip, the only read that works while a motion blocks. +- **`dora_ulite6/services.py`** — Every operation the node serves, and the two sets that + matter. `MOVES_THE_ARM` is what preempts, is preemptable, and reports `moving`. + `IMMEDIATE` is served by the event loop rather than the control thread, so it answers + *during* a motion — adding to it is a promise that the service never blocks and is safe + off the control thread, which is why `get_state` reads the report cache rather than the + wire. +- **`dora_ulite6/payload.py`** — Reading arguments out of the one-row Arrow struct a + request arrives as. Assumes no fixed schema, because there isn't one. Arguments are + read before the arm is touched, so a malformed request never half-moves it. +- **`dora_ulite6/errors.py`** — What the driver raises. The distinctions decide the + response: `UnreachablePoseError` is not a `RobotUnavailableError` because the arm is fine + and the target is not, and `ArmNeedsHomingError` is separate because nothing but a + motion of ours clears it and nobody should drive an arm home without warning the room. +- **`dora_ulite6/effectors.py`** — What is bolted to the flange: which driver method + switches it and how far past the flange its tip sits. A mismatch is refused when the arm + is built, not mid-motion. +- **`dora_ulite6/pose.py`** — `RobotPose` and `with_tool_offset`, which slides a pose along + its own Z axis. The offset inverts exactly, which is what lets goals and telemetry share + a frame. +- **`dora_ulite6/controller.py`** — Facts about the xArm controller: its modes, the codes + that mean the command never ran, and the Euler maths. Nothing tunable. +- **`dora_ulite6/config.py`** — Every environment variable, parsed once at startup. An + unusable `TOOL_TYPE` or `INIT_MODE` raises before the node joins the dataflow. +- **`dora-node.yml`** — Node manifest (`apiVersion: 1`). `dora validate` injects these + types into any dataflow that reaches the node by `path:`; a `git:`-sourced node does + **not** pick it up, which is why `dataflow.yml` builds from the checkout. +- **`examples/caller.py`** and **`examples/dora-node.yml`** — The convention in one file: + one instant request, one that drives the arm, and `tcp_pose` arriving while the latter + is still open. + The manifest next to it exists because dora resolves a node's manifest by walking up + from its path: without one, the caller would be matched against the arm node's manifest + and `dora validate --strict` would fail on the entrypoint mismatch. +- **`.gitea/workflows/validate.yml`** — `dora validate`, ruff, pytest and markdownlint. + None of them need a robot. + +## Two manifest rules worth knowing + +A manifest applies to a node iff `/` names the same file as +`/`. That is why `examples/dora-node.yml` says `entrypoint: caller.py` +and not `examples/caller.py`. + +`dora validate --strict` injects a contract type for *every* typed manifest input and then +warns `input_types key "..." not found in inputs list` for any the dataflow does not wire. +A typed optional input therefore forces the example dataflow to wire it. `request` and +`response` dodge this only because they are untyped for the reason given above. ## Key APIs -- **dora Python API** (`dora`): `Node()`, `node.send_output()`, event iteration via `for event in node`. -- **xArm SDK** (`xarm.wrapper.XArmAPI`): `connect()`, `motion_enable()`, `set_mode()`, `set_state()`, - `set_position()`, `move_gohome()`, `get_position()`, `get_servo_angle()`, `set_vacuum_gripper()`. -- Output data is sent as PyArrow arrays with metadata dicts. +- **dora Python API** (`dora`): `Node(name)`, `node.send_output(id, data, metadata)`, + event iteration via `for event in node`. The `request_id` rides in `event["metadata"]` + and in the `metadata` dict passed to `send_output`. +- **xArm SDK** (`xarm.wrapper.XArmAPI`): `connect()`, `motion_enable()`, `set_mode()`, + `set_state()`, `set_position()`, `move_gohome()`, `get_position()`, `get_servo_angle()`, + `get_err_warn_code()`, `get_inverse_kinematics()`, `is_joint_limit()`, + `set_vacuum_gripper()`, `open_lite6_gripper()`, `vc_set_cartesian_velocity()`, and the + cached `position` / `angles` properties the reporter thread reads. ## Tools -- **Issue tracker:** Gitea. Use the Gitea MCP tools (`mcp__gitea__*`) to read and manage issues, - pull requests, and other repository operations. +- **Issue tracker:** Gitea. Use the Gitea MCP tools (`mcp__gitea__*`) to read and manage + issues, pull requests, and other repository operations. ## Rules - Do not mention AI tools in commit messages or documentation. -- This controls a real robot. Test changes carefully and preserve safety checks. +- This controls a real robot. Test changes carefully and preserve the safety checks: the + reachability check before a move, the arrival confirmation after it, and the live error + reads that make both of them mean anything. +- `_stopping` is set to ask the threads to finish, and it is what they sleep on. Never + invert it back to a flag that is set *while running*: `Event.wait(timeout)` returns at + once on an event that is already set, which turns every cadence in this file into a + busy loop. That bug shipped once and cost a core and 1.4 million arm reads a second, + while behaving correctly in every visible way. `test_the_reporter_honours_the_telemetry_interval` + is there to catch it coming back. +- Every request gets exactly one `response`, under its own `request_id`. A path that can + drop one, or send two, is a bug however unlikely it looks — a caller has no other way + to know its request finished. A `wait: false` motion spends its response on the + acknowledgement, so its outcome goes on `motion_state` and nowhere else. +- `motion_state` is keyed by `request_id` and publishes `accepted` before the control + thread reaches the motion. Both exist to close one race: a caller polling a bare + moving/idle flag right after its request would read "not moving" and conclude the + motion had finished. Do not reduce it to a two-valued flag. diff --git a/README.md b/README.md index f51fd3b..77242d4 100644 --- a/README.md +++ b/README.md @@ -1,117 +1,280 @@ -# Dora ULite6 Node +# dora-ulite6 -Dora node for controlling a UFactory Lite6 robot via REST API, web UI, and publishing joint/TCP state. +Dora node for a UFactory Lite6 arm. Every operation is a **service** — dora's own +request/response pattern — and the arm's joint angles, tool pose and connection state +are published as ordinary topics. -## Installation +One request, one response, correlated by the `request_id` you put in the message +metadata. That is the node's whole control surface: there is no second protocol for the +slow operations. A motion takes a `wait` flag — hold me until the arm stops, or answer me +now and I will follow it on `motion_state`. -```bash -uv pip install -e . +Correlation lives in the message metadata because that is dora's convention, not a ROS2 +transliteration: `request_id` is defined in dora's own message crate, and the runtime +treats a port carrying it as one where several Arrow schemas legitimately flow — one per +service. + +The arm runs on threads of its own, so a move that takes ten seconds — or a controller +whose cable was pulled — never stalls the dora event loop, and `tcp_pose` keeps arriving +throughout. + +The driver is ported from the +[littlehand](https://gitea.liria.cl/Littlehand/monolitic-littlehand-python) robot layer, +which was written against this same controller: typed failures, a reachability check +before a move is commanded, and arrival confirmed rather than assumed. + +## The services + +| Service | Arguments | Moves the arm | Description | +| ------- | --------- | ------------- | ----------- | +| `move_to` | `x`, `y`, `z`, optional `roll`, `pitch`, `yaw`, `speed`, `wait` | yes | Move the tool tip to a base-frame pose. Orientation defaults to tool-down (`180, 0, 0`). Refused before anything moves if the straight-line path leaves the joint envelope. | +| `move_joints` | `angles` (list, degrees), optional `speed`, `wait` | yes | Move the joints directly. | +| `home` | optional `wait` | yes | Joint-space home. | +| `home_and_clear` | optional `wait` | yes | Clear a latched error, then drive home. The answer to a joint-limit error, and only to be sent once the area is clear. | +| `stop` | — | no | Halt the arm. Served by a thread of its own, so it neither queues behind the motion it aborts nor holds up the dataflow; the response says the stop was commanded, and whether it took shows on `motion_state`. | +| `clear` | — | no | Clear a latched controller error. Never moves the arm. | +| `recover` | — | no | Reconnect if needed, clear, re-enable motion. Never moves the arm. | +| `freedrive` | `enabled` (bool, default `true`) | no | Enter or leave manual teach mode. | +| `grasp` | — | no | Take hold with whatever `TOOL_TYPE` names. | +| `release` | — | no | Let go. | +| `get_state` | — | no | Adds `connected`, `ready`, `tool_engaged`, `freedrive`, `pose`, `joints` to the response. Served on the event loop and read from the controller's report cache, so it answers *during* a motion. | + +The four that move the arm report `moving` on `robot_state` while they run, and so does +a jog — an arm that is travelling never looks idle. `stop` interrupts whatever is moving; +the interrupted request comes back `ok: false` saying it was aborted. + +## Calling a service + +Send a one-row struct on `request`, with your own id in the metadata: + +```python +node.send_output( + "request", + pa.array([{"service": "move_to", "x": 250.0, "y": 0.0, "z": 200.0, "speed": 40.0}]), + metadata={"request_id": "move-1"}, +) ``` -## Dataflow +The answer arrives on `response` under the same `request_id`: + +```python +{"ok": True, "service": "move_to", "message": "the tool is at (250, 0, 200 mm)"} +``` + +Exactly one response per request, always — including for a request that was refused +before the arm was touched. Because the answer is matched by the id in the metadata and +not by arrival order, several requests can be in flight at once. + +Poses are the **tool tip**, the same frame `tcp_pose` reports, so the arm can be sent +back to a pose that was read off the dataflow. The tool's reach is taken back off before +the flange is commanded. + +## Blocking and non-blocking motions + +`wait` decides only *when* the one response is sent. + +```python +# wait: true (the default) — the response arrives when the arm has stopped. +{"service": "move_to", "x": 250.0, "y": 0.0, "z": 200.0} + +# wait: false — the response arrives at once, as an acknowledgement. +{"service": "move_to", "x": 250.0, "y": 0.0, "z": 200.0, "wait": False} +``` + +Either way it is **one response per request**. With `wait: false` that response is the +acknowledgement, and how the motion actually ended is reported on `motion_state` — never +as a second response, which would break the id a caller correlates by. + +### Following a motion: `motion_state` + +Published on every change, as `{request_id, service, state, message}`: + +| State | Meaning | +| ----- | ------- | +| `accepted` | Taken, not started. | +| `moving` | The arm is being driven. | +| `done` | Finished; the arm arrived. | +| `failed` | The arm refused it, or never got there. | +| `preempted` | A newer motion took its place. | + +`accepted` is not decoration. Without it, a caller that sends a motion and then polls a +two-valued `moving`/`idle` state reads "not moving" in the window before the control +thread picks the request up, and concludes the motion it just asked for had already +finished. Keying the state by `request_id` and publishing `accepted` closes that window: +the caller can always tell *"mine has not started"* from *"mine has finished"*. + +The `request_id` is in the fields rather than the metadata, because this is a broadcast of +the arm's state rather than an answer to a request — anyone may read it, and +`get_state` sees the same value. + +`tcp_pose` also keeps arriving throughout, at `TELEMETRY_INTERVAL_MS`: the thread that +publishes it is not the thread inside the motion. + +### A new motion preempts the one in play + +The arm is one resource — there is never more than one motion. A new motion cuts the +running one short (through the same stop a caller could send) and takes its place; a +motion still waiting its turn is dropped without ever moving anything. Each one is still +answered exactly once: by its response if a caller is still waiting, and on +`motion_state` as `preempted` either way. + +So a sequence of positions is driven by the caller, one at a time — send the next when +you see `done`. Sending them all at once does not queue them; the last one wins. + +## Jogging + +`jog` is a topic, not a service: a velocity command expires on the controller's own TTL, +so a sender keeps sending and nobody waits for an answer. + +```python +node.send_output("jog", pa.array([{"velocity": [20.0, 0, 0, 0, 0, 0]}])) +``` + +It still drives the arm, so it behaves like a motion where that matters: the first +non-zero jog preempts a motion in play and `robot_state` turns to `moving`. A zero +velocity stops it, and so does simply going quiet — the node returns to `ready` once +`VELOCITY_COMMAND_TTL_S` has passed, because that is when the controller has expired the +command. + +## Telemetry + +| Output | Type | Description | +| ------ | ---- | ----------- | +| `joint_pose` | `Float32` | The six joint angles in degrees, base to wrist. | +| `tcp_pose` | `Float32` | `[x, y, z, roll, pitch, yaw]` in mm and degrees: the tool tip in the base frame. | +| `robot_state` | `String` | `connecting`, `ready`, `moving`, `recovering` or `unavailable`, on every change. | +| `motion_state` | struct | Where the current motion stands — see above. | + +`tick` sets how often the newest reading is *published*; `TELEMETRY_INTERVAL_MS` sets how +often the arm is *read*. A tick with nothing new publishes nothing rather than repeating +a stale pose. + +## Usage + +Add to your dataflow: ```yaml nodes: - id: ulite6 git: ssh://git@gitea.liria.cl:5001/liria/dora-ulite6.git - git_checkout: v0.1.0 - build: uv pip install -e . - path: dora_ulite6/main.py + tag: v0.3.0 + build: pip install . + path: dora-ulite6 inputs: - tick: dora/timer/millis/10 - outputs: [status, joint_pose, tcp_pose] - inputs: [command] + tick: dora/timer/millis/100 + request: planner/request + outputs: + - response + - motion_state + - joint_pose + - tcp_pose + - robot_state env: ROBOT_IP: "192.168.1.192" - DEFAULT_SPEED: "30" - DEFAULT_UNITS: "mm" - API_HOST: "0.0.0.0" - API_PORT: "8080" - VACUUM_ENABLED: "false" + TOOL_TYPE: "vacuum" ``` +Run the dataflow with `--uv` (`dora run dataflow.yml --uv`) so the node gets its own +managed environment. + +### Runnable example + +[`dataflow.yml`](dataflow.yml) builds the node from this checkout and wires +[`examples/caller.py`](examples/caller.py), which sends a non-blocking motion, follows it +on `motion_state`, and then preempts it with a second one: + +```bash +dora run dataflow.yml --uv +``` + +Building from the checkout (`path:` + `build:`) rather than from a tag is what lets +`dora validate` pick up the manifest in [`dora-node.yml`](dora-node.yml). + +**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+, `uv`, and +network access to the arm's controller. + +## Lifecycle + +The node connects to the dataflow **before** it touches the arm, so a startup that never +joins cannot have moved a robot first. + +**On a restart** (`restart_policy` in your dataflow), `INIT_MODE` is skipped: the arm is +left wherever it was. Driving it home because the process came back is a motion nobody +asked for. + +**On shutdown**, the arm is halted before the node exits. A motion lives in the +controller, not in this process — dropping the connection does not abort it — so exiting +without commanding a stop would leave an arm travelling with nobody left to stop it. If +the control thread is still inside a blocking SDK call afterwards, the node leaves +without it rather than hanging until dora loses patience. + ## Configuration | Env Variable | Default | Description | | ------------ | ------- | ----------- | -| `ROBOT_IP` | `192.168.1.192` | Robot IP address | -| `DEFAULT_SPEED` | `30` | Movement speed (mm/s) | -| `DEFAULT_UNITS` | `mm` | Position units (mm or m) | -| `API_HOST` | `0.0.0.0` | API server host | -| `API_PORT` | `8080` | API server port | -| `VACUUM_ENABLED` | `false` | Enable vacuum gripper controls | -| `TOOL_TYPE` | `none` | Mounted tool: `none`, `vacuum`, `gripper` | -| `TOOL_OFFSET_MM` | `0.0` | Additional Z-offset in mm added on top of the tool preset | -| `INIT_MODE` | `none` | Startup positioning: `home`, `pose`, or `none` | -| `INIT_X/Y/Z` | `300/0/250` | Initial pose coordinates (mm) | -| `INIT_ROLL/PITCH/YAW` | `180/0/0` | Initial pose orientation (deg) | -| `INIT_SPEED` | `50` | Startup movement speed (mm/s) | +| `ROBOT_IP` | `192.168.1.192` | Controller address | +| `TOOL_TYPE` | `none` | What is mounted: `none`, `vacuum`, `gripper` | +| `TOOL_OFFSET_MM` | `0.0` | Extra reach past the flange, on top of the tool's own | +| `DEFAULT_SPEED` | `30` | Cartesian speed (mm/s) for a `move_to` with no speed | +| `DEFAULT_JOINT_SPEED` | `20` | Joint speed (deg/s) for a `move_joints` with no speed | +| `TELEMETRY_INTERVAL_MS` | `100` | How often the arm is read, and how often telemetry is produced | +| `RECONNECT_INTERVAL_S` | `5.0` | How long to wait before retrying an unreachable arm | +| `INIT_MODE` | `none` | Startup positioning: `none`, `home`, `pose` | +| `INIT_X/Y/Z` | `300/0/250` | Startup pose position (mm, tool tip) | +| `INIT_ROLL/PITCH/YAW` | `180/0/0` | Startup pose orientation (deg) | +| `INIT_SPEED` | `50` | Startup move speed (mm/s) | -## Web UI +The motion limits the driver enforces are tunable too, and the defaults are the ones +littlehand runs with. The arrival tolerances are wide on purpose: whether a motion +happened is decided by the controller's error, and these only catch an arm that never +left where it stood. -Access at `http://localhost:8080/` +| Env Variable | Default | Description | +| ------------ | ------- | ----------- | +| `ARRIVAL_TOLERANCE_MM` | `25.0` | How far off the target still counts as arrived | +| `ARRIVAL_TOLERANCE_DEG` | `15.0` | The same for orientation, and for homing | +| `ARRIVAL_TIMEOUT_S` | `2.0` | How long to keep asking before calling a move interrupted | +| `JOINT_LIMIT_MARGIN_DEG` | `1.0` | How clear of its bound a joint must stay for a pose to be commanded | +| `PATH_SAMPLES` | `4` | How many points along the straight line are checked for reachability | +| `READY_TIMEOUT_S` | `3.0` | How long `recover` waits for a movable state | +| `HOME_TIMEOUT_S` | `20.0` | How long homing may take | +| `TOOL_SETTLE_S` | `2.0` | How long to wait for suction to build or fingers to close | +| `VELOCITY_COMMAND_TTL_S` | `0.5` | The controller's own expiry for one `jog` command | -- Live status, TCP pose, and joint angles -- Home and Reset buttons -- Move to position form -- Vacuum gripper controls (when enabled) - -## REST API - -Interactive docs at `http://localhost:8080/docs` - -| Method | Endpoint | Description | -| ------ | -------- | ----------- | -| GET | `/` | Web control interface | -| GET | `/api/status` | Robot status (connected, errors) | -| GET | `/api/pose` | Current TCP pose | -| GET | `/api/joints` | Current joint angles | -| GET | `/api/config` | API configuration | -| POST | `/api/home` | Go to home position | -| POST | `/api/reset` | Clear errors and reset state | -| POST | `/api/move_to` | Move to position | -| POST | `/api/move_to_pose` | Move to full pose | -| POST | `/api/disconnect` | Disconnect from robot | - -### Vacuum Gripper (when `VACUUM_ENABLED=true`) - -| Method | Endpoint | Description | -| ------ | -------- | ----------- | -| GET | `/api/vacuum` | Get vacuum status | -| POST | `/api/vacuum/on` | Turn vacuum on | -| POST | `/api/vacuum/off` | Turn vacuum off | - -## CLI Examples +## Development ```bash -# Get pose -curl http://localhost:8080/api/pose - -# Go home -curl -X POST http://localhost:8080/api/home - -# Move to position -curl -X POST http://localhost:8080/api/move_to \ - -H "Content-Type: application/json" \ - -d '{"x": 200, "y": 0, "z": 300}' - -# Move with orientation -curl -X POST http://localhost:8080/api/move_to_pose \ - -H "Content-Type: application/json" \ - -d '{"x": 200, "y": 0, "z": 300, "roll": 180, "pitch": 0, "yaw": 0}' +uv sync --group dev +uv run pytest # 86 tests, no robot needed +uv run ruff check . ``` -## Dora Outputs +## Bridging to ROS2 -- `status` - JSON: `{ok, action, message, timestamp_ns}` -- `joint_pose` - 6 joint angles in degrees -- `tcp_pose` - `[x, y, z, roll, pitch, yaw]` in mm/deg — reflects tool tip when `TOOL_TYPE` is set +Nothing here depends on ROS2, and no ROS2 message definition is shipped. It is worth +knowing anyway that dora's `ros2:` service bridge speaks exactly this convention — it +emits `request` and takes a response under the same `request_id`. So exposing these +services to a ROS2 graph later is a matter of adding bridge nodes to a dataflow and +mapping the struct fields, with no change to this code. -## Dora Inputs +## Migrating from v0.2.x -- `command` - JSON string: `{id, action, payload}` - - `id`: unique command id (required) - - `action`: `home`, `move_to`, or `move_to_pose` - - `payload`: fields for move (`x`, `y`, `z`, `roll`, `pitch`, `yaw`, `speed`) +- **The `command` input and the `status` output are gone**, replaced by `request` and + `response`. A command's `{id, action, payload}` JSON becomes a service request, and the + id moves from the payload into the message metadata, where dora's own pattern puts it. +- **Payloads are Arrow structs, not JSON strings.** `pa.array([{...}])` instead of + `json.dumps`. +- **`action` is now `service`**, and `vacuum_on`/`vacuum_off` are `grasp`/`release`. +- **`jog` is its own input** rather than a command. +- **There is no `started` message.** A request produces exactly one response. Follow a + motion on `motion_state`, which reports `accepted`, `moving` and then how it ended. +- **Telemetry no longer stops while the arm is moving**, thanks to the reporter thread. +- **`stop` answers "commanded" rather than "done."** It is handed to a thread of its own + instead of being run inline, because the SDK spends up to three seconds confirming a + stop and a dataflow router cannot go away that long. -`status` will include `command_id` for command responses. +## Migrating from v0.1.x + +See the v0.2.0 notes in the git history for the removal of the FastAPI REST server and +web UI (still reachable at tag `v0.1.2`), the `DEFAULT_UNITS` removal, and the move to +tool-tip poses. diff --git a/dataflow.yml b/dataflow.yml index ee68ccc..8b4310f 100644 --- a/dataflow.yml +++ b/dataflow.yml @@ -1,19 +1,37 @@ +# The repository's own example: builds and runs the node from this checkout, so +# `dora validate` picks up the typed contract in dora-node.yml next to it. +# A consumer wires the node from a tag instead — see README.md. +# +# Run it with `--uv` so every Python node gets its own managed environment. +# +# `examples/caller.py` shows the whole convention: a non-blocking motion +# answered at once, its progress followed on `motion_state`, and a second motion +# preempting the first. +strict_types: true + nodes: - id: ulite6 - build: uv pip install -e . - path: dora_ulite6/main.py + path: dora-ulite6 + build: pip install . inputs: - tick: dora/timer/millis/10 + tick: dora/timer/millis/100 + request: caller/request outputs: - - status + - response + - motion_state - joint_pose - tcp_pose + - robot_state env: ROBOT_IP: "192.168.1.192" - DEFAULT_SPEED: "30" - DEFAULT_UNITS: "mm" - API_HOST: "0.0.0.0" - API_PORT: "9000" - VACUUM_ENABLED: "true" TOOL_TYPE: "vacuum" - TOOL_OFFSET_MM: "0.0" + INIT_MODE: "none" + + - id: caller + path: examples/caller.py + inputs: + tick: dora/timer/millis/500 + response: ulite6/response + motion_state: ulite6/motion_state + outputs: + - request diff --git a/dora-node.yml b/dora-node.yml new file mode 100644 index 0000000..4eaf428 --- /dev/null +++ b/dora-node.yml @@ -0,0 +1,128 @@ +# 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 no robot. +# +# The `request` and `response` ports are deliberately **untyped**. They carry +# dora's pattern-correlation metadata (`request_id`), and dora defines that as +# the marker for a port where several Arrow schemas legitimately flow — one per +# service — distinguished by metadata rather than by a fixed Arrow type. The +# runtime skips type checks on such messages, so annotating them would claim a +# contract neither side keeps. +apiVersion: 1 +name: dora-ulite6 +namespace: liria +description: UFactory Lite6 arm node serving motion and control services, and publishing joint and tool pose +categories: [robot, actuator] +keywords: [ufactory, lite6, xarm, robot-arm, manipulator, service, vacuum, gripper] + +runtime: python +entrypoint: dora-ulite6 +# `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: + type: std/core/v1/UInt64 + required: true + description: >- + Each tick publishes whatever the arm threads produced since the last one. + It does not drive the arm — the tick rate is the publication rate, and + TELEMETRY_INTERVAL_MS is how often the arm is actually read. Connect to + dora/timer/millis/N. + request: + required: true + description: >- + A service request: a one-row struct {service, ...arguments}, with the + caller's `request_id` in the metadata. Services: move_to, move_joints, + home, home_and_clear, clear, recover, stop, freedrive, grasp, release, + get_state. Answered on `response` under the same request_id, exactly once. + A motion takes `wait`: true (the default) holds the caller until the arm + stops; false answers at once with an acknowledgement and reports the rest + on `motion_state`. + jog: + required: false + description: >- + A continuous velocity command: a one-row struct {velocity} of 6 numbers + [vx, vy, vz, wx, wy, wz] in mm/s and deg/s. A topic rather than a service + because it expires on the controller's own TTL and a sender must keep + sending; nothing is published in answer. It drives the arm, so the first + non-zero one preempts a motion in play and robot_state turns to moving. + +outputs: + response: + description: >- + The answer to one service request, under its `request_id`: a struct + {ok, service, message, ...extras}. get_state adds connected, ready, + tool_engaged, freedrive, pose and joints. Exactly one response per + request, including for one that was refused or preempted. + motion_state: + description: >- + Where the current motion stands, published on every change: a struct + {request_id, service, state, message} with state one of accepted, moving, + done, failed, preempted. The request_id is in the fields rather than the + metadata because this is the arm's state, not an answer to a request. A + new motion preempts the one in play, so there is only ever one. + joint_pose: + type: std/core/v1/Float32 + description: The six joint angles in degrees, base to wrist. + tcp_pose: + type: std/core/v1/Float32 + description: >- + The mounted tool's tip as [x, y, z, roll, pitch, yaw] in mm and degrees, + in the robot base frame. With TOOL_TYPE none this is the flange itself. + robot_state: + type: std/core/v1/String + description: >- + One of connecting, ready, moving, recovering, unavailable. Published on + every change, never repeated. + +env: + ROBOT_IP: + type: string + description: Controller address. Defaults to 192.168.1.192. + TOOL_TYPE: + type: string + description: What is mounted at the flange - none, vacuum or gripper. Defaults to none. + TOOL_OFFSET_MM: + type: float + description: Extra reach past the flange, added to the mounted tool's own. Defaults to 0. + DEFAULT_SPEED: + type: float + description: Cartesian speed in mm/s for a move_to with no speed of its own. Defaults to 30. + DEFAULT_JOINT_SPEED: + type: float + description: Joint speed in deg/s for a move_joints with no speed of its own. Defaults to 20. + TELEMETRY_INTERVAL_MS: + type: int + description: >- + How often the reporter reads the arm, and so how often telemetry is + produced. Keeps running through a motion. Defaults to 100. + RECONNECT_INTERVAL_S: + type: float + description: How long to wait before trying an unreachable arm again. Defaults to 5. + INIT_MODE: + type: string + description: Where the arm goes on startup - none, home or pose. Defaults to none. + +example: | + - id: ulite6 + git: ssh://git@gitea.liria.cl:5001/liria/dora-ulite6.git + tag: v0.3.0 + build: pip install . + path: dora-ulite6 + inputs: + tick: dora/timer/millis/100 + request: planner/request + outputs: + - response + - joint_pose + - tcp_pose + - robot_state + env: + ROBOT_IP: "192.168.1.192" + TOOL_TYPE: "vacuum" diff --git a/dora_ulite6/__init__.py b/dora_ulite6/__init__.py index 1dad1aa..38bd87f 100644 --- a/dora_ulite6/__init__.py +++ b/dora_ulite6/__init__.py @@ -1 +1 @@ -"""Dora ULite6 node package.""" +"""Dora node for a UFactory Lite6 arm: telemetry out, commands in.""" diff --git a/dora_ulite6/config.py b/dora_ulite6/config.py new file mode 100644 index 0000000..c7da41b --- /dev/null +++ b/dora_ulite6/config.py @@ -0,0 +1,130 @@ +"""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 on the +first motion — the same reason the ZED node parses its depth mode up front. +""" + +import os +from dataclasses import dataclass, field + +from dora_ulite6.effectors import EndEffector, get_effector +from dora_ulite6.pose import RobotPose + +INIT_MODES = ("none", "home", "pose") + + +def env_str(name: str, default: str) -> str: + """Return the variable, or ``default`` when it is unset.""" + return os.getenv(name, default) + + +def env_float(name: str, default: float) -> float: + """Return the variable as a float, naming it if it is not one.""" + value = os.getenv(name) + if value is None or not value.strip(): + 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: + """Return the variable as an integer, naming it if it is not one.""" + value = os.getenv(name) + if value is None or not value.strip(): + return default + try: + return int(value) + except ValueError: + raise ValueError(f"{name} must be an integer, not {value!r}") from None + + +@dataclass +class MotionConfig: + """Driver-level motion limits and timeouts. + + The arrival tolerances are wide on purpose: whether a motion happened is + decided by the controller's error, and these only catch an arm that never + left where it stood. Tightening them starts refusing moves that worked. + """ + + arrival_tolerance_mm: float = 25.0 + arrival_tolerance_deg: float = 15.0 + arrival_timeout_s: float = 2.0 + # How clear of its own bound a joint solution must stay for a pose to be + # commanded, and how many points along the straight line are checked. + joint_limit_margin_deg: float = 1.0 + path_samples: int = 4 + ready_timeout_s: float = 3.0 + home_timeout_s: float = 20.0 + tool_settle_s: float = 2.0 + # The controller's own expiry for one jog command: the arm keeps moving at + # the commanded velocity until this runs out, so a sender that stops sending + # stops the arm. + velocity_command_ttl_s: float = 0.5 + + @classmethod + def from_env(cls) -> "MotionConfig": + """Read the motion limits and timeouts from the environment.""" + return cls( + arrival_tolerance_mm=env_float("ARRIVAL_TOLERANCE_MM", 25.0), + arrival_tolerance_deg=env_float("ARRIVAL_TOLERANCE_DEG", 15.0), + arrival_timeout_s=env_float("ARRIVAL_TIMEOUT_S", 2.0), + joint_limit_margin_deg=env_float("JOINT_LIMIT_MARGIN_DEG", 1.0), + path_samples=env_int("PATH_SAMPLES", 4), + ready_timeout_s=env_float("READY_TIMEOUT_S", 3.0), + home_timeout_s=env_float("HOME_TIMEOUT_S", 20.0), + tool_settle_s=env_float("TOOL_SETTLE_S", 2.0), + velocity_command_ttl_s=env_float("VELOCITY_COMMAND_TTL_S", 0.5), + ) + + +@dataclass +class Config: + """The node's settings: where the arm is, what it carries, and how it starts.""" + + robot_ip: str = "192.168.1.192" + effector: EndEffector = field(default_factory=lambda: get_effector("none")) + # The mounted tool's own reach past the flange, plus TOOL_OFFSET_MM. + tool_offset_mm: float = 0.0 + default_speed: float = 30.0 + default_joint_speed: float = 20.0 + # How often the control thread re-reads the arm. The dataflow tick only + # decides how often the newest reading is published. + telemetry_interval_s: float = 0.1 + # How long to wait before trying an unreachable arm again. + reconnect_interval_s: float = 5.0 + init_mode: str = "none" + init_pose: RobotPose = field(default_factory=lambda: RobotPose(300.0, 0.0, 250.0)) + init_speed: float = 50.0 + motion: MotionConfig = field(default_factory=MotionConfig) + + @classmethod + def from_env(cls) -> "Config": + """Read every setting from the environment, refusing one the node cannot use.""" + effector = get_effector(env_str("TOOL_TYPE", "none").lower()) + init_mode = env_str("INIT_MODE", "none").lower() + if init_mode not in INIT_MODES: + raise ValueError(f"INIT_MODE must be one of {', '.join(INIT_MODES)}, not {init_mode!r}") + return cls( + robot_ip=env_str("ROBOT_IP", "192.168.1.192"), + effector=effector, + tool_offset_mm=effector.offset_mm + env_float("TOOL_OFFSET_MM", 0.0), + default_speed=env_float("DEFAULT_SPEED", 30.0), + default_joint_speed=env_float("DEFAULT_JOINT_SPEED", 20.0), + telemetry_interval_s=env_int("TELEMETRY_INTERVAL_MS", 100) / 1000.0, + reconnect_interval_s=env_float("RECONNECT_INTERVAL_S", 5.0), + init_mode=init_mode, + init_pose=RobotPose( + x=env_float("INIT_X", 300.0), + y=env_float("INIT_Y", 0.0), + z=env_float("INIT_Z", 250.0), + roll=env_float("INIT_ROLL", 180.0), + pitch=env_float("INIT_PITCH", 0.0), + yaw=env_float("INIT_YAW", 0.0), + ), + init_speed=env_float("INIT_SPEED", 50.0), + motion=MotionConfig.from_env(), + ) diff --git a/dora_ulite6/control.py b/dora_ulite6/control.py new file mode 100644 index 0000000..f53dc7f --- /dev/null +++ b/dora_ulite6/control.py @@ -0,0 +1,609 @@ +"""The threads that own the arm, and the mailboxes the event loop drains. + +**Nothing outside this file may call into the driver**, with two exceptions +written to be safe from another thread: :meth:`ULite6.stop`, and +:meth:`ULite6.read_report`, which reads the controller's report cache rather +than the wire. + +There are two threads because a motion must not stop the arm being watched. The +xArm SDK blocks: a move waits out the motion, and a read from a controller whose +cable was pulled blocks until its own socket timeout. So: + +- The **control thread** runs one job at a time and is allowed to block. It is + where every service is carried out, including the motions that take seconds. +- The **reporter thread** never blocks. It reads the report cache at a fixed + cadence, so `tcp_pose` and `joint_pose` keep flowing right through a ten-second + move — which is the only way a caller can follow one, since the thread that + started it is inside it until it ends. +- The **stopper thread** exists only to call `stop`. It cannot be the control + thread (a stop must not queue behind the motion it aborts) and it must not be + the event loop: the SDK's `emergency_stop` re-commands the controller until it + confirms, for up to three seconds, and a dataflow router cannot go away for + three seconds. Requests to it coalesce — several stops in a row are one stop. + +The dora event loop is a third party to both: it drains mailboxes and publishes, +and never waits for the arm. +""" + +import queue +import threading +import time +from dataclasses import dataclass, field +from typing import Any + +from dora_ulite6 import services +from dora_ulite6.config import Config +from dora_ulite6.driver import ULite6 +from dora_ulite6.errors import ArmNeedsHomingError, RobotUnavailableError +from dora_ulite6.log import log +from dora_ulite6.pose import RobotPose, with_tool_offset + +# The states the node reports on `robot_state`, in the order a healthy start +# walks through them. +CONNECTING = "connecting" +READY = "ready" +MOVING = "moving" +RECOVERING = "recovering" +UNAVAILABLE = "unavailable" + + +# The motion lifecycle published on `motion_state`. `accepted` exists precisely +# to close the race a bare moving/idle flag leaves open: between a caller being +# told its request was taken and the control thread reaching it, a two-valued +# state still reads "not moving", and a caller polling for completion would +# conclude the motion it just asked for had already finished. +ACCEPTED = "accepted" +DONE = "done" +FAILED = "failed" +PREEMPTED = "preempted" + + +@dataclass +class ServiceCall: + """One service request waiting on the control thread.""" + + request_id: str + row: dict + # Whether the caller is held until the arm stops. False means the response + # was already sent as an acknowledgement, and completion is only reported on + # `motion_state` — which is what keeps it one response per request. + wait: bool = True + # Set when a newer motion took this one's place, before or during its run. + preempted_by: str | None = None + + @property + def service(self) -> str: + """The service this request names.""" + return self.row.get(services.SERVICE, "") + + @property + def moves_the_arm(self) -> bool: + """Whether carrying this out drives the arm, and so preempts and is preemptable.""" + return self.service in services.MOVES_THE_ARM + + +@dataclass +class Jog: + """One velocity command. Not a service: nobody waits for an answer.""" + + velocity: list[float] + + +@dataclass +class Telemetry: + """One reading of the arm: joint angles in degrees, tool-tip pose in mm and degrees.""" + + joints: list[float] + tip: RobotPose + timestamp_ns: int + + +@dataclass +class Message: + """Something to publish: an output id, its fields, and its correlation metadata.""" + + output: str + fields: dict[str, Any] + metadata: dict[str, str] = field(default_factory=dict) + + +class Control: + """Owns the arm and hands the event loop what there is to publish.""" + + def __init__(self, config: Config, restarted: bool = False): + self.config = config + # A restarted node inherits an arm that is wherever it was left. Driving + # it to INIT_MODE because the process came back is a motion nobody asked + # for, so a restart skips it. + self.restarted = restarted + self.robot: ULite6 | None = None + self._jobs: queue.Queue[ServiceCall | Jog] = queue.Queue() + self._lock = threading.Lock() + self._telemetry: Telemetry | None = None + self._outbox: list[Message] = [] + self._state = CONNECTING + self._state_changes: list[str] = [CONNECTING] + # The motion the control thread is inside right now, and whether the + # last preemption left a software stop for the next motion to clear. + self._running_motion: ServiceCall | None = None + self._stopped_for_preempt = False + # Set by anyone who needs the arm halted; the stopper thread drains it. + self._stop_wanted = threading.Event() + # Whether a velocity command is currently keeping the arm moving, and + # when the last one arrived — the controller expires them on its own. + self._jogging = False + self._last_jog = 0.0 + # Whether the arm has been reached once and put where INIT_MODE wants it. + self._started = False + # Set to ask the threads to finish. The sense matters: this is waited on + # as the threads' sleep, and an event that is *set* while running makes + # every wait() return at once — a sleep that never sleeps, which is a + # busy loop rather than a cadence. + self._stopping = threading.Event() + self._control = threading.Thread(target=self._run_control, name="ulite6-control", daemon=True) + self._reporter = threading.Thread(target=self._run_reporter, name="ulite6-reporter", daemon=True) + self._stopper = threading.Thread(target=self._run_stopper, name="ulite6-stopper", daemon=True) + + def start(self) -> None: + """Start the three threads; they reach the arm themselves, so this never blocks.""" + self._control.start() + self._reporter.start() + self._stopper.start() + + def shutdown(self) -> bool: + """Halt the arm, stop the threads, and say whether they really finished. + + Halting first is the point. A motion lives in the *controller*, not in + this process: dropping the connection does not abort it, so a node that + exits mid-move leaves an arm travelling with nobody left to stop it. The + stop is commanded here on the caller's thread and waited for, because by + now the event loop has finished routing and there is nothing left to + starve. + + Returns False if the control thread is still inside the SDK afterwards, + which is the caller's cue to leave without waiting for it. + """ + if self.robot is not None: + self._attempt(self.robot.stop) + self._stopping.set() + self._stop_wanted.set() + self._stopper.join(timeout=1.0) + self._reporter.join(timeout=1.0) + self._control.join(timeout=self.config.motion.ready_timeout_s) + if self._control.is_alive(): + log("the control thread is still inside the SDK; leaving it behind") + return False + return True + + # --- what the event loop puts in --- + + def submit(self, job: ServiceCall | Jog) -> None: + """Queue work for the control thread. Returns immediately.""" + self._jobs.put(job) + + def request_stop(self, request_id: str = "") -> None: + """Ask for the arm to be halted, and come straight back. + + Answering before the arm has actually stopped is deliberate: the reply + says the stop was commanded, and whether it took is reported where every + other outcome is, on `motion_state` and `robot_state`. Waiting here + instead would hand the caller a better answer at the price of the whole + dataflow, for up to three seconds. + """ + self._stop_wanted.set() + if request_id: + self._respond( + ServiceCall(request_id=request_id, row={services.SERVICE: services.STOP}), + (True, {"message": "stop commanded"}), + ) + + def _preempt_motions(self, by: str) -> None: + """Mark the running motion and any still waiting, and halt the arm if one was moving.""" + with self._lock: + running = self._running_motion + waiting = [ + job + for job in list(self._jobs.queue) + if isinstance(job, ServiceCall) and job.moves_the_arm and job.preempted_by is None + ] + for job in [running, *waiting]: + if job is not None: + job.preempted_by = by + # The next motion to actually run clears the software stop this is + # about to cause. Only ever set here, never unset: preempting a + # motion that had not started yet stops nothing, and must not clear a + # flag left by an earlier preemption that nothing has consumed — that + # would send the next motion at an arm still in its stopped state, + # where it would be refused the moment it was commanded. + self._stopped_for_preempt = self._stopped_for_preempt or running is not None + if running is not None: + log(f"{running.service} ({running.request_id}) preempted by {by}") + self.request_stop() + + def start_jog(self, velocity: list[float]) -> None: + """Take one velocity command, preempting a motion the first time it moves the arm. + + A jog arrives many times a second, so everything here happens on the + transitions only: a stream of them is one preemption and one state + change, not hundreds. + """ + moving = any(velocity) + if moving and not self._jogging: + self._preempt_motions("jog") + self._set_state(MOVING) + with self._lock: + was_jogging, self._jogging = self._jogging, moving + self._last_jog = time.monotonic() + # Only a jog that was in fact jogging ends one. A zero velocity from a + # teleop source that is merely idling says nothing about a motion that + # owns the arm, and must not answer for it. + if not moving and was_jogging: + self._idle_unless_busy() + self._jobs.put(Jog(velocity=velocity)) + + def start_motion(self, call: ServiceCall) -> None: + """Take a motion, preempting whatever motion was already in play. + + The arm is one resource: there is never more than one motion, so a new + one replaces the old rather than queueing behind it. The running motion + is cut short through the arm — the same stop a caller can send — and any + motion still waiting its turn is dropped without ever moving anything. + + Each preempted request is answered exactly once, as every request is: if + its caller is still waiting, by its response; if it was a `wait: false` + request, it was acknowledged when it arrived, and the preemption shows + only on `motion_state`. + """ + self._preempt_motions(call.request_id) + self._motion_state(call, ACCEPTED, "queued") + if not call.wait: + self._respond(call, (True, {"message": "accepted", "accepted": True})) + self._jobs.put(call) + + def post(self, message: Message) -> None: + """Add a message for the event loop to publish on the next tick. + + The loop uses this to answer a request it refused itself, so a malformed + message is still answered under its own correlation id without the arm + threads being troubled by it. + """ + self._post(message) + + def call_now(self, request_id: str, row: dict) -> None: + """Run a service on the calling thread, for the ones that must not queue. + + Only `stop` takes this path: queued, it would sit behind the very motion + it exists to abort. It is safe here because the driver method it reaches + is the one written for another thread. + """ + call = ServiceCall(request_id=request_id, row=row) + if self.robot is None: + self._respond(call, (False, {"message": "the arm has not been reached yet"})) + return + self._respond(call, self._attempt(lambda: services.run(self.robot, row))) + + # --- what the event loop drains --- + + def take_telemetry(self) -> Telemetry | None: + """Return the newest reading and clear it, so a tick never republishes a stale pose.""" + with self._lock: + telemetry, self._telemetry = self._telemetry, None + return telemetry + + def take_messages(self) -> list[Message]: + """Return every response recorded since the last call.""" + with self._lock: + outbox, self._outbox = self._outbox, [] + return outbox + + def take_state_changes(self) -> list[str]: + """Return every state transition since the last call.""" + with self._lock: + changes, self._state_changes = self._state_changes, [] + return changes + + def _post(self, message: Message) -> None: + with self._lock: + self._outbox.append(message) + + # --- the control thread --- + + def _run_control(self) -> None: + while not self._stopping.is_set(): + if not self._started: + self._start_up() + continue + try: + job = self._jobs.get(timeout=0.1) + except queue.Empty: + continue + if isinstance(job, Jog): + self._run_jog(job) + else: + self._run_service(job) + + def _start_up(self) -> None: + """Reach an arm that answers, put it where INIT_MODE says, and start serving. + + The driver is built once and then retried through its own ``connect()``, + which reuses the SDK handle: rebuilding it on every attempt would leak a + socket and its threads for every second an absent arm stays absent. + """ + self._set_state(CONNECTING) + if self.robot is None: + self.robot = ULite6(self.config) + try: + if not self.robot.is_connected() and not self.robot.connect(): + raise RobotUnavailableError(f"robot at {self.config.robot_ip} is not reachable") + self._start_position() + except RobotUnavailableError as error: + log(f"{error}; retrying in {self.config.reconnect_interval_s:.0f}s") + self._set_state(UNAVAILABLE) + self._serve_while_down(self.config.reconnect_interval_s) + return + self._started = True + self._set_state(READY) + + def _serve_while_down(self, seconds: float) -> None: + """Answer what can be answered without a serving arm, until it is time to retry. + + This is the wait between connection attempts, and it serves the queue + rather than sleeping through it. Sitting silent is the one thing a caller + must never get: an arm that is down is an outcome to report, not a reason + to stop answering, and a request left in the queue here would be answered + only if the arm ever came back. + """ + deadline = time.monotonic() + seconds + while not self._stopping.is_set(): + remaining = deadline - time.monotonic() + if remaining <= 0: + return + try: + job = self._jobs.get(timeout=min(remaining, 0.1)) + except queue.Empty: + continue + if isinstance(job, ServiceCall): + self._serve_without_arm(job) + + def _serve_without_arm(self, call: ServiceCall) -> None: + """Answer one request that arrived before the arm was serving. + + A motion is refused outright. Holding it would be worse than silence: + the arm is somewhere unknown, nobody is watching, and the motion would + fire unattended the moment the link came back. + + Everything else is tried on the arm, because a controller that answers + while refusing to serve is exactly what `recover` and `clear` are for, + and refusing them here would leave the one state they exist to escape + with no way out of it. + """ + if call.moves_the_arm: + self._finish_motion(call, FAILED, False, "the arm is not serving yet") + return + self._respond(call, self._attempt(lambda: services.run(self.robot, call.row))) + + def _start_position(self) -> None: + """Put the arm where INIT_MODE says it should start, if anywhere.""" + if self.restarted and self.config.init_mode != "none": + log(f"restarted: leaving the arm where it is rather than running INIT_MODE={self.config.init_mode}") + return + if self.config.init_mode == "home": + log("moving to home") + self.robot.go_home() + elif self.config.init_mode == "pose": + log(f"moving to the initial pose {self.config.init_pose.as_list()}") + self.robot.move_to( + with_tool_offset(self.config.init_pose, -self.config.tool_offset_mm), + self.config.init_speed, + ) + + def _run_service(self, call: ServiceCall) -> None: + """Carry out one service and post its response.""" + if call.moves_the_arm: + self._run_motion(call) + return + self._respond(call, self._attempt(lambda: services.run(self.robot, call.row))) + + def _run_motion(self, call: ServiceCall) -> None: + """Carry out one motion, reporting its whole life on `motion_state`. + + Blocks for as long as the arm takes. `robot_state` says `moving` + meanwhile, so a caller watching it can tell a slow service from a stalled + one, and `tcp_pose` keeps arriving because the reporter thread is not the + one in here. + """ + if call.preempted_by is not None: + # Replaced while it waited its turn; it never moved anything. + self._finish_motion(call, PREEMPTED, False, f"preempted by {call.preempted_by}") + return + with self._lock: + self._running_motion = call + clear_first = self._stopped_for_preempt + self._stopped_for_preempt = False + self._set_state(MOVING) + self._motion_state(call, MOVING, "") + if self._leave_jog(): + # A pose command would switch the controller out of velocity mode + # behind the driver's back, leaving its own bookkeeping saying the + # arm is still jogging. Leaving through the front door keeps them + # agreed. + self._attempt(lambda: self.robot.set_cartesian_velocity([0.0] * 6)) + if clear_first: + # Preempting leaves the controller in its software-stopped state. + # The driver's own set_state(0) would probably be enough; clearing + # first is cheap and does not depend on that being true. + self._attempt(self.robot.clear) + outcome = self._attempt(lambda: services.run(self.robot, call.row)) + with self._lock: + self._running_motion = None + preempted_by = call.preempted_by + ok, fields = outcome + if preempted_by is not None: + # A preempted move fails through MotionInterruptedError like any + # aborted one; only the flag says who cut it short. + self._finish_motion(call, PREEMPTED, False, f"preempted by {preempted_by}") + else: + self._finish_motion(call, DONE if ok else FAILED, ok, fields.get("message", ""), fields) + self._idle_unless_busy() + + def _leave_jog(self) -> bool: + """Clear the jogging flag, reporting whether the arm was in fact jogging.""" + with self._lock: + was_jogging, self._jogging = self._jogging, False + return was_jogging + + def _finish_motion(self, call: ServiceCall, state: str, ok: bool, message: str, fields=None) -> None: + """Publish a motion's end, and answer its caller if one is still waiting.""" + self._motion_state(call, state, message) + if call.wait: + self._respond(call, (ok, dict(fields or {"message": message}))) + + def _motion_state(self, call: ServiceCall, state: str, message: str) -> None: + """Publish where a motion stands. + + The request_id rides in the *fields*, not the metadata: metadata carrying + a request_id means "this is the answer to that request", and this is a + broadcast of the arm's state, which anyone may read. Keeping it in the + fields is also what lets a caller polling `get_state` see the same value + as one watching the topic. + """ + self._post( + Message( + output="motion_state", + fields={ + "request_id": call.request_id, + "service": call.service, + "state": state, + "message": message, + }, + ) + ) + + def _run_jog(self, jog: Jog) -> None: + """Command one velocity. Nothing is published: a jog has no answer.""" + def command() -> dict: + self.robot.set_cartesian_velocity(jog.velocity) + return {"message": "jogging"} + + ok, fields = self._attempt(command) + if not ok: + log(f"jog refused: {fields['message']}") + + def _respond(self, call: ServiceCall, outcome: tuple[bool, dict]) -> None: + """Post the one response this request gets.""" + ok, fields = outcome + fields.setdefault("message", "") + self._post( + Message( + output="response", + fields={"ok": ok, "service": call.service, **fields}, + metadata={"request_id": call.request_id}, + ) + ) + + def _attempt(self, work) -> tuple[bool, dict]: + """Run ``work``, turning whatever it raises into a failed response.""" + try: + return True, work() + except ArmNeedsHomingError as error: + return False, {"message": f"{error}; call `home_and_clear` once the area is clear"} + except RobotUnavailableError as error: + self._go_unavailable(error) + return False, {"message": str(error)} + except Exception as error: # noqa: BLE001 — a failed request must never take the thread down + return False, {"message": str(error)} + + def _go_unavailable(self, error: Exception) -> None: + """Report the arm as unusable and try once to bring it back. + + A single attempt, not a retry loop: the thread has jobs to serve, and the + reporter comes round again in milliseconds. + """ + self._set_state(RECOVERING) + log(f"the arm is unusable ({error}); trying to recover") + try: + self.robot.recover() + except RobotUnavailableError as recovery_error: + log(f"recovery failed: {recovery_error}") + self._set_state(UNAVAILABLE) + return + self._set_state(READY) + + # --- the stopper thread --- + + def _run_stopper(self) -> None: + """Halt the arm whenever anyone asks, and never make them wait for it. + + One stop per wake-up however many asked, which is what a stop means: + several callers wanting the arm halted want the same single thing. + """ + while not self._stopping.is_set(): + if not self._stop_wanted.wait(timeout=0.1): + continue + self._stop_wanted.clear() + if self._stopping.is_set(): + return + if self.robot is not None: + self._attempt(self.robot.stop) + + def _set_state(self, state: str) -> None: + """Record a state, queueing it for publication only when it is a change.""" + with self._lock: + if state == self._state: + return + self._state = state + self._state_changes.append(state) + log(f"state: {state}") + + def _idle_unless_busy(self) -> None: + """Report the arm idle, but only when nothing is still driving it. + + One thing ending is not the arm stopping. A motion may still be winding + down inside the SDK after the jog that preempted it went quiet; a jog may + have taken the arm over from the motion that just finished. Either way + `ready` published over it is the false idle the state exists to rule out. + + The state also stops being ours to set once it has left `moving` — that + is `unavailable` or `recovering` reporting something this does not know + about, and it must not be overwritten with `ready`. + """ + with self._lock: + if self._running_motion is not None or self._jogging or self._state != MOVING: + return + self._set_state(READY) + + # --- the reporter thread --- + + def _expire_jog(self) -> None: + """Stop calling the arm jogging once the controller has expired the command. + + A jog lives on the controller's own TTL, so a sender that goes quiet + leaves an arm that stopped moving a moment later. Nothing tells us; the + clock does. + """ + with self._lock: + if not self._jogging or time.monotonic() - self._last_jog < self.config.motion.velocity_command_ttl_s: + return + self._jogging = False + self._idle_unless_busy() + + def _run_reporter(self) -> None: + """Publish telemetry, whatever the control thread is doing. + + Never touches anything that can block, so it keeps running through a + ten-second move — which is what lets a caller follow a motion whose + response will not arrive until it ends. + """ + while not self._stopping.is_set(): + self._stopping.wait(self.config.telemetry_interval_s) + if self.robot is None: + continue + self._expire_jog() + reading = self.robot.read_report() + if reading is None: + continue + joints, flange = reading + tip = with_tool_offset(flange, self.config.tool_offset_mm) + with self._lock: + self._telemetry = Telemetry(joints, tip, time.time_ns()) + diff --git a/dora_ulite6/controller.py b/dora_ulite6/controller.py new file mode 100644 index 0000000..ffb703b --- /dev/null +++ b/dora_ulite6/controller.py @@ -0,0 +1,71 @@ +"""Facts about the xArm controller: its modes, its codes, and the pose maths. + +Nothing tunable lives here — the limits and timeouts are configuration, read +from the environment in :mod:`dora_ulite6.config`. These are the constants the +SDK itself defines and the helpers that read its answers. +""" + +from xarm.core.config.x_code import ControllerErrorCodeMap +from xarm.x3.code import APIState + +MODE_POSITION = 0 +MODE_TEACH = 2 +MODE_CARTESIAN_VELOCITY = 5 + +# Controller states as the arm reports them. From 4 on the arm is stopped and +# refuses to move until enabled again — where the emergency stop leaves it. +STATE_STOPPED = 4 + +# Return codes meaning the command never ran because the arm was not usable. The +# SDK returns them instead of raising, so a driver that only logged them would +# have every caller believe a motion happened that never did. +UNAVAILABLE_CODES = frozenset( + {APIState.NOT_CONNECTED, APIState.NOT_READY, APIState.HAS_ERROR, APIState.STATE_NOT_READY} +) + +# The one controller error a motion of ours can clear: C23, joints angle exceed +# limit, whose documented remedy is to send the arm to its initial position. +# Every other error is answered by a person — releasing a button, dragging the +# arm out of a collision — and homing one would move an arm somebody stopped. +HOMEABLE_ERROR = 23 + +# How often to re-ask the controller while waiting. Cadence, not a limit. +ARRIVAL_POLL = 0.1 +READY_POLL = 0.2 + + +def format_pose(pose) -> str: + """Render a pose for a log line or an error message.""" + x, y, z, roll, pitch, yaw = pose + return f"({x:.0f}, {y:.0f}, {z:.0f} mm; {roll:.0f}, {pitch:.0f}, {yaw:.0f}°)" + + +def shortest_turn(start: float, target: float) -> float: + """Return the signed degrees from ``start`` to ``target``, the short way round. + + Euler angles wrap: an arm reporting roll −178 and asked for roll 180 has two + degrees to turn, but subtracting gives 358 — a sweep through roll 0 with the + tool pointing sideways, which no arm passes through. + """ + return (target - start + 180) % 360 - 180 + + +def interpolate(start: list, target: list, fraction: float) -> list: + """Return the pose ``fraction`` of the way from ``start`` to ``target``. + + Position moves in a straight line and each rotation turns the short way + round, which is what the controller does along a linear move. + """ + pose = [a + (b - a) * fraction for a, b in zip(start[:3], target[:3], strict=True)] + pose += [a + shortest_turn(a, b) * fraction for a, b in zip(start[3:], target[3:], strict=True)] + return pose + + +def describe_controller_error(code: int) -> str: + """Name a controller error, so a log reader does not have to look the number up.""" + if not code: + return "no controller error" + entry = ControllerErrorCodeMap.get(code) + if entry is None: + return f"controller error {code}" + return f"controller error {code} ({entry['en']['title']})" diff --git a/dora_ulite6/driver.py b/dora_ulite6/driver.py new file mode 100644 index 0000000..a5bcded --- /dev/null +++ b/dora_ulite6/driver.py @@ -0,0 +1,526 @@ +"""The UFactory Lite6 driver, over the vendor xArm SDK. + +Ported from the littlehand robot layer, which was written against this same +controller. The point of all of it is that a command either moved the arm or +raised: the SDK returns a code for a motion it quietly refused, and a driver that +only logged that code would have every caller believe in a motion that never +happened. + +**Every method here blocks**, some of them for as long as the arm takes to move, +and on a disconnected controller some block unboundedly. Nothing but +:mod:`dora_ulite6.control` may call into this class — see the note there. +""" + +import time + +from xarm.wrapper import XArmAPI +from xarm.x3.code import APIState + +from dora_ulite6.config import Config +from dora_ulite6.controller import ( + ARRIVAL_POLL, + HOMEABLE_ERROR, + MODE_CARTESIAN_VELOCITY, + MODE_POSITION, + MODE_TEACH, + READY_POLL, + STATE_STOPPED, + UNAVAILABLE_CODES, + describe_controller_error, + format_pose, + interpolate, + shortest_turn, +) +from dora_ulite6.errors import ( + ArmNeedsHomingError, + MotionInterruptedError, + RobotUnavailableError, + UnreachablePoseError, +) +from dora_ulite6.log import log +from dora_ulite6.pose import RobotPose + + +class ULite6: + """A UFactory Lite6 arm and the tool bolted to it.""" + + def __init__(self, config: Config): + self.config = config + self.motion = config.motion + self.ip = config.robot_ip + self.effector = config.effector + self.effector.verify(self) + # Whether the arm is holding something with whatever tool it carries. + self.tool_engaged = False + self.freedrive_on = False + # Built without opening, so every connection goes through connect() and + # an arm that was absent at boot is no different from one unplugged later. + # enable_report is the SDK's default, but read_report's whole promise — + # that it costs no round trip and cannot block — rests on it, so it is + # stated here rather than inherited. Without it `arm.position` quietly + # falls back to a blocking get_position(). + self.arm = XArmAPI(self.ip, do_not_open=True, enable_report=True) + self._velocity_mode = False + self.connect() + + # --- connection --- + + def connect(self) -> bool: + """Open (or reopen) the connection and enable motion. + + Never raises: an unreachable arm is a state to report and retry, not an + error that stops the node from starting. The handle is reused rather than + rebuilt, which is how the SDK reconnects. + """ + try: + self.arm.connect() + self.arm.motion_enable(enable=True) + self.arm.set_mode(MODE_POSITION) + self.arm.set_state(0) + self._velocity_mode = False + log(f"connected to the robot at {self.ip}") + return True + except Exception as error: # noqa: BLE001 — the SDK raises anything; an absent arm is a state, not a crash + log(f"failed to connect to the robot at {self.ip}: {error}") + return False + + def disconnect(self) -> None: + """Close the connection.""" + self.arm.disconnect() + log("robot disconnected") + + def is_connected(self) -> bool: + """Return whether the arm controller is reachable.""" + return bool(self.arm.connected) + + def is_ready(self) -> bool: + """Return whether the arm would accept a motion right now. + + Read from the report stream, which is what makes it cheap enough for + telemetry: a pressed emergency stop leaves the controller answering every + query while refusing every move. + """ + return bool(self.arm.connected and self.arm.state < STATE_STOPPED and self.arm.error_code == 0) + + def _require_connection(self) -> None: + """Raise unless the controller link is up; every command needs one.""" + if not self.arm.connected: + raise RobotUnavailableError(f"robot at {self.ip} is not connected") + + # --- reads --- + + def get_current_position(self) -> RobotPose: + """Return the current flange pose.""" + self._require_connection() + code, values = self.arm.get_position() + if code != 0 or len(values) < 6: + raise RobotUnavailableError(f"could not read the arm pose (code {code})") + return RobotPose.from_sequence(values) + + def get_joint_angles(self) -> list: + """Return the current joint angles in degrees.""" + self._require_connection() + code, angles = self.arm.get_servo_angle() + if code != 0 or not len(angles): + raise RobotUnavailableError(f"could not read the joint angles (code {code})") + return list(angles) + + def read_report(self) -> tuple[list, RobotPose] | None: + """Return ``(joints, flange pose)`` from the controller's report stream, or None. + + The one read that works **while a motion blocks**, and the only one the + reporter thread is allowed to make. The SDK keeps these values fresh from + a background stream of the controller's own, so this costs no round trip + and cannot block behind the move in flight — `get_position()` would sit + on the same socket the motion is using. + + The cost is that it is a cache: it says what the arm last reported, with + no error code to say whether the report is current. That is the right + trade for the telemetry a caller follows a motion by, and the wrong one for + deciding whether a motion happened, which is why the driver's own checks + keep asking over the wire. + + Returns None when the link is down, rather than handing back the last + pose the arm held before the cable was pulled. + """ + if not self.arm.connected: + return None + position = self.arm.position + angles = self.arm.angles + if not position or len(position) < 6 or not angles: + return None + return list(angles), RobotPose.from_sequence(position) + + def _live_error_code(self) -> int: + """Read the controller error over the wire, not from the report cache. + + ``arm.error_code`` is whatever the report stream last pushed, which is + stale exactly when it matters: right after an abort, and right after + clearing one. + """ + _, err_warn = self.arm.get_err_warn_code() + return err_warn[0] + + # --- motion --- + + def move_to(self, pose: RobotPose, speed: float | None = None) -> None: + """Move the flange to ``pose``, refusing a move it cannot make without commanding it.""" + self._require_connection() + target = pose.as_list() + self._check_path_reachable(target, "move_to") + self.arm.set_mode(MODE_POSITION) + self.arm.set_state(0) + code = self.arm.set_position( + pose.x, + pose.y, + pose.z, + roll=pose.roll, + pitch=pose.pitch, + yaw=pose.yaw, + speed=speed if speed is not None else self.config.default_speed, + wait=True, + ) + self._check_motion_code(code, "move_to", pose=target) + self._check_arrived(target, "move_to") + log(f"moved to {format_pose(target)}") + + def set_joint_angles(self, angles: list, speed: float | None = None) -> None: + """Move the joints to ``angles`` in degrees.""" + self._require_connection() + self.arm.set_mode(MODE_POSITION) + self.arm.set_state(0) + code = self.arm.set_servo_angle( + angle=angles, + speed=speed if speed is not None else self.config.default_joint_speed, + wait=True, + ) + self._check_motion_code(code, "set_joint_angles") + log(f"joints moved to {angles}") + + def go_home(self) -> None: + """Send the arm to its joint-space home.""" + self._require_connection() + self.arm.set_mode(MODE_POSITION) + self.arm.set_state(0) + self._go_home_blocking() + log("moved to home") + + def _go_home_blocking(self) -> None: + """Command the joint-space homing and confirm the arm really made it. + + ``XArmAPI.reset`` looks like it does this, but it throws ``move_gohome``'s + return code away: ask it to home an arm the controller refuses to move and + it reports nothing and leaves the arm where it was. + """ + code = self.arm.move_gohome(wait=True, timeout=self.motion.home_timeout_s) + self._check_motion_code(code, "move_gohome") + self._check_at_home() + + def _check_at_home(self) -> None: + """Raise unless the arm really is at the joint zero it was sent to. + + Homing is waited on by the same ``wait_move`` that calls a refused motion + finished, so it can report success for an arm that never moved. + """ + try: + angles = self.get_joint_angles() + except RobotUnavailableError as error: + log(f"could not confirm the arm reached home ({error}); assuming it did") + return + off_by = max(abs(angle) for angle in angles) + if off_by > self.motion.arrival_tolerance_deg: + raise RobotUnavailableError(f"homing did not move the arm; it is still {off_by:.0f}° from home") + + def home_and_clear(self) -> None: + """Clear the latched error and drive the arm home in joint space; this **moves the arm**. + + The answer to :class:`ArmNeedsHomingError`, and only ever run after + whoever asked for it has warned the room. + + Order matters: the controller refuses a motion while an error is latched, + and a latched error is what this is here to escape. + """ + self.clear() + self.go_home() + + def stop(self) -> None: + """Abort the current motion immediately (software stop, state 4). + + The one method safe to call from another thread while a blocking move is + in flight — that is the whole point of it — so the disconnected case + returns rather than sitting for seconds retrying down a dead socket. The + interrupted move raises :class:`MotionInterruptedError` on its own thread. + """ + if not self.arm.connected: + log("stop requested while disconnected") + return + self.arm.emergency_stop() + self._velocity_mode = False + log("motion stopped") + + # --- arrival and reachability --- + + def _check_arrived(self, target: list, operation: str) -> None: + """Raise unless the arm actually made the move it was commanded. + + A return code of 0 does not mean it arrived: ``wait_move`` calls a motion + finished once the arm has been idle for a few polls, so a command the + controller quietly refused comes back looking like one that ran. The + controller's error is the half that decides; the pose is a coarse + backstop for a refusal that latched nothing. + """ + error = self._live_error_code() + if error: + raise RobotUnavailableError( + f"{operation} to {format_pose(target)} left the arm in {describe_controller_error(error)}" + ) + deadline = time.monotonic() + self.motion.arrival_timeout_s + while True: + code, current = self.arm.get_position() + if code != 0 or not len(current): + log(f"{operation}: could not confirm arrival (code {code}); assuming it landed") + return + off_by = max(abs(a - b) for a, b in zip(current[:3], target[:3], strict=True)) + turned = max(abs(shortest_turn(a, b)) for a, b in zip(current[3:], target[3:], strict=True)) + if off_by <= self.motion.arrival_tolerance_mm and turned <= self.motion.arrival_tolerance_deg: + return + if time.monotonic() >= deadline: + raise MotionInterruptedError( + f"{operation} to {format_pose(target)} never arrived; the arm is at {format_pose(current)} " + f"({describe_controller_error(self._live_error_code())})" + ) + time.sleep(ARRIVAL_POLL) + + def _check_path_reachable(self, target: list, operation: str) -> None: + """Refuse a move whose straight-line path leaves the joint envelope. + + The controller aborts partway through a line it cannot follow, with a + latched error that then refuses every later command. Asking it to solve + the path first turns that into a refusal nobody has to recover from. + Sampling matters as much as the target: a line from a turned wrist to a + downward one passes through configurations neither end visits. + """ + *waypoints, destination = self._path_to(target) + for pose in waypoints: + self._check_pose_holdable(pose, target, operation, margin=False) + self._check_pose_holdable(destination, target, operation, margin=True) + + def _path_to(self, target: list) -> list: + """Return the poses to check on the way to ``target``, ``target`` last. + + A pose read the controller could not answer leaves nothing to interpolate + from, so the path collapses to the target alone rather than being sampled + from a stale start, which would refuse perfectly good moves. + """ + code, current = self.arm.get_position() + if code != 0 or not len(current): + log(f"could not read the pose to plan from (code {code}); checking the target only") + return [target] + samples = self.motion.path_samples + steps = [interpolate(current, target, step / samples) for step in range(1, samples)] + return steps + [target] + + def _check_pose_holdable(self, pose: list, target: list, operation: str, margin: bool) -> None: + """Raise unless the arm has a joint solution for ``pose``, clear of its limits. + + ``margin`` asks the dearer question — whether the solution merely fits — + and is worth its round trips only where the arm comes to rest. + """ + code, angles = self.arm.get_inverse_kinematics(pose) + if code != 0 or not len(angles): + raise UnreachablePoseError( + f"{operation} to {format_pose(target)}: no joint solution for {format_pose(pose)} (code {code})" + ) + code, limited = self.arm.is_joint_limit(angles) + # A check the controller could not answer is not a refusal: failing shut + # would ground the arm on any transient, and it still guards the motion. + if code == 0 and limited: + raise UnreachablePoseError( + f"{operation} to {format_pose(target)}: {format_pose(pose)} needs joint angles out of range" + ) + near = self._joints_near_limit(angles) if margin else [] + if near: + joints = ", ".join(f"J{index}" for index in near) + raise UnreachablePoseError( + f"{operation} to {format_pose(target)}: {format_pose(pose)} leaves " + f"{joints} within {self.motion.joint_limit_margin_deg}° of a limit" + ) + + def _joints_near_limit(self, angles) -> list: + """Return the 1-based joints whose solution sits within the margin of a bound. + + Asked of the controller rather than read off a table: the SDK exposes only + the reduced-mode bounds, and the real ones differ per arm model. + """ + margin = self.motion.joint_limit_margin_deg + near = [] + for index in range(len(angles)): + for delta in (margin, -margin): + probe = list(angles) + probe[index] += delta + code, limited = self.arm.is_joint_limit(probe) + if code == 0 and limited: + near.append(index + 1) + break + return near + + # --- failure handling --- + + def _check_motion_code(self, code: int, operation: str, pose: list | None = None) -> None: + """Raise on a motion that was aborted or never ran, log anything else.""" + if code == APIState.EMERGENCY_STOP: + # The SDK returns this for any aborted motion, a stop of ours and a + # controller fault alike; only the live error code tells them apart. + where = f" to {format_pose(pose)}" if pose else "" + error = self._live_error_code() + if error: + raise self._motion_fault(f"{operation}{where} aborted by {describe_controller_error(error)}", error) + raise MotionInterruptedError(f"{operation}{where} aborted by stop") + self._check_command_code(code, operation, pose) + + def _check_command_code(self, code: int, operation: str, pose: list | None = None) -> None: + """Raise on a command that never ran, log anything else. + + For commands that are not motions — switching the vacuum, say. Nothing + here can raise :class:`MotionInterruptedError`, because there was no + motion to interrupt; a refusal means the arm was not usable. + """ + if code == 0: + return + where = f" to {format_pose(pose)}" if pose else "" + if code in UNAVAILABLE_CODES: + error = self._live_error_code() + raise self._motion_fault( + f"{operation}{where} was refused (code {code}, {describe_controller_error(error)}): " + "the arm is disconnected, stopped or not enabled", + error, + ) + log(f"{operation} returned error code {code}") + + def _motion_fault(self, detail: str, error: int) -> RobotUnavailableError: + """Pick the failure that names what will actually get the arm moving again. + + A joint past its limit clears whenever asked and comes straight back on + the next motion, so "clear it and try again" never converges; it needs a + joint-space move home, and that has to be announced first. + """ + if error == HOMEABLE_ERROR: + return ArmNeedsHomingError(detail) + return RobotUnavailableError(detail) + + def recover(self) -> None: + """Reconnect if the link dropped, clear errors, and re-enable motion. Never moves the arm. + + Clearing is unconditional, because the cached error codes come from the + report stream, which is stale exactly in the case that dropped the link. + A joint past its own limit is the one thing clearing cannot fix — that + raises :class:`ArmNeedsHomingError`, whose answer is + :meth:`home_and_clear`. + """ + self._velocity_mode = False + if not self.arm.connected and not self.connect(): + raise RobotUnavailableError(f"robot at {self.ip} is not reachable") + self.clear() + try: + self._wait_until_ready() + except RobotUnavailableError as error: + if self._live_error_code() == HOMEABLE_ERROR: + raise ArmNeedsHomingError(f"{error}; only homing the arm will clear it") from error + raise + log("recovered and motion re-enabled") + + def clear(self) -> None: + """Clear any latched error and warning, and put the arm back in position mode.""" + self._require_connection() + self._velocity_mode = False + self.arm.clean_error() + self.arm.clean_warn() + self.arm.motion_enable(enable=True) + self.arm.set_mode(MODE_POSITION) + self.arm.set_state(0) + + def _wait_until_ready(self) -> None: + """Block until the controller reports a state it can move from. + + Both the state and the error are asked over the wire: after a reconnect + the report stream may have delivered nothing yet, and after a clear it + still carries the error that was just cleared. + """ + deadline = time.monotonic() + self.motion.ready_timeout_s + while True: + code, state = self.arm.get_state() + error = self._live_error_code() + if code == 0 and state < STATE_STOPPED and error == 0: + return + if time.monotonic() >= deadline: + raise RobotUnavailableError( + f"robot did not become ready (state {state}, {describe_controller_error(error)}): " + "is the emergency stop still pressed?" + ) + time.sleep(READY_POLL) + + # --- the tool --- + + def engage_tool(self) -> None: + """Take hold with whatever tool is mounted.""" + self.effector.attach(self) + self.tool_engaged = True + + def release_tool(self) -> None: + """Let go.""" + self.effector.detach(self) + self.tool_engaged = False + + def set_vacuum(self, on: bool) -> None: + """Switch the vacuum gripper and wait for it to build or release.""" + self._require_connection() + code = self.arm.set_vacuum_gripper(on, wait=False) + self._check_command_code(code, "set_vacuum") + time.sleep(self.motion.tool_settle_s) + log(f"vacuum set to {on}") + + def set_gripper(self, closed: bool) -> None: + """Close or open the Lite6 two-finger gripper and wait for the fingers to settle.""" + self._require_connection() + code = self.arm.close_lite6_gripper() if closed else self.arm.open_lite6_gripper() + self._check_command_code(code, "set_gripper") + time.sleep(self.motion.tool_settle_s) + log(f"gripper {'closed' if closed else 'opened'}") + + # --- control modes --- + + def set_freedrive(self, enabled: bool) -> None: + """Enter or leave manual teach mode, so the arm can be moved by hand.""" + self._require_connection() + self.arm.set_mode(MODE_TEACH if enabled else MODE_POSITION) + self.arm.set_state(0) + self.freedrive_on = enabled + log(f"freedrive {'enabled' if enabled else 'disabled'}") + + def set_cartesian_velocity(self, velocity: list) -> None: + """Drive the arm at a constant base-frame velocity (xArm mode 5) and return. + + ``velocity`` is ``[vx, vy, vz, wx, wy, wz]`` in mm/s and deg/s. The arm + keeps moving until the controller's own TTL expires or a new velocity + arrives, so a sender must keep sending; an all-zero vector stops it and + leaves velocity mode. + """ + self._require_connection() + if any(velocity): + if not self._velocity_mode: + self.arm.set_mode(MODE_CARTESIAN_VELOCITY) + self.arm.set_state(0) + self._velocity_mode = True + code = self.arm.vc_set_cartesian_velocity( + velocity, + is_radian=False, + is_tool_coord=False, + duration=self.motion.velocity_command_ttl_s, + ) + self._check_motion_code(code, "set_cartesian_velocity") + return + if self._velocity_mode: + self.arm.vc_set_cartesian_velocity([0.0] * 6, is_radian=False) + self.arm.set_mode(MODE_POSITION) + self.arm.set_state(0) + self._velocity_mode = False diff --git a/dora_ulite6/effectors.py b/dora_ulite6/effectors.py new file mode 100644 index 0000000..2e6267d --- /dev/null +++ b/dora_ulite6/effectors.py @@ -0,0 +1,99 @@ +"""End effectors: what the arm carries at its flange, and what that lets it do. + +"Grasp" is the same symbolic command whatever is bolted on; what differs is how +it is realized (suction on, fingers closed). That difference lives here, so the +event loop never names a tool. + +Each effector declares the driver method that switches it and how far past the +flange its tip sits, which is what `tcp_pose` is offset by. Adding a tool is a +method on the driver and a class here. +""" + +from abc import ABC +from typing import ClassVar + +from dora_ulite6.errors import EffectorUnsupportedError + + +class EndEffector(ABC): + """Base class for whatever is mounted at the flange.""" + + # The driver method that switches this tool on and off; None for a bare + # flange, which asks nothing of the arm. + drives: ClassVar[str | None] + # Distance from the flange to the tool tip along the flange Z axis, in mm. + offset_mm: ClassVar[float] + + def attach(self, robot) -> None: + """Take hold.""" + self._switch(robot, True) + + def detach(self, robot) -> None: + """Let go.""" + self._switch(robot, False) + + def _switch(self, robot, engaged: bool) -> None: + """Call the driver method this effector drives. + + Using the declared name rather than a literal is what keeps ``drives`` + and the call from drifting apart. + """ + getattr(robot, self.drives)(engaged) + + def verify(self, robot) -> None: + """Raise unless this arm has the method this effector switches. + + Checked when the arm is built rather than at first use: a configured + effector the driver has no method for is a setup mistake, and it should + stop the node joining the dataflow rather than surface mid-motion. + """ + if self.drives and not hasattr(robot, self.drives): + raise EffectorUnsupportedError( + f"{type(self).__name__} needs a driver with {self.drives}(); " + f"{type(robot).__name__} has none" + ) + + +class VacuumEffector(EndEffector): + """A suction cup: it comes down onto an object's top face and holds it by vacuum.""" + + drives = "set_vacuum" + offset_mm = 61.0 + + +class GripperEffector(EndEffector): + """The Lite6 two-finger gripper.""" + + drives = "set_gripper" + offset_mm = 65.0 + + +class NoEffector(EndEffector): + """A bare flange: the arm can reach and point, but cannot hold anything.""" + + drives = None + offset_mm = 0.0 + + def attach(self, robot) -> None: + """Refuse: there is nothing mounted to grasp with.""" + raise EffectorUnsupportedError("this arm has no effector to grasp with") + + def detach(self, robot) -> None: + """Refuse: there is nothing mounted to release with.""" + raise EffectorUnsupportedError("this arm has no effector to release with") + + +EFFECTORS: dict[str, type[EndEffector]] = { + "vacuum": VacuumEffector, + "gripper": GripperEffector, + "none": NoEffector, +} + + +def get_effector(name: str) -> EndEffector: + """Return the effector registered under ``name``, or raise listing which exist.""" + try: + return EFFECTORS[name]() + except KeyError: + available = ", ".join(sorted(EFFECTORS)) + raise ValueError(f"unknown effector {name!r}; available: {available}") from None diff --git a/dora_ulite6/errors.py b/dora_ulite6/errors.py new file mode 100644 index 0000000..6c1448e --- /dev/null +++ b/dora_ulite6/errors.py @@ -0,0 +1,43 @@ +"""Everything the robot layer raises. + +The distinctions matter: they say whether the arm moved, whether anything can be +done about it, and whether the answer needs a human first. Taken from the +littlehand robot layer, where they were arrived at against this same controller. +""" + + +class MotionInterruptedError(Exception): + """The motion was aborted in flight, by :meth:`ULite6.stop` or a controller fault.""" + + +class RobotUnavailableError(Exception): + """The command never ran because the arm was not usable. + + A pulled cable, a pressed emergency stop, a latched controller error: what a + caller must do is the same, and it is never to carry on as if the arm moved. + Recoverable by :meth:`ULite6.recover`. + """ + + +class UnreachablePoseError(Exception): + """The pose was never commanded because the arm cannot hold it. + + Deliberately not a :class:`RobotUnavailableError`: the arm is fine and the + target is not, so a retry would only ask the same impossible thing and latch + a joint limit error on the controller. + """ + + +class ArmNeedsHomingError(RobotUnavailableError): + """Only a motion of ours can clear this error, so the caller must warn first. + + A joint past its own limit stays past it however often the error is cleared. + Narrow on purpose: an arm that is stopped rather than stuck — emergency stop + held, collision to be dragged out of by hand — stays a plain + :class:`RobotUnavailableError`, because homing it would move an arm somebody + stopped deliberately. + """ + + +class EffectorUnsupportedError(Exception): + """The configured effector is not one this arm can drive.""" diff --git a/dora_ulite6/log.py b/dora_ulite6/log.py new file mode 100644 index 0000000..41b5c0f --- /dev/null +++ b/dora_ulite6/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"[ulite6 {time.strftime('%H:%M:%S')}] {message}", file=sys.stderr, flush=True) diff --git a/dora_ulite6/main.py b/dora_ulite6/main.py index c90ad41..a05c6b0 100644 --- a/dora_ulite6/main.py +++ b/dora_ulite6/main.py @@ -1,1049 +1,178 @@ -"""Dora node for controlling a UFactory Lite6 robot and publishing state.""" +"""The dora event loop: accept requests, publish what the arm threads produced. -import json +Nothing here touches the arm. The loop reads the `request_id` dora puts in a +message's metadata, hands the work to the control thread, and publishes the +answer under the same id. + +Two services do not take the ordinary road. `get_state` runs here, because it +never blocks and is wanted exactly when the control thread is busy. `stop` is +handed to the stopper thread, because it must not queue behind the motion it +aborts and must not be run here either — the SDK spends up to three seconds on +it, and a router cannot go away that long. +""" + +import argparse import os -import threading -import time -from typing import Any, Dict, Optional -import numpy as np import pyarrow as pa -import uvicorn from dora import Node -from fastapi import FastAPI, HTTPException -from fastapi.responses import HTMLResponse -from pydantic import BaseModel -from scipy.spatial.transform import Rotation -from xarm.wrapper import XArmAPI +from dora_ulite6 import payload, services +from dora_ulite6.config import Config +from dora_ulite6.control import Control, Message, ServiceCall, Telemetry +from dora_ulite6.log import log -class ULite6Helper: - """Minimal ULite6 helper based on ufactory-control.""" - - def __init__(self, robot_ip: str): - self.robot_ip = robot_ip - self.arm = XArmAPI(self.robot_ip) - self.arm.connect() - self.arm.motion_enable(enable=True) - - def go_home(self) -> int: - self.arm.set_mode(0) - self.arm.set_state(state=0) - code = self.arm.move_gohome(wait=True) - # move_gohome may return tuple (code, result) in some versions - if isinstance(code, tuple): - code = code[0] - return code - - def move_to_pose( - self, - x: float, - y: float, - z: float, - roll: float, - pitch: float, - yaw: float, - speed: float = 30.0, - units: str = "mm", - ) -> int: - if units == "m": - x, y, z = x * 1000.0, y * 1000.0, z * 1000.0 - self.arm.set_mode(0) - self.arm.set_state(0) - return self.arm.set_position( - x, y, z, roll=roll, pitch=pitch, yaw=yaw, speed=speed, wait=True - ) - - def get_current_position(self) -> Dict[str, float]: - code, pos = self.arm.get_position() - if code != 0: - raise RuntimeError(f"get_position failed with code {code}") - return { - "x": pos[0], - "y": pos[1], - "z": pos[2], - "roll": pos[3], - "pitch": pos[4], - "yaw": pos[5], - } - - def get_tool_tcp_pose(self, offset_mm: float) -> Dict[str, float]: - pos = self.get_current_position() - if offset_mm == 0.0: - return pos - T = self.get_pose_matrix(tcp_offset_mm=offset_mm) - return { - "x": T[0, 3] * 1000.0, - "y": T[1, 3] * 1000.0, - "z": T[2, 3] * 1000.0, - "roll": pos["roll"], - "pitch": pos["pitch"], - "yaw": pos["yaw"], - } - - def get_pose_matrix(self, tcp_offset_mm: float = 0.0) -> np.ndarray: - pos = self.get_current_position() - R = Rotation.from_euler( - "xyz", [pos["roll"], pos["pitch"], pos["yaw"]], degrees=True - ).as_matrix() - - position_m = np.array([pos["x"], pos["y"], pos["z"]]) / 1000.0 - if tcp_offset_mm != 0.0: - tcp_offset_local = np.array([0, 0, tcp_offset_mm]) / 1000.0 - tcp_offset_global = R @ tcp_offset_local - position_m = position_m + tcp_offset_global - - T = np.eye(4) - T[:3, :3] = R - T[:3, 3] = position_m - return T - - def get_joint_angles_deg(self) -> np.ndarray: - code, angles = self.arm.get_servo_angle(is_radian=False) - if code != 0: - raise RuntimeError(f"get_servo_angle failed with code {code}") - return np.array(angles, dtype=float) - - def get_status(self) -> Dict[str, Any]: - """Get robot status information.""" - return { - "connected": self.arm.connected, - "state": self.arm.state, - "mode": self.arm.mode, - "error_code": self.arm.error_code, - "warn_code": self.arm.warn_code, - "has_error": self.arm.has_error, - "has_warn": self.arm.has_warn, - "motor_enable_states": self.arm.motor_enable_states, - } - - def reset_state(self) -> int: - """Clear errors/warnings and reset robot state.""" - self.arm.clean_error() - self.arm.clean_warn() - self.arm.motion_enable(enable=True) - self.arm.set_mode(0) - return self.arm.set_state(0) - - def set_vacuum_gripper(self, on: bool) -> int: - """Turn vacuum gripper on or off.""" - code = self.arm.set_vacuum_gripper(on) - if isinstance(code, tuple): - code = code[0] - return code - - def get_vacuum_gripper(self) -> Dict[str, Any]: - """Get vacuum gripper status.""" - code, status = self.arm.get_vacuum_gripper() - return {"code": code, "on": status == 1} - - def disconnect(self) -> int: - return self.arm.disconnect() - - -# --- Pydantic models for API requests --- - - -class MoveToRequest(BaseModel): - x: float - y: float - z: float - speed: Optional[float] = None - roll: Optional[float] = 180.0 - pitch: Optional[float] = 0.0 - yaw: Optional[float] = 0.0 - - -class MoveToPoseRequest(BaseModel): - x: float - y: float - z: float - roll: float - pitch: float - yaw: float - speed: Optional[float] = None - - -# --- FastAPI app factory --- - - -def create_api( - helper: ULite6Helper, - default_speed: float, - default_units: str, - vacuum_enabled: bool, - tool_type: str, - tool_offset_mm: float, -) -> FastAPI: - """Create FastAPI application with robot control endpoints.""" - app = FastAPI( - title="ULite6 Robot Control API", - description="REST API for controlling UFactory Lite6 robot", - version="0.1.0", - ) - - @app.get("/", response_class=HTMLResponse) - def web_ui(): - """Serve web control interface.""" - return """ - - - - - ULite6 Control - - - -

ULite6 Robot Control

-
-
-

Status

-
-
-
Connected
-
--
-
-
-
State
-
--
-
-
-
Mode
-
--
-
-
-
Error
-
--
-
-
-
Warning
-
--
-
-
-
- -
-

TCP Pose (mm, deg)

-
-
X
--
-
Y
--
-
Z
--
-
Roll
--
-
Pitch
--
-
Yaw
--
-
-

Joint Angles (deg)

-
-
J1
--
-
J2
--
-
J3
--
-
J4
--
-
J5
--
-
J6
--
-
-
- -
-

Controls

-
- - -
- -

Move To Position

-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
- -
-

Response Log

-
-
-
- - - -""" - - @app.get("/api/status") - def get_status(): - """Get robot status (connected, enabled, errors).""" - try: - return helper.get_status() - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - @app.get("/api/pose") - def get_pose(): - """Get current TCP pose [x, y, z, roll, pitch, yaw] with tool offset applied.""" - try: - return helper.get_tool_tcp_pose(tool_offset_mm) - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - @app.get("/api/joints") - def get_joints(): - """Get current joint angles in degrees.""" - try: - angles = helper.get_joint_angles_deg() - return {"joints": angles.tolist(), "units": "deg"} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - @app.post("/api/home") - def go_home(): - """Send robot to home position.""" - try: - code = helper.go_home() - return {"ok": code == 0, "code": code} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - @app.post("/api/move_to") - def move_to(request: MoveToRequest): - """Move to position with optional orientation.""" - try: - speed = request.speed if request.speed is not None else default_speed - code = helper.move_to_pose( - request.x, - request.y, - request.z, - request.roll, - request.pitch, - request.yaw, - speed, - default_units, - ) - return {"ok": code == 0, "code": code} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - @app.post("/api/move_to_pose") - def move_to_pose(request: MoveToPoseRequest): - """Move to full pose with position and orientation.""" - try: - speed = request.speed if request.speed is not None else default_speed - code = helper.move_to_pose( - request.x, - request.y, - request.z, - request.roll, - request.pitch, - request.yaw, - speed, - default_units, - ) - return {"ok": code == 0, "code": code} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - @app.post("/api/reset") - def reset_state(): - """Clear errors/warnings and reset robot state.""" - try: - code = helper.reset_state() - return {"ok": code == 0, "code": code} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - @app.post("/api/disconnect") - def disconnect(): - """Disconnect from robot.""" - try: - code = helper.disconnect() - return {"ok": code == 0, "code": code} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - # Vacuum gripper endpoints (only if enabled) - if vacuum_enabled: - - @app.get("/api/vacuum") - def get_vacuum(): - """Get vacuum gripper status.""" - try: - return helper.get_vacuum_gripper() - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - @app.post("/api/vacuum/on") - def vacuum_on(): - """Turn vacuum gripper on.""" - try: - code = helper.set_vacuum_gripper(True) - return {"ok": code == 0, "code": code} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - @app.post("/api/vacuum/off") - def vacuum_off(): - """Turn vacuum gripper off.""" - try: - code = helper.set_vacuum_gripper(False) - return {"ok": code == 0, "code": code} - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - @app.get("/api/config") - def get_config(): - """Get API configuration.""" - return { - "vacuum_enabled": vacuum_enabled, - "tool_type": tool_type, - "tool_offset_mm": tool_offset_mm, - } - - return app - - -def run_uvicorn(app: FastAPI, host: str, port: int) -> None: - """Run uvicorn server (for use in background thread).""" - config = uvicorn.Config(app, host=host, port=port, log_level="warning") - server = uvicorn.Server(config) - server.run() - - -def _send_joint_pose(node: Node, joints_deg: np.ndarray) -> None: - metadata = {"units": "deg", "timestamp_ns": time.time_ns()} - node.send_output("joint_pose", pa.array(joints_deg.tolist()), metadata=metadata) - - -def _send_tcp_pose(node: Node, tcp_pose: Dict[str, float]) -> None: - metadata = { - "units_position": "mm", - "units_rotation": "deg", - "timestamp_ns": time.time_ns(), - } - vec = [ - tcp_pose["x"], - tcp_pose["y"], - tcp_pose["z"], - tcp_pose["roll"], - tcp_pose["pitch"], - tcp_pose["yaw"], - ] - node.send_output("tcp_pose", pa.array(vec), metadata=metadata) - - -def _send_error_status( - node: Node, action: str, message: str, status: Optional[Dict[str, Any]] = None -) -> None: - payload = { - "ok": False, - "action": action, - "message": message, - "timestamp_ns": time.time_ns(), - } - if status: - payload.update(status) - payload["type"] = "error" - node.send_output("status", pa.array([json.dumps(payload)])) - - -def _send_command_status( - node: Node, - command_id: str, - action: str, - ok: bool, - message: str, - state: str, - code: Optional[int] = None, - status: Optional[Dict[str, Any]] = None, -) -> None: - payload = { - "ok": ok, - "action": action, - "message": message, - "timestamp_ns": time.time_ns(), - "command_id": command_id, - } - if code is not None: - payload["code"] = code - if status: - payload.update(status) - payload["type"] = "command" - payload["state"] = state - node.send_output("status", pa.array([json.dumps(payload)])) - - -def _status_snapshot(helper: ULite6Helper) -> Dict[str, Any]: - status = helper.get_status() - return { - "robot_state": status.get("state"), - "mode": status.get("mode"), - "error_code": status.get("error_code"), - "warn_code": status.get("warn_code"), - "has_error": status.get("has_error"), - "has_warn": status.get("has_warn"), - } - - -def _log(msg: str) -> None: - """Print a timestamped log message.""" - timestamp = time.strftime("%H:%M:%S") - print(f"[ulite6 {timestamp}] {msg}", flush=True) +# The metadata key dora's service pattern is built on. +REQUEST_ID = "request_id" def main() -> None: - node = Node() + """Connect to the dataflow, start the arm threads, and run until dora stops us.""" + args = _parse_args() + config = Config.from_env() - robot_ip = os.getenv("ROBOT_IP", "192.168.1.192") - default_speed = float(os.getenv("DEFAULT_SPEED", "30")) - default_units = os.getenv("DEFAULT_UNITS", "mm") - api_host = os.getenv("API_HOST", "0.0.0.0") - api_port = int(os.getenv("API_PORT", "8080")) - vacuum_enabled = os.getenv("VACUUM_ENABLED", "false").lower() in ("true", "1", "yes") + # The node comes first, and the arm afterwards. Starting the arm threads + # first would let INIT_MODE drive a real robot before this process had + # joined the dataflow at all — and if joining then failed, it would have + # moved with nobody watching. Connecting first also lets the arm be told + # whether this is a restart. + # + # 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) - _TOOL_OFFSET_PRESETS: Dict[str, float] = {"none": 0.0, "vacuum": 61.0, "gripper": 65.0} - tool_type = os.getenv("TOOL_TYPE", "none").lower() - tool_offset_mm = _TOOL_OFFSET_PRESETS.get(tool_type, 0.0) + float(os.getenv("TOOL_OFFSET_MM", "0.0")) + control = Control(config, restarted=node.is_restart()) + control.start() - # Initial position settings - init_mode = os.getenv("INIT_MODE", "none").lower() # "home", "pose", or "none" - init_x = float(os.getenv("INIT_X", "300.0")) - init_y = float(os.getenv("INIT_Y", "0.0")) - init_z = float(os.getenv("INIT_Z", "250.0")) - init_roll = float(os.getenv("INIT_ROLL", "180.0")) - init_pitch = float(os.getenv("INIT_PITCH", "0.0")) - init_yaw = float(os.getenv("INIT_YAW", "0.0")) - init_speed = float(os.getenv("INIT_SPEED", "50.0")) + try: + for event in node: + if event["type"] == "ERROR": + raise RuntimeError(event["error"]) + if event["type"] != "INPUT": + continue + _handle(node, control, event) + finally: + if not control.shutdown(): + # The arm has been told to stop, but the control thread is still + # inside a blocking SDK call and may never come out. Leave rather + # than hang until dora loses patience and kills us. + os._exit(0) - _log(f"Connecting to robot at {robot_ip}...") - helper = ULite6Helper(robot_ip) - _log("Robot connected") - # Move to initial position on startup - if init_mode == "home": - _log("Moving to home position...") - code = helper.go_home() - if code == 0: - _log("Home position reached") - else: - _log(f"Home failed with code {code}") - elif init_mode == "pose": - _log(f"Moving to initial pose: [{init_x}, {init_y}, {init_z}] roll={init_roll} pitch={init_pitch} yaw={init_yaw}") - code = helper.move_to_pose( - init_x, init_y, init_z, - init_roll, init_pitch, init_yaw, - speed=init_speed, - units="mm", - ) - if code == 0: - _log("Initial pose reached") - else: - _log(f"Move to initial pose failed with code {code}") +def _handle(node: Node, control: Control, event: dict) -> None: + """Route one input event.""" + event_id = event["id"] + if event_id == "tick": + _publish(node, control) + elif event_id == "request": + _accept_request(control, event) + elif event_id == "jog": + _accept_jog(control, event) else: - _log("Skipping initial position (INIT_MODE=none)") + log(f"ignoring unexpected input `{event_id}`") - # Create and start FastAPI server in background thread - app = create_api(helper, default_speed, default_units, vacuum_enabled, tool_type, tool_offset_mm) - api_thread = threading.Thread( - target=run_uvicorn, args=(app, api_host, api_port), daemon=True + +def _publish(node: Node, control: Control) -> None: + """Send everything the arm threads produced since the last tick.""" + for state in control.take_state_changes(): + node.send_output("robot_state", pa.array([state])) + for message in control.take_messages(): + node.send_output(message.output, pa.array([message.fields]), metadata=message.metadata) + telemetry = control.take_telemetry() + if telemetry is not None: + _send_telemetry(node, telemetry) + + +def _send_telemetry(node: Node, telemetry: Telemetry) -> None: + node.send_output( + "joint_pose", + pa.array(telemetry.joints, type=pa.float32()), + metadata={"units": "deg", "timestamp_ns": telemetry.timestamp_ns}, + ) + node.send_output( + "tcp_pose", + pa.array(telemetry.tip.as_list(), type=pa.float32()), + metadata={ + "units_position": "mm", + "units_rotation": "deg", + "frame": "tool_tip", + "timestamp_ns": telemetry.timestamp_ns, + }, ) - api_thread.start() - _log(f"Web interface at http://{api_host}:{api_port}") - # Dora event loop - only handles tick for state publishing - for event in node: - if event["type"] != "INPUT": - continue - if event["id"] == "tick": - try: - joints = helper.get_joint_angles_deg() - tcp_pose = helper.get_tool_tcp_pose(tool_offset_mm) - _send_joint_pose(node, joints) - _send_tcp_pose(node, tcp_pose) - except Exception as exc: - _send_error_status( - node, "publish_state", str(exc), status=_status_snapshot(helper) - ) - elif event["id"] == "command": - try: - value = event["value"] - raw = value[0].as_py() if len(value) else None - if not raw: - _send_command_status( - node, - command_id="", - action="command", - ok=False, - message="Empty command payload", - state="failed", - status=_status_snapshot(helper), - ) - continue - payload = json.loads(raw) - except Exception as exc: - _send_command_status( - node, - command_id="", - action="command", - ok=False, - message=f"Invalid command payload: {exc}", - state="failed", - status=_status_snapshot(helper), - ) - continue +def _accept_request(control: Control, event: dict) -> None: + """Take one service request, or refuse it under its own request id. - command_id = str(payload.get("id", "")) - action = str(payload.get("action", "")) - data = payload.get("payload", {}) if isinstance(payload.get("payload", {}), dict) else {} + A request the loop cannot parse is answered here rather than queued: a + caller waiting on a request_id must get exactly one response, never silence. + A request it *can* parse is queued even before the arm has been reached, + because `recover` and `get_state` are precisely what a caller sends then. - if not command_id: - _send_command_status( - node, - command_id="", - action=action or "command", - ok=False, - message="Missing command id", - state="failed", - status=_status_snapshot(helper), - ) - continue + `wait` decides only *when* the one response is sent — at once, or when the + arm stops. It is read here rather than in the service handler because it is + about the protocol, not about what the arm does. + """ + request_id = event["metadata"].get(REQUEST_ID, "") + try: + row = payload.read_row(event["value"]) + name = payload.name_of(row, services.SERVICE, services.NAMES) + wait = payload.flag(row, "wait", True) + except payload.PayloadError as error: + _refuse(control, request_id, str(error)) + return + if name == services.STOP: + control.request_stop(request_id) + return + if name in services.IMMEDIATE: + control.call_now(request_id, row) + return + call = ServiceCall(request_id=request_id, row=row, wait=wait) + if call.moves_the_arm: + # Motions go through their own door: one preempts whatever is already + # moving, rather than queueing behind it. + control.start_motion(call) + return + control.submit(call) - try: - if action == "home": - _send_command_status( - node, - command_id=command_id, - action=action, - ok=True, - message="Executing home", - state="started", - ) - code = helper.go_home() - _send_command_status( - node, - command_id=command_id, - action=action, - ok=code == 0, - message="Home command executed", - state="done" if code == 0 else "failed", - code=code, - status=_status_snapshot(helper), - ) - elif action in ("move_to", "move_to_pose"): - speed = float(data.get("speed", default_speed)) - x = float(data.get("x", 0.0)) - y = float(data.get("y", 0.0)) - z = float(data.get("z", 0.0)) - roll = float(data.get("roll", 180.0)) - pitch = float(data.get("pitch", 0.0)) - yaw = float(data.get("yaw", 0.0)) - _send_command_status( - node, - command_id=command_id, - action=action, - ok=True, - message="Executing move", - state="started", - ) - code = helper.move_to_pose( - x, - y, - z, - roll, - pitch, - yaw, - speed, - default_units, - ) - _send_command_status( - node, - command_id=command_id, - action=action, - ok=code == 0, - message="Move command executed", - state="done" if code == 0 else "failed", - code=code, - status=_status_snapshot(helper), - ) - elif action in ("vacuum_on", "vacuum_off"): - if not vacuum_enabled: - _send_command_status( - node, - command_id=command_id, - action=action, - ok=False, - message="Vacuum gripper not enabled", - state="failed", - status=_status_snapshot(helper), - ) - continue - _send_command_status( - node, - command_id=command_id, - action=action, - ok=True, - message="Executing vacuum", - state="started", - ) - vacuum_on = action == "vacuum_on" - code = helper.set_vacuum_gripper(vacuum_on) - _send_command_status( - node, - command_id=command_id, - action=action, - ok=code == 0, - message="Vacuum command executed", - state="done" if code == 0 else "failed", - code=code, - status=_status_snapshot(helper), - ) - else: - _send_command_status( - node, - command_id=command_id, - action=action or "command", - ok=False, - message=f"Unknown action: {action}", - state="failed", - status=_status_snapshot(helper), - ) - except Exception as exc: - _send_command_status( - node, - command_id=command_id, - action=action or "command", - ok=False, - message=str(exc), - state="failed", - status=_status_snapshot(helper), - ) + +def _accept_jog(control: Control, event: dict) -> None: + """Take one velocity command. + + A topic rather than a service: a jog is a stream that expires on the + controller's own TTL, so a sender keeps sending and nobody waits for an + answer. It still drives the arm, so it preempts a motion and reports + `moving` the same way one does — an arm that is travelling must never look + idle. + """ + try: + row = payload.read_row(event["value"]) + velocity = payload.numbers(row, "velocity", count=6) + except payload.PayloadError as error: + log(f"ignoring a malformed jog: {error}") + return + control.start_jog(velocity) + + +def _refuse(control: Control, request_id: str, message: str) -> None: + """Answer a request the loop rejected, without troubling the arm threads.""" + control.post( + Message( + output="response", + fields={"ok": False, "service": "", "message": message}, + metadata={REQUEST_ID: request_id}, + ) + ) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Dora node for a UFactory Lite6 arm.") + parser.add_argument( + "--name", + default="ulite6", + help="The name of the node in the dataflow, used when running as a dynamic node.", + ) + return parser.parse_args() if __name__ == "__main__": diff --git a/dora_ulite6/payload.py b/dora_ulite6/payload.py new file mode 100644 index 0000000..90ecd0b --- /dev/null +++ b/dora_ulite6/payload.py @@ -0,0 +1,91 @@ +"""Reading arguments out of the Arrow row that carries a request. + +Every request arrives as a one-row Arrow struct, and each service +brings its own schema — dora allows that on an input carrying a correlation id, +so nothing here assumes a fixed set of fields. `as_py()` hands back a plain dict +and these helpers read it, naming what was missing or unusable rather than +letting a `None` reach the arm. +""" + +from typing import Any + +from dora_ulite6.pose import RobotPose + + +class PayloadError(Exception): + """The request could not be understood, so nothing was asked of the arm.""" + + +def read_row(value) -> dict[str, Any]: + """Return the single struct row of ``value`` as a dict. + + Raises + ------ + PayloadError + The message was empty, or was not a struct this node can read. + """ + if value is None or len(value) == 0: + raise PayloadError("the message carries no data") + row = value[0].as_py() + if not isinstance(row, dict): + raise PayloadError(f"expected a struct with named fields, got {type(row).__name__}") + return row + + +def name_of(row: dict, field: str, known) -> str: + """Return the operation ``row`` names, or raise listing the ones that exist.""" + name = row.get(field) + if not isinstance(name, str) or name not in known: + raise PayloadError(f"unknown {field} {name!r}; available: {', '.join(sorted(known))}") + return name + + +def number(row: dict, field: str) -> float: + """Return a required numeric field.""" + if field not in row or row[field] is None: + raise PayloadError(f"missing `{field}`") + try: + return float(row[field]) + except (TypeError, ValueError): + raise PayloadError(f"`{field}` must be a number, not {row[field]!r}") from None + + +def optional_number(row: dict, field: str, default: float | None) -> float | None: + """Return a numeric field, or ``default`` when it is absent or null.""" + if field not in row or row[field] is None: + return default + try: + return float(row[field]) + except (TypeError, ValueError): + raise PayloadError(f"`{field}` must be a number, not {row[field]!r}") from None + + +def flag(row: dict, field: str, default: bool) -> bool: + """Return a boolean field, or ``default`` when it is absent or null.""" + value = row.get(field) + return default if value is None else bool(value) + + +def numbers(row: dict, field: str, count: int | None = None) -> list[float]: + """Return a required list of numbers, optionally of an exact length.""" + values = row.get(field) + if not isinstance(values, list) or not values: + raise PayloadError(f"`{field}` must be a non-empty list of numbers") + if count is not None and len(values) != count: + raise PayloadError(f"`{field}` must hold {count} numbers, got {len(values)}") + try: + return [float(value) for value in values] + except (TypeError, ValueError): + raise PayloadError(f"`{field}` must hold only numbers") from None + + +def pose(row: dict) -> RobotPose: + """Read a tool-tip pose, defaulting the orientation to tool-down.""" + return RobotPose( + x=number(row, "x"), + y=number(row, "y"), + z=number(row, "z"), + roll=optional_number(row, "roll", 180.0), + pitch=optional_number(row, "pitch", 0.0), + yaw=optional_number(row, "yaw", 0.0), + ) diff --git a/dora_ulite6/pose.py b/dora_ulite6/pose.py new file mode 100644 index 0000000..c9c7276 --- /dev/null +++ b/dora_ulite6/pose.py @@ -0,0 +1,50 @@ +"""The pose that crosses the driver boundary, and the tool-tip offset applied to it.""" + +from dataclasses import dataclass + +import numpy as np +from scipy.spatial.transform import Rotation + + +@dataclass +class RobotPose: + """A robot pose: position in mm, orientation in degrees, both in the base frame. + + The controller reports and accepts the flange pose. ``tcp_pose`` on the + dataflow is this pose pushed out to the mounted tool's tip by + :func:`with_tool_offset`. + """ + + x: float + y: float + z: float + roll: float = 180.0 + pitch: float = 0.0 + yaw: float = 0.0 + + @classmethod + def from_sequence(cls, values) -> "RobotPose": + """Build a pose from ``[x, y, z, roll, pitch, yaw]``; a position alone is enough.""" + return cls(**dict(zip(("x", "y", "z", "roll", "pitch", "yaw"), values, strict=False))) + + def as_list(self) -> list[float]: + """Return the pose as ``[x, y, z, roll, pitch, yaw]``, the order the SDK uses.""" + return [self.x, self.y, self.z, self.roll, self.pitch, self.yaw] + + +def rotation_matrix(pose: RobotPose) -> np.ndarray: + """Return the 3x3 base-frame rotation of ``pose``.""" + return Rotation.from_euler("xyz", [pose.roll, pose.pitch, pose.yaw], degrees=True).as_matrix() + + +def with_tool_offset(pose: RobotPose, offset_mm: float) -> RobotPose: + """Return ``pose`` moved ``offset_mm`` along its own Z axis. + + The offset is along the *tool* Z, not the base Z, so a tilted flange puts the + tip where the tool actually reaches rather than straight down from it. + Orientation is unchanged: sliding along the axis does not turn anything. + """ + if offset_mm == 0.0: + return pose + tip = np.array([pose.x, pose.y, pose.z]) + rotation_matrix(pose) @ np.array([0.0, 0.0, offset_mm]) + return RobotPose(tip[0], tip[1], tip[2], pose.roll, pose.pitch, pose.yaw) diff --git a/dora_ulite6/services.py b/dora_ulite6/services.py new file mode 100644 index 0000000..b1746a1 --- /dev/null +++ b/dora_ulite6/services.py @@ -0,0 +1,167 @@ +"""Every operation the node serves, and what each one does to the arm. + +A service is one request and one response, correlated by the `request_id` dora +puts in the message metadata. That is the node's whole control surface: there is +no second protocol for the slow ones. + +Two groups behave differently enough to be worth naming here: + +- The motions in :data:`MOVES_THE_ARM` take as long as the arm does. A caller + chooses with `wait` whether to be held until the arm stops or answered at + once; either way the motion's progress is on `motion_state`. A new motion + preempts one already running, so there is never more than one in play. +- The services in :data:`IMMEDIATE` are run by the event loop rather than queued, + because queued they would sit behind a motion — which is exactly when they are + wanted. Putting one there is a promise that it never blocks: `get_state` reads + the controller's report cache rather than the wire, which is what makes it + answerable *during* a motion. + +`stop` is a service to a caller but has no handler here at all. It cannot queue +(it exists to abort what is at the head of the queue) and it cannot run on the +event loop either: the SDK's `emergency_stop` re-commands the controller for up +to three seconds, and three seconds is far too long to stop routing a dataflow. +It gets a thread of its own — see :mod:`dora_ulite6.control`. +""" + +from typing import Any + +from dora_ulite6 import payload +from dora_ulite6.driver import ULite6 +from dora_ulite6.pose import with_tool_offset + +# Field naming the service in a request row. +SERVICE = "service" + + +def run(robot: ULite6, row: dict) -> dict[str, Any]: + """Carry out the service ``row`` names and return the response fields. + + ``row`` must name a service with a handler, which `stop` deliberately has + not; the event loop routes that one before it ever reaches here. + + Blocks for as long as the service takes — seconds, for a motion. + + Raises + ------ + PayloadError + The row named no known service, or its arguments were unusable. + RobotUnavailableError, UnreachablePoseError, EffectorUnsupportedError + The arm refused; the caller turns it into a failed response. + """ + return SERVICES[payload.name_of(row, SERVICE, SERVICES)](robot, row) + + +# --- motions: these block, and they move a real arm --- + + +def _move_to(robot: ULite6, row: dict) -> dict: + tip = payload.pose(row) + # The request names where the tool tip should end up; the controller only + # takes flange poses, so the tool's own reach comes back off here. + robot.move_to( + with_tool_offset(tip, -robot.config.tool_offset_mm), + payload.optional_number(row, "speed", None), + ) + return {"message": f"the tool is at ({tip.x:.0f}, {tip.y:.0f}, {tip.z:.0f} mm)"} + + +def _move_joints(robot: ULite6, row: dict) -> dict: + angles = payload.numbers(row, "angles") + robot.set_joint_angles(angles, payload.optional_number(row, "speed", None)) + return {"message": f"the joints are at {angles}"} + + +def _home(robot: ULite6, row: dict) -> dict: + robot.go_home() + return {"message": "the arm is home"} + + +def _home_and_clear(robot: ULite6, row: dict) -> dict: + robot.home_and_clear() + return {"message": "the arm was cleared and driven home"} + + +# --- everything that answers immediately --- + + +def _clear(robot: ULite6, row: dict) -> dict: + robot.clear() + return {"message": "the controller error is cleared"} + + +def _recover(robot: ULite6, row: dict) -> dict: + robot.recover() + return {"message": "the arm is usable again"} + + +def _freedrive(robot: ULite6, row: dict) -> dict: + enabled = payload.flag(row, "enabled", True) + robot.set_freedrive(enabled) + return {"message": f"freedrive {'on' if enabled else 'off'}"} + + +def _grasp(robot: ULite6, row: dict) -> dict: + robot.engage_tool() + return {"message": "the tool is holding"} + + +def _release(robot: ULite6, row: dict) -> dict: + robot.release_tool() + return {"message": "the tool has let go"} + + +def _get_state(robot: ULite6, row: dict) -> dict: + """Read the arm and hand back everything a caller might otherwise poll for. + + Reads the report cache, not the wire. That is what lets this run on the event + loop and answer *during* a motion — the point of polling at all — where a + `get_position()` round trip would block behind the move on the same socket. + + An arm the report cache cannot speak for answers `connected: false` rather + than handing back the pose it held before the cable was pulled. + """ + reading = robot.read_report() + state = { + "connected": reading is not None, + "ready": robot.is_ready(), + "tool_engaged": robot.tool_engaged, + "freedrive": robot.freedrive_on, + "pose": [], + "joints": [], + } + if reading is None: + return {"message": "not connected", **state} + joints, flange = reading + state["pose"] = with_tool_offset(flange, robot.config.tool_offset_mm).as_list() + state["joints"] = joints + return {"message": "ready" if state["ready"] else "not ready", **state} + + +SERVICES = { + "move_to": _move_to, + "move_joints": _move_joints, + "home": _home, + "home_and_clear": _home_and_clear, + "clear": _clear, + "recover": _recover, + "freedrive": _freedrive, + "grasp": _grasp, + "release": _release, + "get_state": _get_state, +} + +# The services that drive the arm across the table. They are what `robot_state` +# reports as `moving`, and what a `stop` is there to interrupt. +MOVES_THE_ARM = frozenset({"move_to", "move_joints", "home", "home_and_clear"}) + +# Served on the event loop rather than the control thread. See the module note. +# Adding to this set is a promise that the service never blocks and is safe from +# a thread other than the control one — check the driver method before you do. +IMMEDIATE = frozenset({"get_state"}) + +# Named by callers and accepted, but handled by neither of the above. See the +# module note for why it has a thread to itself. +STOP = "stop" + +# Every name a request may carry. +NAMES = frozenset(SERVICES) | {STOP} diff --git a/examples/caller.py b/examples/caller.py new file mode 100644 index 0000000..95b8cd3 --- /dev/null +++ b/examples/caller.py @@ -0,0 +1,64 @@ +"""A minimal caller: what using the node's services looks like. + +Run by `dataflow.yml`. It sends a motion with `wait: false`, follows it on +`motion_state`, and then sends a second one that preempts it. That is the whole +convention: + +- a request carries a `request_id` you choose, and gets exactly one `response` + under that id — an acknowledgement when `wait` is false, the outcome when it + is true; +- a motion's progress is on `motion_state`, keyed by the same request_id, so a + non-blocking caller can tell "mine has not started" from "mine has finished" + without a race; +- a new motion replaces the one in play, because the arm is one resource. +""" + +import pyarrow as pa +from dora import Node + + +def main() -> None: + """Send a non-blocking motion, follow it, preempt it, and exit.""" + node = Node("caller") + sent_first = False + sent_second = False + + for event in node: + if event["type"] != "INPUT": + continue + + if event["id"] == "tick" and not sent_first: + sent_first = True + _move(node, "move-1", z=200.0) + + elif event["id"] == "response": + row = event["value"][0].as_py() + print( + f"response to {event['metadata'].get('request_id')}: " + f"ok={row['ok']} {row['message']}", + flush=True, + ) + + elif event["id"] == "motion_state": + row = event["value"][0].as_py() + print(f"{row['request_id']} is {row['state']}", flush=True) + + # Once the first one is really under way, change our mind. + if row["request_id"] == "move-1" and row["state"] == "moving" and not sent_second: + sent_second = True + _move(node, "move-2", z=120.0) + elif row["request_id"] == "move-2" and row["state"] in ("done", "failed"): + break + + +def _move(node: Node, request_id: str, z: float) -> None: + """Ask for a move and come straight back, rather than waiting it out.""" + node.send_output( + "request", + pa.array([{"service": "move_to", "x": 250.0, "y": 0.0, "z": z, "wait": False}]), + metadata={"request_id": request_id}, + ) + + +if __name__ == "__main__": + main() diff --git a/examples/dora-node.yml b/examples/dora-node.yml new file mode 100644 index 0000000..b8bf260 --- /dev/null +++ b/examples/dora-node.yml @@ -0,0 +1,30 @@ +# Manifest for the example caller, so `dora validate` matches it against this +# node rather than against the arm node's manifest at the repository root — +# dora resolves a node's manifest from its path, and without this the caller +# would be checked against an entrypoint it does not have. +apiVersion: 1 +name: caller +namespace: liria +description: Example caller showing a non-blocking motion followed on motion_state, then preempted +categories: [debug] + +runtime: python +# Resolved against this manifest's own directory: / +# must name the same file as the dataflow's `path:`. +entrypoint: caller.py + +inputs: + tick: + type: std/core/v1/UInt64 + required: true + description: Sends the first motion on the first tick. + response: + required: true + description: The answer to a request, under its request_id. + motion_state: + required: true + description: Where the current motion stands, which is how a non-blocking caller follows it. + +outputs: + request: + description: A service request, carrying a request_id in its metadata. diff --git a/pyproject.toml b/pyproject.toml index 90ce414..7ebc910 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,28 +1,42 @@ [project] name = "dora-ulite6" -version = "0.1.2" -license = "MIT" -authors = [ - { name = "Dora" } -] -description = "Dora node for controlling the UFactory Lite6 robot" - -requires-python = ">=3.12" +version = "0.3.0" +description = "Dora node for a UFactory Lite6 arm: motion and control services, and joint and tool-pose telemetry" +readme = "README.md" +license = { text = "MIT" } +# dora-rs 1.0 requires 3.11; the driver uses zip(strict=) and PEP 604 unions. +requires-python = ">=3.11" dependencies = [ - "dora-rs == 0.5.0", - "numpy < 2.0.0", - "scipy >= 1.14.0", + "dora-rs >= 1.0.1", + "numpy >= 2.0", + "pyarrow >= 17.0", + "scipy >= 1.14", "xarm-python-sdk >= 1.17.3", - "fastapi >= 0.109.0", - "uvicorn >= 0.27.0", ] -[dependency-groups] -dev = ["pytest >=8.1.1", "ruff >=0.9.1"] - [project.scripts] dora-ulite6 = "dora_ulite6.main:main" -[tool.setuptools] +[dependency-groups] +dev = ["pytest >= 8.1", "ruff >= 0.9"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] packages = ["dora_ulite6"] + +[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"] diff --git a/scripts/install.sh b/scripts/install.sh deleted file mode 100755 index c21bc32..0000000 --- a/scripts/install.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash -# Install dependencies for dora-ulite6. -# Requires: Python >=3.8 -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_DIR="${SCRIPT_DIR}/.." - -# Install uv if not present -if ! command -v uv &>/dev/null; then - echo ">>> Installing uv..." - curl -LsSf https://astral.sh/uv/install.sh | sh -fi - -# Create venv and install -echo ">>> Installing dora-ulite6 in editable mode..." -cd "$REPO_DIR" -uv venv --seed --allow-existing -uv pip install -e ".[dev]" - -echo ">>> Done. Activate with: source .venv/bin/activate" 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..f7f7279 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,169 @@ +"""Shared fakes. Nothing here needs a robot, a dataflow or a daemon.""" + +import time + +import pytest + +from dora_ulite6.pose import RobotPose + +TIMEOUT = 2.0 + + +class FakeArm: + """Stands in for the driver: records what it was asked and answers instantly. + + ``block`` lets a test hold a motion open the way a real one does, which is + the only way to observe feedback and cancellation at all. + """ + + def __init__(self, config): + self.config = config + self.calls = [] + self.ready = True + self.tool_engaged = False + self.freedrive_on = False + self.pose = RobotPose(300, 0, 250) + self.joints = [0.0] * 6 + self.read_error = None + self.move_error = None + self.command_error = None + self.recover_error = None + self.block = None + self.stopped = False + self.connected = True + + # --- reads --- + + def is_connected(self): + return self.connected + + def connect(self): + self.calls.append("connect") + return self.connected + + def is_ready(self): + return self.ready + + def read_report(self): + # A report cache that cannot speak for the arm returns nothing, the way + # the real one does when the link is down. + if self.read_error: + return None + return list(self.joints), self.pose + + def get_joint_angles(self): + if self.read_error: + raise self.read_error + return list(self.joints) + + def get_current_position(self): + if self.read_error: + raise self.read_error + return self.pose + + # --- motion --- + + def _move(self, what): + self.calls.append(what) + if self.block is not None: + # Hold the "motion" open until the test releases it or stops the arm. + deadline = time.monotonic() + TIMEOUT + while not self.block.is_set() and not self.stopped and time.monotonic() < deadline: + time.sleep(0.005) + if self.stopped: + from dora_ulite6.errors import MotionInterruptedError + + raise MotionInterruptedError("aborted by stop") + if self.move_error: + raise self.move_error + + def go_home(self): + self._move("go_home") + + def home_and_clear(self): + self._move("home_and_clear") + + def move_to(self, pose, speed=None): + self._move(("move_to", pose.as_list(), speed)) + + def set_joint_angles(self, angles, speed=None): + self._move(("set_joint_angles", angles, speed)) + + def set_cartesian_velocity(self, velocity): + self.calls.append(("jog", velocity)) + + def stop(self): + self.calls.append("stop") + self.stopped = True + + # --- the rest --- + + def clear(self): + self.calls.append("clear") + self.stopped = False + + def recover(self): + self.calls.append("recover") + if self.recover_error: + raise self.recover_error + self.read_error = None + + def engage_tool(self): + self.calls.append("engage_tool") + self.tool_engaged = True + + def release_tool(self): + self.calls.append("release_tool") + self.tool_engaged = False + + def set_freedrive(self, enabled): + self.calls.append(("set_freedrive", enabled)) + if self.command_error: + raise self.command_error + self.freedrive_on = enabled + + +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 precisely what this node's service and action patterns are built + on — the request_id and goal_id a caller correlates by. So the tests record + it themselves. + """ + + def __init__(self, events=()): + self._events = iter(list(events)) + self.sent = [] + + def is_restart(self): + return False + + def __iter__(self): + return self + + def __next__(self): + return next(self._events) + + 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 (row, metadata) pairs.""" + return [(data[0].as_py(), metadata) for name, data, metadata in self.sent if name == output_id] + + +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 {})} + + +def wait_for(predicate): + """Poll until ``predicate`` holds, or fail the test rather than hang it.""" + deadline = time.monotonic() + TIMEOUT + while time.monotonic() < deadline: + result = predicate() + if result: + return result + time.sleep(0.005) + pytest.fail("the control threads never reached the expected state") diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..0b63347 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,110 @@ +"""Reading the environment, and refusing a setting the node cannot use.""" + +import pytest + +from dora_ulite6.config import Config, env_float, env_int +from dora_ulite6.effectors import ( + EFFECTORS, + GripperEffector, + NoEffector, + VacuumEffector, + get_effector, +) +from dora_ulite6.errors import EffectorUnsupportedError + + +@pytest.fixture(autouse=True) +def clean_env(monkeypatch): + """Run every test against a bare environment, whatever the shell set.""" + for name in ("ROBOT_IP", "TOOL_TYPE", "TOOL_OFFSET_MM", "INIT_MODE", "INIT_Z", "DEFAULT_SPEED"): + monkeypatch.delenv(name, raising=False) + + +def test_the_defaults_need_no_environment(): + config = Config.from_env() + + assert config.robot_ip == "192.168.1.192" + assert isinstance(config.effector, NoEffector) + assert config.tool_offset_mm == 0.0 + assert config.init_mode == "none" + + +def test_the_tool_offset_is_the_tools_own_plus_the_extra(monkeypatch): + monkeypatch.setenv("TOOL_TYPE", "vacuum") + monkeypatch.setenv("TOOL_OFFSET_MM", "4.5") + + config = Config.from_env() + + assert isinstance(config.effector, VacuumEffector) + assert config.tool_offset_mm == pytest.approx(VacuumEffector.offset_mm + 4.5) + + +def test_an_unknown_tool_is_refused_at_startup(monkeypatch): + monkeypatch.setenv("TOOL_TYPE", "magnet") + + with pytest.raises(ValueError, match="vacuum"): + Config.from_env() + + +def test_an_unknown_init_mode_is_refused_at_startup(monkeypatch): + # Caught here rather than at the first motion, so the node never joins the + # dataflow with a startup move nobody can name. + monkeypatch.setenv("INIT_MODE", "somewhere") + + with pytest.raises(ValueError, match="INIT_MODE"): + Config.from_env() + + +def test_init_pose_components_are_read(monkeypatch): + monkeypatch.setenv("INIT_MODE", "pose") + monkeypatch.setenv("INIT_Z", "180") + + assert Config.from_env().init_pose.z == 180.0 + + +@pytest.mark.parametrize("value", ["", " "]) +def test_a_blank_variable_falls_back_to_the_default(monkeypatch, value): + monkeypatch.setenv("DEFAULT_SPEED", value) + + assert env_float("DEFAULT_SPEED", 30.0) == 30.0 + + +def test_a_number_that_is_not_one_names_itself(monkeypatch): + monkeypatch.setenv("PATH_SAMPLES", "four") + + with pytest.raises(ValueError, match="PATH_SAMPLES"): + env_int("PATH_SAMPLES", 4) + + +def test_a_bare_flange_refuses_to_grasp(): + with pytest.raises(EffectorUnsupportedError): + NoEffector().attach(object()) + + +def test_an_effector_is_refused_by_a_driver_that_cannot_switch_it(): + class ArmWithNoTools: + pass + + with pytest.raises(EffectorUnsupportedError, match="set_vacuum"): + VacuumEffector().verify(ArmWithNoTools()) + + +def test_each_effector_switches_the_driver_method_it_declares(): + switched = [] + + class Arm: + def set_gripper(self, closed): + switched.append(closed) + + arm = Arm() + effector = get_effector("gripper") + effector.verify(arm) + effector.attach(arm) + effector.detach(arm) + + assert isinstance(effector, GripperEffector) + assert switched == [True, False] + + +def test_every_registered_effector_declares_a_reach(): + assert all(effector.offset_mm >= 0 for effector in EFFECTORS.values()) diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py new file mode 100644 index 0000000..5e50c83 --- /dev/null +++ b/tests/test_lifecycle.py @@ -0,0 +1,315 @@ +"""Starting up, jogging and shutting down: the node's behaviour as a dora node.""" + +import threading +import time + +import pytest + +from dora_ulite6.config import Config, MotionConfig +from dora_ulite6.control import MOVING, PREEMPTED, READY, UNAVAILABLE, Control, ServiceCall +from dora_ulite6.pose import RobotPose +from tests.conftest import FakeArm, wait_for + + +def build(monkeypatch, connected=True, **config): + """Return a started Control over a fake arm, and the arms it built.""" + arms = [] + + def make(cfg): + arm = FakeArm(cfg) + arm.connected = connected + arms.append(arm) + return arm + + monkeypatch.setattr("dora_ulite6.control.ULite6", make) + restarted = config.pop("restarted", False) + control = Control(Config(telemetry_interval_s=0.01, **config), restarted=restarted) + control.arms = arms + control.start() + wait_for(lambda: control.robot is not None) + return control + + +# --- shutting down --- + + +def test_shutdown_halts_the_arm_before_leaving(monkeypatch): + # A motion lives in the controller, not in this process: exiting without + # commanding a stop leaves an arm travelling with nobody left to stop it. + control = build(monkeypatch) + control.arms[0].block = threading.Event() + control.submit(ServiceCall(request_id="m1", row={"service": "home"})) + wait_for(lambda: "go_home" in control.arms[0].calls) + + assert control.shutdown() is True + assert "stop" in control.arms[0].calls + + +def test_shutdown_reports_a_control_thread_it_could_not_get_back(monkeypatch): + # The caller's cue to leave rather than hang until dora kills the process. + control = build(monkeypatch, motion=MotionConfig(ready_timeout_s=0.1)) + control.arms[0].block = threading.Event() + # An arm that ignores the stop is one whose thread will not come back. + control.arms[0].stop = lambda: control.arms[0].calls.append("stop") + control.submit(ServiceCall(request_id="m1", row={"service": "home"})) + wait_for(lambda: "go_home" in control.arms[0].calls) + + assert control.shutdown() is False + + control.arms[0].block.set() + + +def test_shutdown_is_clean_when_the_arm_was_idle(monkeypatch): + control = build(monkeypatch) + + assert control.shutdown() is True + + +# --- restarting --- + + +@pytest.mark.parametrize("init_mode", ["home", "pose"]) +def test_a_restart_leaves_the_arm_where_it_is(monkeypatch, init_mode): + # Driving a real arm to INIT_MODE because the process came back is a motion + # nobody asked for. + control = build(monkeypatch, init_mode=init_mode, restarted=True) + try: + wait_for(lambda: READY in control.take_state_changes()) + assert control.arms[0].calls == [] + finally: + control.shutdown() + + +def test_a_first_start_still_runs_init_mode(monkeypatch): + control = build(monkeypatch, init_mode="home") + try: + wait_for(lambda: "go_home" in control.arms[0].calls) + finally: + control.shutdown() + + +# --- an arm that is not serving yet --- + + +def messages_on(control, output, seen): + """Collect messages on one output, draining the outbox as the event loop would.""" + seen.extend(control.take_messages()) + return [message for message in seen if message.output == output] + + +def test_a_request_sent_while_the_arm_is_down_is_still_answered(monkeypatch): + # An arm that is down is an outcome to report, not a reason to stop + # answering. `recover` in particular has to reach a controller that answers + # while refusing to serve — that state is the one it exists to escape. + control = build(monkeypatch, connected=False, reconnect_interval_s=0.05) + seen = [] + try: + wait_for(lambda: UNAVAILABLE in control.take_state_changes()) + + control.submit(ServiceCall(request_id="r1", row={"service": "recover"})) + + responses = wait_for(lambda: messages_on(control, "response", seen)) + assert responses[0].metadata["request_id"] == "r1" + assert responses[0].fields["ok"] is True + assert "recover" in control.arms[0].calls + finally: + control.shutdown() + + +def test_a_motion_sent_while_the_arm_is_down_is_refused_rather_than_held(monkeypatch): + # Holding it would be worse than silence: the arm is somewhere unknown and + # nobody is watching, so a held motion would fire unattended the moment the + # link came back. + control = build(monkeypatch, connected=False, reconnect_interval_s=0.05) + seen = [] + try: + wait_for(lambda: UNAVAILABLE in control.take_state_changes()) + + control.start_motion(ServiceCall(request_id="m1", row={"service": "home"})) + + responses = wait_for(lambda: messages_on(control, "response", seen)) + assert responses[0].metadata["request_id"] == "m1" + assert responses[0].fields["ok"] is False + + control.arms[0].connected = True + wait_for(lambda: READY in control.take_state_changes()) + assert "go_home" not in control.arms[0].calls + finally: + control.shutdown() + + +# --- jogging --- + + +def test_a_jog_makes_the_arm_report_that_it_is_moving(monkeypatch): + # An arm that is travelling must never look idle. + control = build(monkeypatch) + try: + wait_for(lambda: READY in control.take_state_changes()) + + control.start_jog([20.0, 0, 0, 0, 0, 0]) + + assert MOVING in control.take_state_changes() + wait_for(lambda: any(c[0] == "jog" for c in control.arms[0].calls if isinstance(c, tuple))) + finally: + control.shutdown() + + +def test_a_stream_of_jogs_is_one_state_change_not_hundreds(monkeypatch): + control = build(monkeypatch) + try: + wait_for(lambda: READY in control.take_state_changes()) + for _ in range(20): + control.start_jog([20.0, 0, 0, 0, 0, 0]) + + assert control.take_state_changes() == [MOVING] + finally: + control.shutdown() + + +def test_a_zero_velocity_jog_puts_the_arm_back_to_ready(monkeypatch): + control = build(monkeypatch) + try: + wait_for(lambda: READY in control.take_state_changes()) + control.start_jog([20.0, 0, 0, 0, 0, 0]) + control.take_state_changes() + + control.start_jog([0.0] * 6) + + assert READY in control.take_state_changes() + finally: + control.shutdown() + + +def test_a_zero_velocity_jog_does_not_make_a_running_motion_look_idle(monkeypatch): + # A teleop source streaming zero velocity says nothing about a motion that + # owns the arm, and must not answer for it: an arm that is travelling must + # never look idle, and a `ready` here would also stop the motion's own end + # from ever being published. + control = build(monkeypatch) + control.arms[0].block = threading.Event() + try: + wait_for(lambda: READY in control.take_state_changes()) + control.submit(ServiceCall(request_id="m1", row={"service": "home"})) + wait_for(lambda: MOVING in control.take_state_changes()) + + for _ in range(5): + control.start_jog([0.0] * 6) + + assert control.take_state_changes() == [] + + control.arms[0].block.set() + wait_for(lambda: READY in control.take_state_changes()) + finally: + control.shutdown() + + +def test_a_sender_that_goes_quiet_stops_being_called_jogging(monkeypatch): + # The controller expires the command on its own; nothing tells us, so the + # clock has to. + control = build(monkeypatch, motion=MotionConfig(velocity_command_ttl_s=0.02)) + try: + wait_for(lambda: READY in control.take_state_changes()) + control.start_jog([20.0, 0, 0, 0, 0, 0]) + control.take_state_changes() + + wait_for(lambda: READY in control.take_state_changes()) + finally: + control.shutdown() + + +def test_a_jog_preempts_a_motion_that_is_running(monkeypatch): + control = build(monkeypatch) + control.arms[0].block = threading.Event() + try: + control.start_motion(ServiceCall(request_id="m1", row={"service": "home"}, wait=False)) + wait_for(lambda: "go_home" in control.arms[0].calls) + + control.start_jog([20.0, 0, 0, 0, 0, 0]) + + wait_for(lambda: "stop" in control.arms[0].calls) + states = _motion_states(control, "m1") + wait_for(lambda: PREEMPTED in _motion_states(control, "m1", states)) + finally: + control.arms[0].block.set() + control.shutdown() + + +def test_a_motion_leaves_velocity_mode_through_the_front_door(monkeypatch): + # A pose command switches the controller out of velocity mode behind the + # driver's back, leaving its own bookkeeping wrong. + control = build(monkeypatch) + try: + wait_for(lambda: READY in control.take_state_changes()) + control.start_jog([20.0, 0, 0, 0, 0, 0]) + wait_for(lambda: any(isinstance(c, tuple) and c[0] == "jog" for c in control.arms[0].calls)) + + control.start_motion( + ServiceCall(request_id="m1", row={"service": "move_to", "x": 1.0, "y": 2.0, "z": 3.0}, wait=False) + ) + wait_for(lambda: any(isinstance(c, tuple) and c[0] == "move_to" for c in control.arms[0].calls)) + + jogs = [c for c in control.arms[0].calls if isinstance(c, tuple) and c[0] == "jog"] + assert jogs[-1] == ("jog", [0.0] * 6) + finally: + control.shutdown() + + +def _motion_states(control, request_id, into=None): + collected = into if into is not None else [] + collected.extend( + message.fields["state"] + for message in control.take_messages() + if message.output == "motion_state" and message.fields["request_id"] == request_id + ) + return collected + + +# --- the report invariant get_state rests on --- + + +def test_get_state_reads_the_cache_not_the_wire(monkeypatch): + control = build(monkeypatch) + try: + control.arms[0].pose = RobotPose(1, 2, 3) + control.call_now("s1", {"service": "get_state"}) + + fields = control.take_messages()[0].fields + + assert fields["pose"][:3] == [1, 2, 3] + # Nothing that costs a round trip was called. + assert control.arms[0].calls == [] + finally: + control.shutdown() + + +# --- the cadence itself --- + + +def test_the_reporter_honours_the_telemetry_interval(monkeypatch): + """The interval is a real sleep, not a flag that makes every wait return at once. + + Worth a test of its own because the failure is silent: the node behaves + correctly in every other respect while burning a core and reading the arm a + million times a second. + """ + reads = [] + + class Counting(FakeArm): + def read_report(self): + reads.append(1) + return super().read_report() + + monkeypatch.setattr("dora_ulite6.control.ULite6", Counting) + control = Control(Config(telemetry_interval_s=0.05)) + control.start() + try: + wait_for(lambda: reads) + reads.clear() + time.sleep(0.3) + finally: + control.shutdown() + + # ~6 at a 50 ms cadence. A spinning loop reaches six figures. + assert len(reads) < 30, f"the reporter read the arm {len(reads)} times in 0.3 s" + assert reads, "the reporter stopped reading the arm altogether" diff --git a/tests/test_loop.py b/tests/test_loop.py new file mode 100644 index 0000000..8a8fb07 --- /dev/null +++ b/tests/test_loop.py @@ -0,0 +1,215 @@ +"""The dora event loop: routing, correlation, and refusing without touching the arm.""" + +import pyarrow as pa +import pytest + +from dora_ulite6 import main +from dora_ulite6.config import Config +from dora_ulite6.control import Message, ServiceCall, Telemetry +from dora_ulite6.pose import RobotPose +from tests.conftest import RecordingNode, input_event + + +class SpyControl: + """Records what the loop hands the arm threads, without starting any.""" + + def __init__(self): + self.submitted = [] + self.motions = [] + self.called_now = [] + self.stops = [] + self.jogs = [] + self.shutdowns = [] + self.posted = [] + self._telemetry = None + self._states = [] + + def submit(self, job): + self.submitted.append(job) + + def start_motion(self, call): + self.motions.append(call) + + def call_now(self, request_id, row): + self.called_now.append((request_id, row)) + + def request_stop(self, request_id=""): + self.stops.append(request_id) + + def start_jog(self, velocity): + self.jogs.append(velocity) + + def shutdown(self): + self.shutdowns.append(True) + return True + + def post(self, message): + self.posted.append(message) + + def take_messages(self): + messages, self.posted = self.posted, [] + return messages + + def take_state_changes(self): + states, self._states = self._states, [] + return states + + def take_telemetry(self): + telemetry, self._telemetry = self._telemetry, None + return telemetry + + +def row(**fields): + return pa.array([fields]) + + +def handle(event, control=None): + """Run one event through the loop and return the node it wrote to.""" + node = RecordingNode() + main._handle(node, control or SpyControl(), event) + return node + + +def test_a_service_request_is_queued_with_its_request_id(): + control = SpyControl() + + main._handle(RecordingNode(), control, input_event("request", row(service="clear"), {"request_id": "r1"})) + + assert isinstance(control.submitted[0], ServiceCall) + assert control.submitted[0].request_id == "r1" + + +def test_stop_goes_to_the_stopper_rather_than_the_queue_or_the_loop(): + # It must not queue behind the motion it aborts, and must not run here + # either: the SDK spends up to three seconds confirming a stop. + control = SpyControl() + + main._handle(RecordingNode(), control, input_event("request", row(service="stop"), {"request_id": "r1"})) + + assert control.stops == ["r1"] + assert control.submitted == [] + assert control.called_now == [] + + +def test_get_state_is_the_one_service_run_on_the_loop(): + control = SpyControl() + + main._handle(RecordingNode(), control, input_event("request", row(service="get_state"), {"request_id": "r1"})) + + assert control.called_now == [("r1", {"service": "get_state"})] + + +def test_a_jog_goes_through_the_door_that_preempts(): + # A jog drives the arm, so it takes the same road a motion does. + control = SpyControl() + + main._handle( + RecordingNode(), control, input_event("jog", row(velocity=[1.0, 0, 0, 0, 0, 0])) + ) + + assert control.jogs == [[1.0, 0, 0, 0, 0, 0]] + assert control.submitted == [] + + +def test_a_jog_of_the_wrong_length_is_dropped_not_commanded(): + control = SpyControl() + + main._handle(RecordingNode(), control, input_event("jog", row(velocity=[1.0, 0]))) + + assert control.jogs == [] + + +def test_a_motion_goes_through_the_door_that_preempts(): + # Still one protocol for the caller; the routing differs because the arm is + # one resource and a new motion replaces the old. + control = SpyControl() + + main._handle( + RecordingNode(), + control, + input_event("request", row(service="move_to", x=1.0, y=2.0, z=3.0), {"request_id": "m1"}), + ) + + assert control.submitted == [] + assert control.motions[0].request_id == "m1" + assert control.motions[0].wait is True + + +def test_wait_false_is_read_off_the_request(): + control = SpyControl() + + main._handle( + RecordingNode(), + control, + input_event("request", row(service="home", wait=False), {"request_id": "m1"}), + ) + + assert control.motions[0].wait is False + + +def test_a_malformed_request_is_refused_under_its_own_id_without_reaching_the_arm(): + control = SpyControl() + + main._handle( + RecordingNode(), control, input_event("request", row(service="teleport"), {"request_id": "r1"}) + ) + + assert control.submitted == [] + refusal = control.posted[0] + assert refusal.output == "response" + assert refusal.metadata == {"request_id": "r1"} + assert refusal.fields["ok"] is False + assert "teleport" in refusal.fields["message"] + + +def test_an_unexpected_input_is_ignored(): + node = handle(input_event("weather", row(sunny=True))) + + assert node.sent == [] + + +def test_a_tick_publishes_a_response_under_its_request_id(): + control = SpyControl() + control.post(Message("response", {"ok": True, "message": "done"}, {"request_id": "r1"})) + + node = handle(input_event("tick", pa.array([0])), control) + + assert node.outputs("response") == [({"ok": True, "message": "done"}, {"request_id": "r1"})] + + +def test_a_tick_publishes_telemetry_and_state_changes(): + control = SpyControl() + control._states = ["ready"] + control._telemetry = Telemetry([1.0] * 6, RobotPose(1, 2, 3), 42) + + node = handle(input_event("tick", pa.array([0])), control) + + sent = {name: (data, metadata) for name, data, metadata in node.sent} + assert sent["robot_state"][0].to_pylist() == ["ready"] + assert sent["joint_pose"][0].to_pylist() == [1.0] * 6 + assert sent["tcp_pose"][0].to_pylist() == pytest.approx([1, 2, 3, 180, 0, 0]) + assert sent["tcp_pose"][1]["frame"] == "tool_tip" + + +def test_a_tick_with_nothing_new_publishes_nothing(): + node = handle(input_event("tick", pa.array([0]))) + + assert node.sent == [] + + +def test_the_loop_runs_until_dora_stops_it(monkeypatch): + # The loop must drain on tick and leave on STOP, shutting the threads down. + control = SpyControl() + control.post(Message("response", {"ok": True}, {"request_id": "r1"})) + + node = RecordingNode([input_event("tick", pa.array([0])), {"type": "STOP"}]) + monkeypatch.setattr(main, "Node", lambda name: node) + monkeypatch.setattr(main, "Control", lambda config, restarted: control) + monkeypatch.setattr(main, "_parse_args", lambda: type("Args", (), {"name": "ulite6"})()) + monkeypatch.setattr(Config, "from_env", classmethod(lambda cls: Config())) + control.start = lambda: None + + main.main() + + assert node.outputs("response")[0][1] == {"request_id": "r1"} + assert control.shutdowns == [True] diff --git a/tests/test_pose.py b/tests/test_pose.py new file mode 100644 index 0000000..7cc1c83 --- /dev/null +++ b/tests/test_pose.py @@ -0,0 +1,71 @@ +"""Pose maths and the tool-tip offset. No robot needed.""" + +import numpy as np +import pytest + +from dora_ulite6.controller import interpolate, shortest_turn +from dora_ulite6.pose import RobotPose, with_tool_offset + + +def test_from_sequence_accepts_a_position_alone(): + pose = RobotPose.from_sequence([10, 20, 30]) + + assert pose.as_list() == [10, 20, 30, 180.0, 0.0, 0.0] + + +def test_from_sequence_round_trips_a_full_pose(): + values = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0] + + assert RobotPose.from_sequence(values).as_list() == values + + +@pytest.mark.parametrize( + ("start", "target", "expected"), + [(-178.0, 180.0, -2.0), (180.0, -178.0, 2.0), (0.0, 90.0, 90.0), (0.0, 270.0, -90.0)], +) +def test_shortest_turn_never_takes_the_long_way(start, target, expected): + assert shortest_turn(start, target) == pytest.approx(expected) + + +def test_interpolate_turns_the_short_way_across_the_wrap(): + halfway = interpolate([0, 0, 0, -178, 0, 0], [0, 0, 0, 180, 0, 0], 0.5) + + assert halfway[3] == pytest.approx(-179.0) + + +def test_interpolate_walks_the_straight_line_in_position(): + halfway = interpolate([0, 0, 0, 180, 0, 0], [100, 200, 300, 180, 0, 0], 0.5) + + assert halfway[:3] == pytest.approx([50, 100, 150]) + + +def test_tool_down_offset_lowers_the_tip(): + tip = with_tool_offset(RobotPose(300, 0, 250, roll=180, pitch=0, yaw=0), 61.0) + + assert tip.z == pytest.approx(250 - 61.0) + assert (tip.x, tip.y) == pytest.approx((300, 0)) + + +def test_offset_follows_the_tool_axis_not_the_base_axis(): + # Pitched 90° from tool-down, the tool points along +x rather than -z. + tip = with_tool_offset(RobotPose(300, 0, 250, roll=0, pitch=90, yaw=0), 61.0) + + assert tip.x == pytest.approx(300 + 61.0) + assert tip.z == pytest.approx(250) + + +def test_a_zero_offset_returns_the_pose_untouched(): + pose = RobotPose(1, 2, 3, 4, 5, 6) + + assert with_tool_offset(pose, 0.0) is pose + + +def test_the_offset_inverts_exactly(): + # move_to relies on this: it takes the offset back off a tip pose to get the + # flange pose the controller wants. + pose = RobotPose(123, -45, 210, roll=170, pitch=12, yaw=-30) + + there = with_tool_offset(pose, 61.0) + back = with_tool_offset(there, -61.0) + + np.testing.assert_allclose(back.as_list(), pose.as_list(), atol=1e-9) diff --git a/tests/test_preempt.py b/tests/test_preempt.py new file mode 100644 index 0000000..adc02df --- /dev/null +++ b/tests/test_preempt.py @@ -0,0 +1,231 @@ +"""Non-blocking motions, and a new one taking the place of the old.""" + +import threading + +import pytest + +from dora_ulite6.config import Config +from dora_ulite6.control import ( + ACCEPTED, + DONE, + MOVING, + PREEMPTED, + Control, + ServiceCall, +) +from tests.conftest import FakeArm, wait_for + + +@pytest.fixture +def control(monkeypatch): + """Start a Control over a fake arm whose motions can be held open.""" + arms = [] + + def build(config): + arm = FakeArm(config) + arm.block = threading.Event() + arms.append(arm) + return arm + + monkeypatch.setattr("dora_ulite6.control.ULite6", build) + control = Control(Config(telemetry_interval_s=0.01, reconnect_interval_s=0.01)) + control.arms = arms + control.start() + wait_for(lambda: control.robot is not None) + yield control + arms[0].block.set() + control.shutdown() + + +class Mail: + """Accumulates what the control threads produced, so nothing is missed.""" + + def __init__(self, control): + self.control = control + self.messages = [] + + def drain(self): + self.messages.extend(self.control.take_messages()) + return self.messages + + def states(self, request_id): + """Return the motion_state lifecycle published for one request, in order.""" + self.drain() + return [ + m.fields["state"] + for m in self.messages + if m.output == "motion_state" and m.fields["request_id"] == request_id + ] + + def responses(self, request_id): + self.drain() + return [ + m.fields + for m in self.messages + if m.output == "response" and m.metadata.get("request_id") == request_id + ] + + def wait_for_state(self, request_id, state): + wait_for(lambda: state in self.states(request_id)) + + +def motion(request_id, wait=False, service="home", **args): + return ServiceCall(request_id=request_id, row={"service": service, **args}, wait=wait) + + +def test_a_non_blocking_motion_is_answered_before_the_arm_stops(control): + mail = Mail(control) + + control.start_motion(motion("m1", wait=False)) + wait_for(lambda: mail.responses("m1")) + + # The caller has its answer while the arm is still held open — the response + # was never waiting on the motion, so the motion has not even been reported + # as finished. + assert not control.arms[0].block.is_set() + assert DONE not in mail.states("m1") + assert mail.responses("m1")[0] == { + "ok": True, + "service": "home", + "message": "accepted", + "accepted": True, + } + + +def test_a_non_blocking_motion_reports_completion_only_on_the_state(control): + mail = Mail(control) + + control.start_motion(motion("m1", wait=False)) + mail.wait_for_state("m1", MOVING) + control.arms[0].block.set() + mail.wait_for_state("m1", DONE) + + assert mail.states("m1") == [ACCEPTED, MOVING, DONE] + # Exactly one response, the acknowledgement. A second would break the + # one-response-per-request contract the caller correlates by. + assert len(mail.responses("m1")) == 1 + + +def test_a_blocking_motion_is_answered_only_when_the_arm_stops(control): + mail = Mail(control) + + control.start_motion(motion("m1", wait=True)) + mail.wait_for_state("m1", MOVING) + + assert mail.responses("m1") == [] + + control.arms[0].block.set() + wait_for(lambda: mail.responses("m1")) + + assert mail.responses("m1")[0]["ok"] is True + + +def test_accepted_is_published_before_the_thread_reaches_the_motion(control): + # The race a bare moving/idle flag leaves open: a caller polling right after + # its request must not read a state that says nothing is moving. + mail = Mail(control) + control.arms[0].block = threading.Event() + + control.start_motion(motion("m1", wait=False)) + + assert mail.states("m1")[0] == ACCEPTED + + +def test_a_new_motion_preempts_the_one_running(control): + mail = Mail(control) + control.start_motion(motion("m1", wait=False)) + mail.wait_for_state("m1", MOVING) + + control.start_motion(motion("m2", wait=False, service="move_to", x=1.0, y=2.0, z=3.0)) + + mail.wait_for_state("m1", PREEMPTED) + assert "stop" in control.arms[0].calls + assert mail.states("m1") == [ACCEPTED, MOVING, PREEMPTED] + + +def test_the_preempting_motion_then_runs(control): + mail = Mail(control) + control.start_motion(motion("m1", wait=False)) + mail.wait_for_state("m1", MOVING) + + control.start_motion(motion("m2", wait=False, service="move_to", x=1.0, y=2.0, z=3.0)) + mail.wait_for_state("m1", PREEMPTED) + control.arms[0].stopped = False + control.arms[0].block.set() + mail.wait_for_state("m2", DONE) + + # The software stop the preemption caused is cleared before the new move. + assert "clear" in control.arms[0].calls + assert any(isinstance(call, tuple) and call[0] == "move_to" for call in control.arms[0].calls) + + +def test_a_preempted_blocking_caller_is_released_with_a_failure(control): + mail = Mail(control) + control.start_motion(motion("m1", wait=True)) + mail.wait_for_state("m1", MOVING) + + control.start_motion(motion("m2", wait=False)) + + wait_for(lambda: mail.responses("m1")) + answer = mail.responses("m1")[0] + assert answer["ok"] is False + assert "preempted by m2" in answer["message"] + + +def test_a_motion_still_waiting_its_turn_is_dropped_without_moving_the_arm(control): + # Driven straight at the branch rather than through the threads: whether a + # preemption lands before or after the control thread picks the motion up is + # a race by nature, and only one side of it is this behaviour. + mail = Mail(control) + call = motion("m2", wait=True, service="move_to", x=1.0, y=2.0, z=3.0) + call.preempted_by = "m3" + + control._run_motion(call) + + assert mail.states("m2") == [PREEMPTED] + assert mail.responses("m2")[0]["ok"] is False + assert "preempted by m3" in mail.responses("m2")[0]["message"] + assert not any(isinstance(c, tuple) and c[0] == "move_to" for c in control.arms[0].calls) + + +def test_every_preempted_request_is_answered_exactly_once(control): + mail = Mail(control) + control.start_motion(motion("m1", wait=True)) + mail.wait_for_state("m1", MOVING) + control.start_motion(motion("m2", wait=True)) + mail.wait_for_state("m1", PREEMPTED) + # m2 may or may not have started by now; either way m3 takes its place, and + # what must hold is that nobody is left without an answer. + control.start_motion(motion("m3", wait=True)) + control.arms[0].block.set() + + for request_id in ("m1", "m2", "m3"): + wait_for(lambda rid=request_id: mail.responses(rid)) + assert len(mail.responses(request_id)) == 1, request_id + + +def test_a_preemption_that_stopped_nothing_still_clears_for_the_one_that_did(control): + # A preemption of a motion still waiting its turn stops nothing, and must + # not swallow the pending clear owed by an earlier one that did. + mail = Mail(control) + control.start_motion(motion("m1", wait=False)) + mail.wait_for_state("m1", MOVING) + + control.start_motion(motion("m2", wait=False)) # stops m1 + mail.wait_for_state("m1", PREEMPTED) + control.start_motion(motion("m3", wait=False)) # may stop nothing + control.arms[0].block.set() + + mail.wait_for_state("m3", DONE) + assert "clear" in control.arms[0].calls + + +def test_a_service_that_does_not_move_the_arm_never_preempts(control): + mail = Mail(control) + control.start_motion(motion("m1", wait=False)) + mail.wait_for_state("m1", MOVING) + + control.submit(ServiceCall(request_id="s1", row={"service": "clear"})) + + assert PREEMPTED not in mail.states("m1") + assert "stop" not in control.arms[0].calls diff --git a/tests/test_services.py b/tests/test_services.py new file mode 100644 index 0000000..ff8e5f9 --- /dev/null +++ b/tests/test_services.py @@ -0,0 +1,275 @@ +"""Services: one request, one response, correlated by request_id.""" + +import threading + +import pytest + +from dora_ulite6 import payload, services +from dora_ulite6.config import Config +from dora_ulite6.control import MOVING, READY, Control, ServiceCall +from dora_ulite6.errors import RobotUnavailableError, UnreachablePoseError +from tests.conftest import FakeArm, wait_for + + +@pytest.fixture +def control(monkeypatch): + """Start a Control over a fake arm, and shut it down when the test ends.""" + arms = [] + + def build(config): + arm = FakeArm(config) + arms.append(arm) + return arm + + monkeypatch.setattr("dora_ulite6.control.ULite6", build) + control = Control(Config(telemetry_interval_s=0.01, reconnect_interval_s=0.01)) + control.arms = arms + control.start() + wait_for(lambda: control.robot is not None) + yield control + control.shutdown() + + +def responses(control): + """Wait for at least one response and return them as (row, metadata) pairs.""" + found = [] + + def got_one(): + found.extend(m for m in control.take_messages() if m.output == "response") + return found + + wait_for(got_one) + return [(message.fields, message.metadata) for message in found] + + +def test_a_response_comes_back_under_the_requests_own_id(control): + control.submit(ServiceCall(request_id="req-7", row={"service": "clear"})) + + fields, metadata = responses(control)[0] + + assert metadata == {"request_id": "req-7"} + assert fields["ok"] is True + assert fields["service"] == "clear" + assert "clear" in control.arms[0].calls + + +def test_get_state_answers_with_everything_a_caller_would_otherwise_poll(control): + control.submit(ServiceCall(request_id="s1", row={"service": "get_state"})) + + fields, _ = responses(control)[0] + + assert fields["ok"] is True + assert fields["connected"] is True + assert fields["ready"] is True + assert fields["pose"] == [300, 0, 250, 180.0, 0.0, 0.0] + assert fields["joints"] == [0.0] * 6 + + +def test_the_tool_offset_reaches_the_state_response(monkeypatch): + monkeypatch.setattr("dora_ulite6.control.ULite6", FakeArm) + control = Control(Config(telemetry_interval_s=0.01, tool_offset_mm=61.0)) + control.start() + try: + wait_for(lambda: control.robot is not None) + control.submit(ServiceCall(request_id="s1", row={"service": "get_state"})) + fields, _ = responses(control)[0] + finally: + control.shutdown() + + assert fields["pose"][2] == pytest.approx(250 - 61.0) + + +def test_freedrive_reads_its_argument(control): + control.submit(ServiceCall(request_id="s1", row={"service": "freedrive", "enabled": False})) + responses(control) + + assert ("set_freedrive", False) in control.arms[0].calls + + +def test_grasp_and_release_go_through_the_mounted_tool(control): + control.submit(ServiceCall(request_id="s1", row={"service": "grasp"})) + responses(control) + control.submit(ServiceCall(request_id="s2", row={"service": "release"})) + responses(control) + + assert "engage_tool" in control.arms[0].calls + assert "release_tool" in control.arms[0].calls + + +def test_a_refusing_arm_makes_a_failed_response_not_an_exception(control): + control.arms[0].recover_error = RobotUnavailableError("still unreachable") + control.arms[0].command_error = RobotUnavailableError("cable pulled") + + control.submit(ServiceCall(request_id="s1", row={"service": "freedrive"})) + fields, metadata = responses(control)[0] + + assert fields["ok"] is False + assert "cable pulled" in fields["message"] + assert metadata == {"request_id": "s1"} + + +def test_get_state_on_a_dead_link_says_so_rather_than_handing_back_a_stale_pose(control): + control.arms[0].read_error = RobotUnavailableError("cable pulled") + + control.call_now("s1", {"service": "get_state"}) + fields = control.take_messages()[0].fields + + assert fields["connected"] is False + assert fields["pose"] == [] + + +def test_get_state_answers_during_a_motion_rather_than_queueing_behind_it(control): + # The whole point of polling: it must work while the arm is moving. + control.arms[0].block = threading.Event() + control.start_motion(ServiceCall(request_id="m1", row={"service": "home"}, wait=False)) + wait_for(lambda: "go_home" in control.arms[0].calls) + + control.call_now("s1", {"service": "get_state"}) + + answers = [m for m in control.take_messages() if m.output == "response" and m.metadata == {"request_id": "s1"}] + assert answers and answers[0].fields["ok"] is True + control.arms[0].block.set() + + +def test_stop_does_not_queue_behind_the_motion_it_aborts(control): + # The motion service is held open the way a real one holds the thread. + control.arms[0].block = threading.Event() + control.submit(ServiceCall(request_id="m1", row={"service": "home"})) + wait_for(lambda: "go_home" in control.arms[0].calls) + + # Queued, this would sit behind `home` until the motion ended. + control.request_stop("s1") + + wait_for(lambda: "stop" in control.arms[0].calls) + assert any(metadata == {"request_id": "s1"} for _, metadata in responses(control)) + + +def test_stop_answers_without_waiting_for_the_arm_to_confirm(control): + # The SDK spends up to three seconds confirming a stop. The caller is + # answered at once and learns the outcome from the state, like everything + # else — the alternative is a dataflow that stops routing for three seconds. + control.arms[0].block = threading.Event() + control.submit(ServiceCall(request_id="m1", row={"service": "home"})) + wait_for(lambda: "go_home" in control.arms[0].calls) + + control.request_stop("s1") + answers = [m for m in control.take_messages() if m.metadata == {"request_id": "s1"}] + + # Answered on this thread, before the stopper thread has run at all. + assert answers and answers[0].fields["message"] == "stop commanded" + + +def test_a_motion_service_answers_only_once_the_arm_has_stopped(control): + control.arms[0].block = threading.Event() + control.submit(ServiceCall(request_id="m1", row={"service": "home"})) + wait_for(lambda: "go_home" in control.arms[0].calls) + + # The arm is still moving, so the caller has no answer yet. + assert [m for m in control.take_messages() if m.output == "response"] == [] + + control.arms[0].block.set() + fields, metadata = responses(control)[0] + + assert metadata == {"request_id": "m1"} + assert fields["ok"] is True + assert fields["message"] == "the arm is home" + + +def test_telemetry_keeps_flowing_while_a_motion_blocks(control): + # The reporter thread is not the one inside the motion, which is what lets a + # caller follow a service whose response will not arrive for seconds. + control.arms[0].block = threading.Event() + control.submit(ServiceCall(request_id="m1", row={"service": "home"})) + wait_for(lambda: "go_home" in control.arms[0].calls) + + assert wait_for(control.take_telemetry) is not None + + control.arms[0].block.set() + + +def test_the_state_says_moving_while_a_motion_service_runs(control): + control.arms[0].block = threading.Event() + + control.submit(ServiceCall(request_id="m1", row={"service": "home"})) + wait_for(lambda: MOVING in control.take_state_changes()) + + control.arms[0].block.set() + wait_for(lambda: READY in control.take_state_changes()) + + +def test_a_move_request_is_commanded_in_the_flange_frame(monkeypatch): + monkeypatch.setattr("dora_ulite6.control.ULite6", FakeArm) + control = Control(Config(telemetry_interval_s=0.01, tool_offset_mm=61.0)) + control.start() + try: + wait_for(lambda: control.robot is not None) + control.submit( + ServiceCall(request_id="m1", row={"service": "move_to", "x": 300, "y": 0, "z": 100}) + ) + responses(control) + move = next(call for call in control.robot.calls if call[0] == "move_to") + finally: + control.shutdown() + + # The caller asked for the tool tip at z=100, so the flange goes to 161. + assert move[1][2] == pytest.approx(161.0) + + +def test_an_unreachable_pose_fails_the_request_and_leaves_the_arm_ready(control): + control.arms[0].move_error = UnreachablePoseError("no joint solution") + + control.submit( + ServiceCall(request_id="m1", row={"service": "move_to", "x": 9000, "y": 0, "z": 0}) + ) + fields, metadata = responses(control)[0] + + assert fields["ok"] is False + assert "no joint solution" in fields["message"] + assert metadata == {"request_id": "m1"} + # The target was wrong, not the arm, so nothing tried to recover it. + assert "recover" not in control.arms[0].calls + + +def test_a_motion_missing_an_argument_is_refused_without_moving_the_arm(control): + control.submit(ServiceCall(request_id="m1", row={"service": "move_to", "x": 1, "y": 2})) + fields, _ = responses(control)[0] + + assert fields["ok"] is False + assert "missing `z`" in fields["message"] + assert control.arms[0].calls == [] + + +def test_move_joints_reads_its_angles(control): + control.submit( + ServiceCall(request_id="m1", row={"service": "move_joints", "angles": [1, 2, 3, 4, 5, 6]}) + ) + responses(control) + + assert ("set_joint_angles", [1.0, 2.0, 3.0, 4.0, 5.0, 6.0], None) in control.arms[0].calls + + +@pytest.mark.parametrize( + ("row", "expected"), + [ + ({}, "unknown service"), + ({"service": "teleport"}, "unknown service"), + ({"service": 7}, "unknown service"), + ], +) +def test_a_row_naming_no_known_service_is_refused(row, expected): + with pytest.raises(payload.PayloadError, match=expected): + payload.name_of(row, services.SERVICE, services.SERVICES) + + +def test_an_immediate_service_before_the_arm_is_reached_still_gets_an_answer(monkeypatch): + # `stop` runs on the event loop, so it can arrive before the control thread + # has built the driver. A caller waiting on a request_id must never hang. + monkeypatch.setattr("dora_ulite6.control.ULite6", FakeArm) + control = Control(Config()) + + control.call_now("s1", {"service": "stop"}) + + message = control.take_messages()[0] + assert message.metadata == {"request_id": "s1"} + assert message.fields["ok"] is False + assert "not been reached" in message.fields["message"] diff --git a/uv.lock b/uv.lock index d70effd..41ca2ea 100644 --- a/uv.lock +++ b/uv.lock @@ -1,48 +1,9 @@ version = 1 revision = 3 -requires-python = ">=3.12" - -[[package]] -name = "annotated-doc" -version = "0.0.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, -] - -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, -] - -[[package]] -name = "anyio" -version = "4.13.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, -] - -[[package]] -name = "click" -version = "8.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version < '3.12'", ] [[package]] @@ -56,36 +17,39 @@ wheels = [ [[package]] name = "dora-rs" -version = "0.5.0" +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/f6/67/3b65873c34a2f668dcc698e3ed9571cfefe58f33cf1b4a1a06a5581f5ea6/dora_rs-0.5.0.tar.gz", hash = "sha256:3e0e41d31821d5c647c84072efb8ea358d38abfb09ea96de7628859a7754bdcd", size = 427224, upload-time = "2026-03-25T19:26:08.881Z" } +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/14/70/705f0f7c13b7cc82665f98a5fd57057c078b0bb52afd4f6cd095c3451e18/dora_rs-0.5.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:80603b8a500dbe114bce5a333ef6858989b06064664c8c24ba3cb5d8b4fac2f2", size = 18258324, upload-time = "2026-03-25T19:25:55.26Z" }, - { url = "https://files.pythonhosted.org/packages/9d/be/91f42e924658b70bac526a6357627b6c37e3715621c357c5893e616e74a7/dora_rs-0.5.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:37c89d9266231c664d124a1f182e10421e3bfa36d938865e53c3380cc79aa76c", size = 17406326, upload-time = "2026-03-25T19:25:44.719Z" }, - { url = "https://files.pythonhosted.org/packages/f3/6f/4eccc13c10c92226be5bf069da7c6ae60fbd88b8af4b25a9826fbf791ab2/dora_rs-0.5.0-cp37-abi3-manylinux_2_28_armv7l.whl", hash = "sha256:a7d080908eb9e6b3f00060652ceea068ca5f944f03e975065fec2291f50fbb57", size = 15835974, upload-time = "2026-03-25T19:25:47.388Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0b/13a843554e602617fdb7a9fb7861534e843c9d217d3e2313a9d7faa902d6/dora_rs-0.5.0-cp37-abi3-manylinux_2_28_i686.whl", hash = "sha256:1213a2e9a76f2a488cc89abebb08a0de0047f031dea83fed3e969c69c55026b2", size = 19131472, upload-time = "2026-03-25T19:25:50.045Z" }, - { url = "https://files.pythonhosted.org/packages/38/f4/ada8cb79d7e535aaddc32fbc326557105e00902c75a272bc3ecddda903dd/dora_rs-0.5.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ab368789f72c2cfcd38f9871a0c7cafb8e9fb57d57f810672f71d9d43ffabe17", size = 17796791, upload-time = "2026-03-25T19:25:52.879Z" }, - { url = "https://files.pythonhosted.org/packages/b5/5b/546a15683d396674ba142e2ac0df19203468bce7082f958181ebe3458e1c/dora_rs-0.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:08f0617699aaa5d3a75d3f94f87dc2b3906dfec5b70ca24398f3b30d0b1c38eb", size = 21162981, upload-time = "2026-03-25T19:25:57.676Z" }, - { url = "https://files.pythonhosted.org/packages/b4/e9/511ecd0274996cec4d412a1ac0dbbc7ecd72b46026d6a44f838b9bda3bbd/dora_rs-0.5.0-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:cb21bad41e4013845edd5910167fd234fabc3420be8a03254781222afdb5235e", size = 19917806, upload-time = "2026-03-25T19:26:00.644Z" }, - { url = "https://files.pythonhosted.org/packages/77/28/b24148d34c8bb642be83d4a0a17545dc9e9962c42003dd9a96c07f347968/dora_rs-0.5.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:bfdcce9e1c5d40836f4300136de004fbdc34981ed9e790b93723801eaa99e9bd", size = 21215205, upload-time = "2026-03-25T19:26:03.455Z" }, - { url = "https://files.pythonhosted.org/packages/25/e1/1e629306ec502eb302e41ab4da718d11b9a528231ab27cce028591009711/dora_rs-0.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85c172e083cb300fcc3b95c14a675680633c533d62c0cae65ee73b0a73cb61a5", size = 20993466, upload-time = "2026-03-25T19:26:06.713Z" }, - { url = "https://files.pythonhosted.org/packages/b9/56/81d7da08648412f0dd5374d3331ddb69a0e2dd775952317709cabe8de123/dora_rs-0.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:b4353f50d3d1aca100057cd959474f30c3c39d4b3d2771adf6dc452eeec53d34", size = 15366445, upload-time = "2026-03-25T19:26:10.736Z" }, + { 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-ulite6" -version = "0.1.1" -source = { virtual = "." } +version = "0.3.0" +source = { editable = "." } dependencies = [ { name = "dora-rs" }, - { name = "fastapi" }, - { name = "numpy" }, - { name = "scipy" }, - { name = "uvicorn" }, + { 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 = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "xarm-python-sdk" }, ] @@ -97,52 +61,17 @@ dev = [ [package.metadata] requires-dist = [ - { name = "dora-rs", specifier = "==0.5.0" }, - { name = "fastapi", specifier = ">=0.109.0" }, - { name = "numpy", specifier = "<2.0.0" }, - { name = "scipy", specifier = ">=1.14.0" }, - { name = "uvicorn", specifier = ">=0.27.0" }, + { name = "dora-rs", specifier = ">=1.0.1" }, + { name = "numpy", specifier = ">=2.0" }, + { name = "pyarrow", specifier = ">=17.0" }, + { name = "scipy", specifier = ">=1.14" }, { name = "xarm-python-sdk", specifier = ">=1.17.3" }, ] [package.metadata.requires-dev] dev = [ - { name = "pytest", specifier = ">=8.1.1" }, - { name = "ruff", specifier = ">=0.9.1" }, -] - -[[package]] -name = "fastapi" -version = "0.135.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-doc" }, - { name = "pydantic" }, - { name = "starlette" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f7/e6/7adb4c5fa231e82c35b8f5741a9f2d055f520c29af5546fd70d3e8e1cd2e/fastapi-0.135.3.tar.gz", hash = "sha256:bd6d7caf1a2bdd8d676843cdcd2287729572a1ef524fc4d65c17ae002a1be654", size = 396524, upload-time = "2026-04-01T16:23:58.188Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/a4/5caa2de7f917a04ada20018eccf60d6cc6145b0199d55ca3711b0fc08312/fastapi-0.135.3-py3-none-any.whl", hash = "sha256:9b0f590c813acd13d0ab43dd8494138eb58e484bfac405db1f3187cfc5810d98", size = 117734, upload-time = "2026-04-01T16:23:59.328Z" }, -] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "idna" -version = "3.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { name = "pytest", specifier = ">=8.1" }, + { name = "ruff", specifier = ">=0.9" }, ] [[package]] @@ -156,27 +85,169 @@ wheels = [ [[package]] name = "numpy" -version = "1.26.4" +version = "2.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129, upload-time = "2024-02-06T00:26:44.495Z" } +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/95/12/8f2020a8e8b8383ac0177dc9570aad031a3beb12e38847f7129bacd96228/numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218", size = 20335901, upload-time = "2024-02-05T23:55:32.801Z" }, - { url = "https://files.pythonhosted.org/packages/75/5b/ca6c8bd14007e5ca171c7c03102d17b4f4e0ceb53957e8c44343a9546dcc/numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b", size = 13685868, upload-time = "2024-02-05T23:55:56.28Z" }, - { url = "https://files.pythonhosted.org/packages/79/f8/97f10e6755e2a7d027ca783f63044d5b1bc1ae7acb12afe6a9b4286eac17/numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b", size = 13925109, upload-time = "2024-02-05T23:56:20.368Z" }, - { url = "https://files.pythonhosted.org/packages/0f/50/de23fde84e45f5c4fda2488c759b69990fd4512387a8632860f3ac9cd225/numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed", size = 17950613, upload-time = "2024-02-05T23:56:56.054Z" }, - { url = "https://files.pythonhosted.org/packages/4c/0c/9c603826b6465e82591e05ca230dfc13376da512b25ccd0894709b054ed0/numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a", size = 13572172, upload-time = "2024-02-05T23:57:21.56Z" }, - { url = "https://files.pythonhosted.org/packages/76/8c/2ba3902e1a0fc1c74962ea9bb33a534bb05984ad7ff9515bf8d07527cadd/numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0", size = 17786643, upload-time = "2024-02-05T23:57:56.585Z" }, - { url = "https://files.pythonhosted.org/packages/28/4a/46d9e65106879492374999e76eb85f87b15328e06bd1550668f79f7b18c6/numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110", size = 5677803, upload-time = "2024-02-05T23:58:08.963Z" }, - { url = "https://files.pythonhosted.org/packages/16/2e/86f24451c2d530c88daf997cb8d6ac622c1d40d19f5a031ed68a4b73a374/numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818", size = 15517754, upload-time = "2024-02-05T23:58:36.364Z" }, + { 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.0" +version = "26.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +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/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, + { 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]] @@ -190,145 +261,59 @@ wheels = [ [[package]] name = "pyarrow" -version = "23.0.1" +version = "25.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/88/22/134986a4cc224d593c1afde5494d18ff629393d74cc2eddb176669f234a4/pyarrow-23.0.1.tar.gz", hash = "sha256:b8c5873e33440b2bc2f4a79d2b47017a89c5a24116c055625e6f2ee50523f019", size = 1167336, upload-time = "2026-02-16T10:14:12.39Z" } +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/9a/4b/4166bb5abbfe6f750fc60ad337c43ecf61340fa52ab386da6e8dbf9e63c4/pyarrow-23.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:f4b0dbfa124c0bb161f8b5ebb40f1a680b70279aa0c9901d44a2b5a20806039f", size = 34214575, upload-time = "2026-02-16T10:09:56.225Z" }, - { url = "https://files.pythonhosted.org/packages/e1/da/3f941e3734ac8088ea588b53e860baeddac8323ea40ce22e3d0baa865cc9/pyarrow-23.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:7707d2b6673f7de054e2e83d59f9e805939038eebe1763fe811ee8fa5c0cd1a7", size = 35832540, upload-time = "2026-02-16T10:10:03.428Z" }, - { url = "https://files.pythonhosted.org/packages/88/7c/3d841c366620e906d54430817531b877ba646310296df42ef697308c2705/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:86ff03fb9f1a320266e0de855dee4b17da6794c595d207f89bba40d16b5c78b9", size = 44470940, upload-time = "2026-02-16T10:10:10.704Z" }, - { url = "https://files.pythonhosted.org/packages/2c/a5/da83046273d990f256cb79796a190bbf7ec999269705ddc609403f8c6b06/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:813d99f31275919c383aab17f0f455a04f5a429c261cc411b1e9a8f5e4aaaa05", size = 47586063, upload-time = "2026-02-16T10:10:17.95Z" }, - { url = "https://files.pythonhosted.org/packages/5b/3c/b7d2ebcff47a514f47f9da1e74b7949138c58cfeb108cdd4ee62f43f0cf3/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bf5842f960cddd2ef757d486041d57c96483efc295a8c4a0e20e704cbbf39c67", size = 48173045, upload-time = "2026-02-16T10:10:25.363Z" }, - { url = "https://files.pythonhosted.org/packages/43/b2/b40961262213beaba6acfc88698eb773dfce32ecdf34d19291db94c2bd73/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564baf97c858ecc03ec01a41062e8f4698abc3e6e2acd79c01c2e97880a19730", size = 50621741, upload-time = "2026-02-16T10:10:33.477Z" }, - { url = "https://files.pythonhosted.org/packages/f6/70/1fdda42d65b28b078e93d75d371b2185a61da89dda4def8ba6ba41ebdeb4/pyarrow-23.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:07deae7783782ac7250989a7b2ecde9b3c343a643f82e8a4df03d93b633006f0", size = 27620678, upload-time = "2026-02-16T10:10:39.31Z" }, - { url = "https://files.pythonhosted.org/packages/47/10/2cbe4c6f0fb83d2de37249567373d64327a5e4d8db72f486db42875b08f6/pyarrow-23.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6b8fda694640b00e8af3c824f99f789e836720aa8c9379fb435d4c4953a756b8", size = 34210066, upload-time = "2026-02-16T10:10:45.487Z" }, - { url = "https://files.pythonhosted.org/packages/cb/4f/679fa7e84dadbaca7a65f7cdba8d6c83febbd93ca12fa4adf40ba3b6362b/pyarrow-23.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:8ff51b1addc469b9444b7c6f3548e19dc931b172ab234e995a60aea9f6e6025f", size = 35825526, upload-time = "2026-02-16T10:10:52.266Z" }, - { url = "https://files.pythonhosted.org/packages/f9/63/d2747d930882c9d661e9398eefc54f15696547b8983aaaf11d4a2e8b5426/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:71c5be5cbf1e1cb6169d2a0980850bccb558ddc9b747b6206435313c47c37677", size = 44473279, upload-time = "2026-02-16T10:11:01.557Z" }, - { url = "https://files.pythonhosted.org/packages/b3/93/10a48b5e238de6d562a411af6467e71e7aedbc9b87f8d3a35f1560ae30fb/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9b6f4f17b43bc39d56fec96e53fe89d94bac3eb134137964371b45352d40d0c2", size = 47585798, upload-time = "2026-02-16T10:11:09.401Z" }, - { url = "https://files.pythonhosted.org/packages/5c/20/476943001c54ef078dbf9542280e22741219a184a0632862bca4feccd666/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fc13fc6c403d1337acab46a2c4346ca6c9dec5780c3c697cf8abfd5e19b6b37", size = 48179446, upload-time = "2026-02-16T10:11:17.781Z" }, - { url = "https://files.pythonhosted.org/packages/4b/b6/5dd0c47b335fcd8edba9bfab78ad961bd0fd55ebe53468cc393f45e0be60/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c16ed4f53247fa3ffb12a14d236de4213a4415d127fe9cebed33d51671113e2", size = 50623972, upload-time = "2026-02-16T10:11:26.185Z" }, - { url = "https://files.pythonhosted.org/packages/d5/09/a532297c9591a727d67760e2e756b83905dd89adb365a7f6e9c72578bcc1/pyarrow-23.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:cecfb12ef629cf6be0b1887f9f86463b0dd3dc3195ae6224e74006be4736035a", size = 27540749, upload-time = "2026-02-16T10:12:23.297Z" }, - { url = "https://files.pythonhosted.org/packages/a5/8e/38749c4b1303e6ae76b3c80618f84861ae0c55dd3c2273842ea6f8258233/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:29f7f7419a0e30264ea261fdc0e5fe63ce5a6095003db2945d7cd78df391a7e1", size = 34471544, upload-time = "2026-02-16T10:11:32.535Z" }, - { url = "https://files.pythonhosted.org/packages/a3/73/f237b2bc8c669212f842bcfd842b04fc8d936bfc9d471630569132dc920d/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:33d648dc25b51fd8055c19e4261e813dfc4d2427f068bcecc8b53d01b81b0500", size = 35949911, upload-time = "2026-02-16T10:11:39.813Z" }, - { url = "https://files.pythonhosted.org/packages/0c/86/b912195eee0903b5611bf596833def7d146ab2d301afeb4b722c57ffc966/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd395abf8f91c673dd3589cadc8cc1ee4e8674fa61b2e923c8dd215d9c7d1f41", size = 44520337, upload-time = "2026-02-16T10:11:47.764Z" }, - { url = "https://files.pythonhosted.org/packages/69/c2/f2a717fb824f62d0be952ea724b4f6f9372a17eed6f704b5c9526f12f2f1/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:00be9576d970c31defb5c32eb72ef585bf600ef6d0a82d5eccaae96639cf9d07", size = 47548944, upload-time = "2026-02-16T10:11:56.607Z" }, - { url = "https://files.pythonhosted.org/packages/84/a7/90007d476b9f0dc308e3bc57b832d004f848fd6c0da601375d20d92d1519/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c2139549494445609f35a5cda4eb94e2c9e4d704ce60a095b342f82460c73a83", size = 48236269, upload-time = "2026-02-16T10:12:04.47Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3f/b16fab3e77709856eb6ac328ce35f57a6d4a18462c7ca5186ef31b45e0e0/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7044b442f184d84e2351e5084600f0d7343d6117aabcbc1ac78eb1ae11eb4125", size = 50604794, upload-time = "2026-02-16T10:12:11.797Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a1/22df0620a9fac31d68397a75465c344e83c3dfe521f7612aea33e27ab6c0/pyarrow-23.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a35581e856a2fafa12f3f54fce4331862b1cfb0bef5758347a858a4aa9d6bae8", size = 27660642, upload-time = "2026-02-16T10:12:17.746Z" }, - { url = "https://files.pythonhosted.org/packages/8d/1b/6da9a89583ce7b23ac611f183ae4843cd3a6cf54f079549b0e8c14031e73/pyarrow-23.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:5df1161da23636a70838099d4aaa65142777185cc0cdba4037a18cee7d8db9ca", size = 34238755, upload-time = "2026-02-16T10:12:32.819Z" }, - { url = "https://files.pythonhosted.org/packages/ae/b5/d58a241fbe324dbaeb8df07be6af8752c846192d78d2272e551098f74e88/pyarrow-23.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:fa8e51cb04b9f8c9c5ace6bab63af9a1f88d35c0d6cbf53e8c17c098552285e1", size = 35847826, upload-time = "2026-02-16T10:12:38.949Z" }, - { url = "https://files.pythonhosted.org/packages/54/a5/8cbc83f04aba433ca7b331b38f39e000efd9f0c7ce47128670e737542996/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b95a3994f015be13c63148fef8832e8a23938128c185ee951c98908a696e0eb", size = 44536859, upload-time = "2026-02-16T10:12:45.467Z" }, - { url = "https://files.pythonhosted.org/packages/36/2e/c0f017c405fcdc252dbccafbe05e36b0d0eb1ea9a958f081e01c6972927f/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4982d71350b1a6e5cfe1af742c53dfb759b11ce14141870d05d9e540d13bc5d1", size = 47614443, upload-time = "2026-02-16T10:12:55.525Z" }, - { url = "https://files.pythonhosted.org/packages/af/6b/2314a78057912f5627afa13ba43809d9d653e6630859618b0fd81a4e0759/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c250248f1fe266db627921c89b47b7c06fee0489ad95b04d50353537d74d6886", size = 48232991, upload-time = "2026-02-16T10:13:04.729Z" }, - { url = "https://files.pythonhosted.org/packages/40/f2/1bcb1d3be3460832ef3370d621142216e15a2c7c62602a4ea19ec240dd64/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f4763b83c11c16e5f4c15601ba6dfa849e20723b46aa2617cb4bffe8768479f", size = 50645077, upload-time = "2026-02-16T10:13:14.147Z" }, - { url = "https://files.pythonhosted.org/packages/eb/3f/b1da7b61cd66566a4d4c8383d376c606d1c34a906c3f1cb35c479f59d1aa/pyarrow-23.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:3a4c85ef66c134161987c17b147d6bffdca4566f9a4c1d81a0a01cdf08414ea5", size = 28234271, upload-time = "2026-02-16T10:14:09.397Z" }, - { url = "https://files.pythonhosted.org/packages/b5/78/07f67434e910a0f7323269be7bfbf58699bd0c1d080b18a1ab49ba943fe8/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:17cd28e906c18af486a499422740298c52d7c6795344ea5002a7720b4eadf16d", size = 34488692, upload-time = "2026-02-16T10:13:21.541Z" }, - { url = "https://files.pythonhosted.org/packages/50/76/34cf7ae93ece1f740a04910d9f7e80ba166b9b4ab9596a953e9e62b90fe1/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:76e823d0e86b4fb5e1cf4a58d293036e678b5a4b03539be933d3b31f9406859f", size = 35964383, upload-time = "2026-02-16T10:13:28.63Z" }, - { url = "https://files.pythonhosted.org/packages/46/90/459b827238936d4244214be7c684e1b366a63f8c78c380807ae25ed92199/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a62e1899e3078bf65943078b3ad2a6ddcacf2373bc06379aac61b1e548a75814", size = 44538119, upload-time = "2026-02-16T10:13:35.506Z" }, - { url = "https://files.pythonhosted.org/packages/28/a1/93a71ae5881e99d1f9de1d4554a87be37da11cd6b152239fb5bd924fdc64/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:df088e8f640c9fae3b1f495b3c64755c4e719091caf250f3a74d095ddf3c836d", size = 47571199, upload-time = "2026-02-16T10:13:42.504Z" }, - { url = "https://files.pythonhosted.org/packages/88/a3/d2c462d4ef313521eaf2eff04d204ac60775263f1fb08c374b543f79f610/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:46718a220d64677c93bc243af1d44b55998255427588e400677d7192671845c7", size = 48259435, upload-time = "2026-02-16T10:13:49.226Z" }, - { url = "https://files.pythonhosted.org/packages/cc/f1/11a544b8c3d38a759eb3fbb022039117fd633e9a7b19e4841cc3da091915/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a09f3876e87f48bc2f13583ab551f0379e5dfb83210391e68ace404181a20690", size = 50629149, upload-time = "2026-02-16T10:13:57.238Z" }, - { url = "https://files.pythonhosted.org/packages/50/f2/c0e76a0b451ffdf0cf788932e182758eb7558953f4f27f1aff8e2518b653/pyarrow-23.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:527e8d899f14bd15b740cd5a54ad56b7f98044955373a17179d5956ddb93d9ce", size = 28365807, upload-time = "2026-02-16T10:14:03.892Z" }, -] - -[[package]] -name = "pydantic" -version = "2.12.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, -] - -[[package]] -name = "pydantic-core" -version = "2.41.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, - { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, - { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, - { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { 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 = "pygments" -version = "2.20.0" +version = "2.21.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +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/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, + { 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.0.2" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -337,9 +322,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +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/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { 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]] @@ -348,6 +333,15 @@ 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" }, @@ -390,38 +384,51 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.8" +version = "0.16.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" } +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/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" }, - { url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" }, - { url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" }, - { url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" }, - { url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" }, - { url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" }, - { url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" }, - { url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" }, - { url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" }, - { url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" }, - { url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" }, - { url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" }, - { url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" }, - { url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" }, - { url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" }, - { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" }, + { 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" }, ] [[package]] name = "scipy" version = "1.17.1" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] dependencies = [ - { name = "numpy" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, @@ -475,57 +482,84 @@ wheels = [ ] [[package]] -name = "starlette" -version = "1.0.0" +name = "scipy" +version = "1.18.1" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] dependencies = [ - { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" } }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/74/66de6258867beb2ef08f35f9f2ac017a52cacd5081714d239ff1a442d458/scipy-1.18.1.tar.gz", hash = "sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307", size = 30781235, upload-time = "2026-08-21T23:28:50.599Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, -] - -[[package]] -name = "typing-inspection" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, -] - -[[package]] -name = "uvicorn" -version = "0.42.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, + { url = "https://files.pythonhosted.org/packages/18/f7/240c110c08693826b4513a52f5717d62ec7c7af72f2920821247c03b17b3/scipy-1.18.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:457fd7a2a8edeb044ab6ffbc0aa03ff6cd18491356e5e0c834d76ce621b916d1", size = 31111061, upload-time = "2026-08-21T23:23:44.522Z" }, + { url = "https://files.pythonhosted.org/packages/05/4a/78c6285577c375e7cf27277ea8ee6961224327f1e1a0c44af5f17f23635c/scipy-1.18.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:e708533e8b2ae2497d65346538a7dcc92814410b25b81432eac66de0f2af8265", size = 28733332, upload-time = "2026-08-21T23:23:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a5/f6/a5b82f8abbe14d134691b8b903696f701d25a081353a29dc655c364d9e62/scipy-1.18.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:7bbf207c4453ce1ad2e00b17313852b33310b83090c2311bdaf97f93c0380d12", size = 20475078, upload-time = "2026-08-21T23:23:54.138Z" }, + { url = "https://files.pythonhosted.org/packages/23/22/0858a0bbd6b3e825ceb8cd9baf9eaf3b2f2b1d77727eb6be40500bcdc92f/scipy-1.18.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:78c0665edead396b1abb4897c41a5c1d9bf090c8a637a4c20a61678e0a264e66", size = 23108904, upload-time = "2026-08-21T23:23:57.824Z" }, + { url = "https://files.pythonhosted.org/packages/75/9a/2e71719f31eaefe0e3a1706c4a1ded94e664bfd95ffca2b219a671faee01/scipy-1.18.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c085faa2cfa879c5141df483f836f4d691045a078224a670fa570fa01612d89", size = 34025113, upload-time = "2026-08-21T23:24:02.209Z" }, + { url = "https://files.pythonhosted.org/packages/df/64/ff35eb9e54894cf471ff4716abd3c81eb0a0626869217ce3e6ba4ccf17d7/scipy-1.18.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f55fa87b6c612ecd6b058f167c53231b1d14e412efe361d3d6e38b3631c73218", size = 35344199, upload-time = "2026-08-21T23:24:07.844Z" }, + { url = "https://files.pythonhosted.org/packages/d3/af/c5538be1792f7034c12c7db6ee67cace58253c7b87b122d68253eaf5de89/scipy-1.18.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c35d74ce0e193ff740c2f2be2ac913ddc232fe6c1ff40b26cfecb9c670c63314", size = 35639587, upload-time = "2026-08-21T23:24:13.05Z" }, + { url = "https://files.pythonhosted.org/packages/91/4c/075e4f66471bac101141ac739e9e135549be1bae584571bd03a530c056e1/scipy-1.18.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2924a03db38dc2e848bca2fe9f077dafb891480b91a00a0963a8cf86dfc31c1", size = 37480330, upload-time = "2026-08-21T23:24:19.608Z" }, + { url = "https://files.pythonhosted.org/packages/39/e7/979fd14e75008623df31ba70d6bb144700f68feadcea042021c06a05bf82/scipy-1.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:5e4d44984abc0020154ea81b247adeddcc3ac5527b975ff798bd1ba0adc513c2", size = 36658278, upload-time = "2026-08-21T23:24:25.463Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/e1525354ff9d7d5feb6d1b31af6d14072e5c91e9607b421fa1ec889660b3/scipy-1.18.1-cp312-cp312-win_arm64.whl", hash = "sha256:d65d448389b8436493abcf629cc94ad0cf32aecaf06e1acca1de53cc795f2f12", size = 24400588, upload-time = "2026-08-21T23:24:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/4540ee0f9c42a9ad7109d0d1a8cc70de54c3572b01c6693a2b1c70e90ceb/scipy-1.18.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:3ab3523da44749156e1f68b464dc56af11ae4cbc5c739a49d05f32b982eca9f3", size = 31089958, upload-time = "2026-08-21T23:24:35.8Z" }, + { url = "https://files.pythonhosted.org/packages/2a/f5/769f36d14922b8071a43e95d24d18b6bdafad10d7f5cf647867e1ac052bc/scipy-1.18.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e6fb6a55cc0ba97b59a1f288fb86dc6fce8bdfc0fffcbfd015e3a954bf2a2d93", size = 28715106, upload-time = "2026-08-21T23:24:40.775Z" }, + { url = "https://files.pythonhosted.org/packages/9a/d7/21d890274f75ea37a8209d5519e72da3da90302e3b9fb8397a0918386a62/scipy-1.18.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ea324d9dd34c38bfb9bec8ca4d1b407db97dbb74029f566b8e322b1b6fe56fe6", size = 20456846, upload-time = "2026-08-21T23:24:45.066Z" }, + { url = "https://files.pythonhosted.org/packages/ec/01/798430ecea2e78ec7c02663d5f71c007bb6abeca931080debd40d7fa55ea/scipy-1.18.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:75b00eb8fb802090aa903f4ea1c7f5a584779f967361e68b7e98e531cc2d7174", size = 23087986, upload-time = "2026-08-21T23:24:49.539Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5f/4634e9d35c68496e4e34cb6946eafab044458e6cedab42b40b6588e475b6/scipy-1.18.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d416b16cccfd70fbf62400e84d0bb2f4e6af519a45557f1692c749b37f14b315", size = 33998146, upload-time = "2026-08-21T23:24:54.714Z" }, + { url = "https://files.pythonhosted.org/packages/41/48/6450ed9243315322bbc19ac57b9b70d66a20bf1d38d124c96bc4bf6af9ea/scipy-1.18.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fdaf5ea890a6183d0565f51a61799d67081bd5b1cf03c5f4b3fd3732108625c9", size = 35312578, upload-time = "2026-08-21T23:25:00.44Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/bf5a4be6a3525676499f6dff307991739ff6fdcad1481b1aeb6745339f58/scipy-1.18.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c825cef2f49e46753726a7181a8e199804a912b29519ada542c6ebc654951899", size = 35612621, upload-time = "2026-08-21T23:25:06.144Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4e/3c45c33e00a77996c4b1cb707929f833ba7b1d522ee29f882512c330676d/scipy-1.18.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3b417bf8c2c7c16e8f58ad91db17783ec911ac16e7b50eb6eab6e809b4f5b07", size = 37457323, upload-time = "2026-08-21T23:25:12.483Z" }, + { url = "https://files.pythonhosted.org/packages/93/0e/e0348fbc0dbab65c114cf78957e7dfeb49f8e8b556b4d930cc12ff195e18/scipy-1.18.1-cp313-cp313-win_amd64.whl", hash = "sha256:559ed65f60c1af5a03f3912605a1b5114f522c7c32fb23c3376ae8f03219fe28", size = 36622841, upload-time = "2026-08-21T23:25:18.722Z" }, + { url = "https://files.pythonhosted.org/packages/50/a8/6a77f5f267c555108f0a864b6db714363dab567a8266422a79a385f9232b/scipy-1.18.1-cp313-cp313-win_arm64.whl", hash = "sha256:cd479fc04dd9401e3b4f49e76518768ef99c4f517a98c284eb091fd725719adf", size = 24399315, upload-time = "2026-08-21T23:25:23.458Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/d8eb4e280ddb56a4ab2c6f02ee49b56b23f6e977cf0802fd6d68dbef14f5/scipy-1.18.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:83de5453a7799afc9048b4616bd085cef126e36412f0ea2f6370c36a2a3a51e7", size = 31090936, upload-time = "2026-08-21T23:25:28.686Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/59ea385dc3a62ff498ddf3cfff7c2b41b0f9f9d3c4122b3f1dcb6d6327fe/scipy-1.18.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:9554bcc6d715ee87a633a3cc8e7703c6628b100dd29cb8a2efc4c0533c7ff729", size = 28725221, upload-time = "2026-08-21T23:25:33.244Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/6b0c288c50942d78193696c9f15f9a0874f5178aa0ddf40f83d9924b3e8d/scipy-1.18.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:011413b7426b75012840e35649e00fe0a2c3bae89fed433876e3a99251572efc", size = 20466839, upload-time = "2026-08-21T23:25:37.516Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/54fd3793c729e3b936782f181b59cbb1205bf250ab605a16cb1ba61cdd5e/scipy-1.18.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:88f0e784020649f88ea48c9f5ddfa403bf9205820667c0914740b392035afb82", size = 23089121, upload-time = "2026-08-21T23:25:42.019Z" }, + { url = "https://files.pythonhosted.org/packages/0b/56/030af62bea3cf878e0028515dff78c123b01633606a879b63f42d2db99cc/scipy-1.18.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d3ab0e8c69a17dd3559eab8cbb88f258e285c94d572c2719033f90f83290c89", size = 34053851, upload-time = "2026-08-21T23:25:47.998Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/2a844506d49651e9aa1af6ef95b6bd8031cb1d5a4375edec6155037e04cf/scipy-1.18.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac0333bdf38309aa3dcbe7e3fa7ea29e7a2c37c6ea306a757b700ded8e4596ad", size = 35329183, upload-time = "2026-08-21T23:25:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/c7370c3640e92ac9613cbf26cb3f729f9b12ddf1727b55b94b53b24d6f48/scipy-1.18.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:911de823097db8b63f034299d12662db93344e6ffa0b881cbb57748974b70168", size = 35672551, upload-time = "2026-08-21T23:25:59.387Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/ec8536f351421f8bf60a1120930638f83790f4710b8230446aca3d6159d4/scipy-1.18.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:95298364e251be3e60249facbeeca03631d3bb7584f85879516ec55ac717b81f", size = 37469416, upload-time = "2026-08-21T23:26:05.432Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/d73da0d28f16c45bb9b0a5691b91610b0275c5ef0eb5e43c87cf2dc1bf31/scipy-1.18.1-cp314-cp314-win_amd64.whl", hash = "sha256:78a0d7c918e74a232394117160e7e3db503377572a45bcef8826e4ab8a35feba", size = 37362755, upload-time = "2026-08-21T23:26:11.366Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/e996e4dc74e10e227b1e14db5eaf6608bb6dd33884a64851c38f18dd4249/scipy-1.18.1-cp314-cp314-win_arm64.whl", hash = "sha256:cbf38d043c1aa4ab306e1ada6ab6eddacc3322a20b7af1b30bc93254b366fe09", size = 25036090, upload-time = "2026-08-21T23:26:15.887Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c9/c00213f92309d753b48903e6a451b87eb52ff5b7a16e789d1568bbf221c4/scipy-1.18.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0fcb3c93519f27bb4f0c4b0f7802cdcaca7fcf93267b75edda2e9f4e8a55cbd7", size = 31485550, upload-time = "2026-08-21T23:26:20.776Z" }, + { url = "https://files.pythonhosted.org/packages/74/b2/e3067c487982d4eeab2938928529410370c06fea84a4d3f4925e7d96647d/scipy-1.18.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ddef79fb382df40104a19bb7151b3b23e57c1778fcf857c71ceecd9bd264513f", size = 29174642, upload-time = "2026-08-21T23:26:25.395Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ab/374c9fe2d1ec014e576c781a4b5d8e1ba340e8f6b4638c16f711d2b194f0/scipy-1.18.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0e82073ecc7acc6436fac4b31674109c7e1d3e596789767eda01258a8c9e8123", size = 20916357, upload-time = "2026-08-21T23:26:30.112Z" }, + { url = "https://files.pythonhosted.org/packages/90/38/223915c88a17317cafbf8ca2a42b11c265a9fb1e804aa665544132b5fe8a/scipy-1.18.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8bcf3c1ba5d6456e2effd30fcbd3459b044d683fcdac79a2e6830f0bdf7de487", size = 23482611, upload-time = "2026-08-21T23:26:34.846Z" }, + { url = "https://files.pythonhosted.org/packages/c4/d1/db0948da8ca57a80b36520ef0a768b967d99f3af65f4b6f1bf6362ad4dd4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cfbf154f2ba187f2ed6cce2639efff7d105f1140573642c0161615b6d91d6a87", size = 34143202, upload-time = "2026-08-21T23:26:40.4Z" }, + { url = "https://files.pythonhosted.org/packages/87/53/39d046cc7574ed6acacb6bd5723e220107ece80bff12faaf3efc4ddeede4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1d33a7836f7ddc1993427966a0823468ec41bcbdb1a9f9942d1d7e57f803ba3", size = 35380876, upload-time = "2026-08-21T23:26:46.1Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/32e0e799d875a85ca57d9bde6c78148afcc0e38276df683d95854eadc8c3/scipy-1.18.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4b8bc363b6d65ee2152bec57568e3c52639bb34c46057b09857a307ed5e21d", size = 35770885, upload-time = "2026-08-21T23:26:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/88/2e/f97a666d362fee68b18f41c9c30ed502ca5c98b549749bfcb52a8b74d1eb/scipy-1.18.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11c423f1049c5755ad4409af52a9ada1cff96fe9b50795d4af3619f292901239", size = 37525424, upload-time = "2026-08-21T23:26:56.751Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d5/a9e765a84654ebba8479a1fd1b059ced1af72b168a3b2a3a46540ea38d20/scipy-1.18.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c24acac1e18912761c4700239bbc1fd32f615af690f1584d49b35859be51324d", size = 37416961, upload-time = "2026-08-21T23:27:01.546Z" }, + { url = "https://files.pythonhosted.org/packages/ee/16/e79e0d1c63ef698879d85439d37e9fb434e3b804e506a6991038d086ebd9/scipy-1.18.1-cp314-cp314t-win_arm64.whl", hash = "sha256:9f2897bf7737392ad0d5213ea7b6add72a4edf5679b3153106aeb88b6507b3b9", size = 25331848, upload-time = "2026-08-21T23:27:05.884Z" }, + { url = "https://files.pythonhosted.org/packages/be/4f/1bd37c883b67163e2ca1f60977a399500e6879c15defecac62831c8d078d/scipy-1.18.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:eb0dfcf4e28a99c12c999744a2ff67c9b06200e20401c7c88186e33552a46331", size = 31091484, upload-time = "2026-08-21T23:27:11.051Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c5/ba929d7feb9b2332f96827c12e0e924b61973b59b4dea383b603372c65ce/scipy-1.18.1-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:30f464bee641fa8e282577c7dce027308403213c6ca8270bba73285c91024bc5", size = 28725057, upload-time = "2026-08-21T23:27:15.9Z" }, + { url = "https://files.pythonhosted.org/packages/a4/19/68f1c50f609d955d230e66d25d02bd3e1e167ec540232135354fb9a4b9e3/scipy-1.18.1-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:1bca3b943fc2567ea49cd02c99abde49da4d5178ec46f624bd8255cda8755beb", size = 20466734, upload-time = "2026-08-21T23:27:20.044Z" }, + { url = "https://files.pythonhosted.org/packages/ef/6d/319fa29b73d1802fa80b32a6eaf3f5be456ef81526da2716a9493bcb5501/scipy-1.18.1-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:c9d18a33309122074ea483dd92dd444189166b8b2ec429fe9ed5ac73c7a0aa23", size = 23089664, upload-time = "2026-08-21T23:27:24.345Z" }, + { url = "https://files.pythonhosted.org/packages/b7/db/30992f9b51a63de671daf3888ffd18378b6cb9ec9f2c972264238ffa7fd6/scipy-1.18.1-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82f201b4c878551d48558337aab270d3c6cca5507b8737c8d8a608d234cccde0", size = 34054035, upload-time = "2026-08-21T23:27:29.409Z" }, + { url = "https://files.pythonhosted.org/packages/91/d4/bf3e735dc0b9d5a8ff45079d2540e17d3aff7a2f0048dd8f552ffd031d2b/scipy-1.18.1-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ac49ea97594532dd44b7136094d35f5440fa06e6d9c6384a74c01764df388c5", size = 35333883, upload-time = "2026-08-21T23:27:34.293Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/12d78ce9f871fe945fca588d32644e6e63f553c2a35c564d73f3b22a3313/scipy-1.18.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:ceb30a00ce7c92d459819443d29ca486d882b83fb6738bdcbb2a1cce94ac5daa", size = 35673124, upload-time = "2026-08-21T23:27:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/70/cd/886219313a1012a48e6ae0ec4f302c837151beb92e1ff0d709ef8fdfc488/scipy-1.18.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f29633129f9fa7e88a3f0fca835de2d030bfc9643f7799e1a0c46cee24d38fc7", size = 37470753, upload-time = "2026-08-21T23:27:44.435Z" }, + { url = "https://files.pythonhosted.org/packages/17/6c/a776888ce618bee54fbde26172f0f46ac1da70d27b63861797fe78e1904b/scipy-1.18.1-cp315-cp315-win_amd64.whl", hash = "sha256:92c14f5bdbfb6216315ce33e78080474082de8b3830122ba97809bfbe65f75c0", size = 37361483, upload-time = "2026-08-21T23:27:49.334Z" }, + { url = "https://files.pythonhosted.org/packages/ab/09/97b651691322ebee97999b017ffc18a15a0b815103844c97e8da9d469731/scipy-1.18.1-cp315-cp315-win_arm64.whl", hash = "sha256:e402cf31eb68f453dbb2d36fc6d722b33f24a55d68b2ae1d92fa6305ca71c298", size = 25035883, upload-time = "2026-08-21T23:27:53.596Z" }, + { url = "https://files.pythonhosted.org/packages/ed/0f/9ec20467bbabd0d44e2a77d0fd3d124f884b4d67df92af82c91d2d6a486f/scipy-1.18.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2a0b02f9fc46f8520330c23d45e6560db7e3a0d927232139427637f98943e11d", size = 31474926, upload-time = "2026-08-21T23:27:57.993Z" }, + { url = "https://files.pythonhosted.org/packages/8a/58/dcb79161e56efbedc50079fcd2f5fe427a0ebb53022eb476aa73c015ad8f/scipy-1.18.1-cp315-cp315t-macosx_12_0_arm64.whl", hash = "sha256:1d73131e358976663dd969e1fb4ed1404b815cd977eaaedc3b3a133ba2d81c35", size = 29164940, upload-time = "2026-08-21T23:28:03.062Z" }, + { url = "https://files.pythonhosted.org/packages/71/d3/1eeea80c817fcb8ef7bd4a05a58824977a0e57a375cfc3d7ea7c911c01ad/scipy-1.18.1-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:bff0b729edd992766136b34e39cc76bc2fad905aa58897ee72a9cd000a6d8443", size = 20906742, upload-time = "2026-08-21T23:28:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/54/46/e59350428b6099301a20128108c995e2eb175a43f383af9a346e38824f9b/scipy-1.18.1-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:10ac20c69d880f77f375db44c22e3e6a644f9fefa291d4cd2fb9790a89fc99fd", size = 23472183, upload-time = "2026-08-21T23:28:12.109Z" }, + { url = "https://files.pythonhosted.org/packages/89/31/cc91623fa98f0621766a0f0aaaadb2c66de74a7ea7e3837164f6e4354260/scipy-1.18.1-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33a834464fdabc0f26a45508df31b3cc5d028e04dbf6c5ed398541418e0a12fe", size = 34130796, upload-time = "2026-08-21T23:28:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3e/8572ef536957ddb8aa81bb4090d9e25f257e3b4e05d97deb54319deb8a3a/scipy-1.18.1-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49023963c193dacee096301452f223ee24d86ec5807f8df93c0f7221d119e305", size = 35374253, upload-time = "2026-08-21T23:28:23.732Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c6/59fdeffb4f1435299f93d9dc8140b43ad2916e6cfc944be6c3041fcec86d/scipy-1.18.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:d84a09d0dad90ba6525d8ac1c2334b33e64bf3ccfe9e841f02feb867a22681e4", size = 35758543, upload-time = "2026-08-21T23:28:29.431Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d9/135be205d9de8783193aff9cc3bf483a03a38e4b29432c954e8cb66ac14e/scipy-1.18.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:179ce34a8d0fe273d8883ba59e17e052247d08973dfcb743ca52bb1cce2d60b0", size = 37521946, upload-time = "2026-08-21T23:28:35.245Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a2/5b7d5270621ab7cfa3f7766067bf95dc360b5efb6394694e8143b4156e2b/scipy-1.18.1-cp315-cp315t-win_amd64.whl", hash = "sha256:5632e3ae3d09197c446310cd5187de63e28448ce22f0f67b2b93d97503c0c230", size = 37408295, upload-time = "2026-08-21T23:28:40.724Z" }, + { url = "https://files.pythonhosted.org/packages/63/ad/741c19fcb66755ff953daf9243af8480e4bf3d7fbe57583c178c7d2b6b51/scipy-1.18.1-cp315-cp315t-win_arm64.whl", hash = "sha256:eda632a7981f69730d6281f451db9c1c370993a2c0d7ddb43e2a809a2862b83a", size = 25319710, upload-time = "2026-08-21T23:28:45.713Z" }, ] [[package]] name = "xarm-python-sdk" -version = "1.17.3" +version = "1.18.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/27/dd/073cf64fa9e74cfb97c9ded97750ed4652ada1b4921cd0e7d895ff242f7c/xarm_python_sdk-1.17.3.tar.gz", hash = "sha256:e61b988bc3be684c15f8e686958c00e619e14725130ee94148074b8ef5bd9ec3", size = 215842, upload-time = "2025-12-02T03:09:49.342Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/f1/5dcf5ed5bfd009b226a9656c1401df1782f7f86c7292696bb753366c9267/xarm_python_sdk-1.18.4.tar.gz", hash = "sha256:75acad469c106e1cb7074762015980372564e88a6d62b64ebaea1c7c580332d5", size = 230108, upload-time = "2026-05-21T04:08:48.871Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/0a/85b3df0fa6ddd4f1a9d23cd63948aa145797631c9e20d165ada8e13a058c/xarm_python_sdk-1.17.3-py3-none-any.whl", hash = "sha256:3dee2f9819d54f0ba476ea51ff63f2d1eb248e0658da1b1dcab8c519008955bb", size = 186790, upload-time = "2025-12-02T03:09:47.606Z" }, + { url = "https://files.pythonhosted.org/packages/0f/25/ad2814469965e765dcf86c12276505a8aadd3d00c387aed82519f6c45434/xarm_python_sdk-1.18.4-py3-none-any.whl", hash = "sha256:943ce66308bc49619386d6eaf428a2783bcd202c1fbe3fe79de87d5f89647989", size = 193152, upload-time = "2026-05-21T04:08:47.281Z" }, ]