Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cd4db93f1 | ||
|
|
4427da9c34 | ||
|
|
608e22078e | ||
|
|
412aeac955 | ||
|
|
4ff584e9b5 | ||
|
|
6311f6daae | ||
|
|
a2bf59065d | ||
|
|
fdabf7e241 | ||
|
|
eccd9e3c24 |
@@ -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"
|
||||||
@@ -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"
|
|
||||||
@@ -11,3 +11,4 @@ build/
|
|||||||
.coverage
|
.coverage
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
out/
|
out/
|
||||||
|
.dora/
|
||||||
|
|||||||
@@ -4,56 +4,199 @@ This file provides guidance to AI coding assistants when working with code in th
|
|||||||
|
|
||||||
## What this is
|
## What this is
|
||||||
|
|
||||||
A dora-rs Python node that interfaces with a UFactory Lite6 robot arm. On each tick it reads joint angles
|
A dora-rs Python node that drives a UFactory Lite6 arm. Every operation is a **service**
|
||||||
and TCP pose from the robot and publishes them as dora outputs. It also accepts JSON commands
|
— one request, one response — and the arm's joint angles, tool pose and connection state
|
||||||
(home, move_to, move_to_pose, vacuum_on/off) via a `command` input. A FastAPI REST server with a web UI
|
are published as ordinary topics.
|
||||||
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.
|
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
|
## Build
|
||||||
|
|
||||||
```bash
|
```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/<node-id>/`, 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
|
## Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dora up
|
dora run dataflow.yml --uv
|
||||||
dora start dataflow.yml
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Robot behavior is configured through environment variables (see README.md for the full list:
|
Everything 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.).
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest # 86 tests, no robot and no dataflow needed
|
||||||
|
uv run ruff check .
|
||||||
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- **`dora_ulite6/main.py`** -- Single-file implementation containing:
|
Two things carry the design. **Who may touch the SDK**, and **why the arm gets threads of
|
||||||
- `ULite6Helper`: Wraps the xArm Python SDK for motion control (go_home, move_to_pose,
|
its own**.
|
||||||
get_current_position, get_joint_angles_deg, get_pose_matrix, vacuum gripper, status, reset).
|
|
||||||
- `MoveToRequest` / `MoveToPoseRequest`: Pydantic models for API validation.
|
The xArm SDK blocks: a move waits out the motion, and a read from a controller whose
|
||||||
- `create_api()`: FastAPI app factory with REST endpoints and embedded HTML/JS web UI for manual control.
|
cable was pulled blocks until its own socket timeout. One thread would therefore have to
|
||||||
- `main()`: Dora event loop. Starts FastAPI in a daemon thread, then processes `tick` (publishes
|
choose between carrying out a motion and reporting where the arm is, and since a motion
|
||||||
joint_pose and tcp_pose) and `command` (executes robot actions and publishes status) inputs.
|
service does not answer until it finishes, telemetry is the only thing a caller can watch
|
||||||
- **`dora_ulite6/__init__.py`** -- Package marker.
|
while it runs. So:
|
||||||
- **`pyproject.toml`** -- Package metadata, dependencies, and entry point.
|
|
||||||
- **`dataflow.yml`** -- Example dora dataflow using the git: source format.
|
- **`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 `<manifest dir>/<entrypoint>` names the same file as
|
||||||
|
`<working dir>/<path>`. 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
|
## Key APIs
|
||||||
|
|
||||||
- **dora Python API** (`dora`): `Node()`, `node.send_output()`, event iteration via `for event in node`.
|
- **dora Python API** (`dora`): `Node(name)`, `node.send_output(id, data, metadata)`,
|
||||||
- **xArm SDK** (`xarm.wrapper.XArmAPI`): `connect()`, `motion_enable()`, `set_mode()`, `set_state()`,
|
event iteration via `for event in node`. The `request_id` rides in `event["metadata"]`
|
||||||
`set_position()`, `move_gohome()`, `get_position()`, `get_servo_angle()`, `set_vacuum_gripper()`.
|
and in the `metadata` dict passed to `send_output`.
|
||||||
- Output data is sent as PyArrow arrays with metadata dicts.
|
- **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
|
## Tools
|
||||||
|
|
||||||
- **Issue tracker:** Gitea. Use the Gitea MCP tools (`mcp__gitea__*`) to read and manage issues,
|
- **Issue tracker:** Gitea. Use the Gitea MCP tools (`mcp__gitea__*`) to read and manage
|
||||||
pull requests, and other repository operations.
|
issues, pull requests, and other repository operations.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- Do not mention AI tools in commit messages or documentation.
|
- 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.
|
||||||
|
|||||||
@@ -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
|
Correlation lives in the message metadata because that is dora's convention, not a ROS2
|
||||||
uv pip install -e .
|
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
|
```yaml
|
||||||
nodes:
|
nodes:
|
||||||
- id: ulite6
|
- id: ulite6
|
||||||
git: ssh://git@gitea.liria.cl:5001/liria/dora-ulite6.git
|
git: ssh://git@gitea.liria.cl:5001/liria/dora-ulite6.git
|
||||||
git_checkout: v0.1.0
|
tag: v0.3.0
|
||||||
build: uv pip install -e .
|
build: pip install .
|
||||||
path: dora_ulite6/main.py
|
path: dora-ulite6
|
||||||
inputs:
|
inputs:
|
||||||
tick: dora/timer/millis/10
|
tick: dora/timer/millis/100
|
||||||
outputs: [status, joint_pose, tcp_pose]
|
request: planner/request
|
||||||
inputs: [command]
|
outputs:
|
||||||
|
- response
|
||||||
|
- motion_state
|
||||||
|
- joint_pose
|
||||||
|
- tcp_pose
|
||||||
|
- robot_state
|
||||||
env:
|
env:
|
||||||
ROBOT_IP: "192.168.1.192"
|
ROBOT_IP: "192.168.1.192"
|
||||||
DEFAULT_SPEED: "30"
|
TOOL_TYPE: "vacuum"
|
||||||
DEFAULT_UNITS: "mm"
|
|
||||||
API_HOST: "0.0.0.0"
|
|
||||||
API_PORT: "8080"
|
|
||||||
VACUUM_ENABLED: "false"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
## Configuration
|
||||||
|
|
||||||
| Env Variable | Default | Description |
|
| Env Variable | Default | Description |
|
||||||
| ------------ | ------- | ----------- |
|
| ------------ | ------- | ----------- |
|
||||||
| `ROBOT_IP` | `192.168.1.192` | Robot IP address |
|
| `ROBOT_IP` | `192.168.1.192` | Controller address |
|
||||||
| `DEFAULT_SPEED` | `30` | Movement speed (mm/s) |
|
| `TOOL_TYPE` | `none` | What is mounted: `none`, `vacuum`, `gripper` |
|
||||||
| `DEFAULT_UNITS` | `mm` | Position units (mm or m) |
|
| `TOOL_OFFSET_MM` | `0.0` | Extra reach past the flange, on top of the tool's own |
|
||||||
| `API_HOST` | `0.0.0.0` | API server host |
|
| `DEFAULT_SPEED` | `30` | Cartesian speed (mm/s) for a `move_to` with no speed |
|
||||||
| `API_PORT` | `8080` | API server port |
|
| `DEFAULT_JOINT_SPEED` | `20` | Joint speed (deg/s) for a `move_joints` with no speed |
|
||||||
| `VACUUM_ENABLED` | `false` | Enable vacuum gripper controls |
|
| `TELEMETRY_INTERVAL_MS` | `100` | How often the arm is read, and how often telemetry is produced |
|
||||||
| `TOOL_TYPE` | `none` | Mounted tool: `none`, `vacuum`, `gripper` |
|
| `RECONNECT_INTERVAL_S` | `5.0` | How long to wait before retrying an unreachable arm |
|
||||||
| `TOOL_OFFSET_MM` | `0.0` | Additional Z-offset in mm added on top of the tool preset |
|
| `INIT_MODE` | `none` | Startup positioning: `none`, `home`, `pose` |
|
||||||
| `INIT_MODE` | `none` | Startup positioning: `home`, `pose`, or `none` |
|
| `INIT_X/Y/Z` | `300/0/250` | Startup pose position (mm, tool tip) |
|
||||||
| `INIT_X/Y/Z` | `300/0/250` | Initial pose coordinates (mm) |
|
| `INIT_ROLL/PITCH/YAW` | `180/0/0` | Startup pose orientation (deg) |
|
||||||
| `INIT_ROLL/PITCH/YAW` | `180/0/0` | Initial pose orientation (deg) |
|
| `INIT_SPEED` | `50` | Startup move speed (mm/s) |
|
||||||
| `INIT_SPEED` | `50` | Startup movement 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
|
## Development
|
||||||
- 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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Get pose
|
uv sync --group dev
|
||||||
curl http://localhost:8080/api/pose
|
uv run pytest # 86 tests, no robot needed
|
||||||
|
uv run ruff check .
|
||||||
# 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}'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Dora Outputs
|
## Bridging to ROS2
|
||||||
|
|
||||||
- `status` - JSON: `{ok, action, message, timestamp_ns}`
|
Nothing here depends on ROS2, and no ROS2 message definition is shipped. It is worth
|
||||||
- `joint_pose` - 6 joint angles in degrees
|
knowing anyway that dora's `ros2:` service bridge speaks exactly this convention — it
|
||||||
- `tcp_pose` - `[x, y, z, roll, pitch, yaw]` in mm/deg — reflects tool tip when `TOOL_TYPE` is set
|
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}`
|
- **The `command` input and the `status` output are gone**, replaced by `request` and
|
||||||
- `id`: unique command id (required)
|
`response`. A command's `{id, action, payload}` JSON becomes a service request, and the
|
||||||
- `action`: `home`, `move_to`, or `move_to_pose`
|
id moves from the payload into the message metadata, where dora's own pattern puts it.
|
||||||
- `payload`: fields for move (`x`, `y`, `z`, `roll`, `pitch`, `yaw`, `speed`)
|
- **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.
|
||||||
|
|||||||
+28
-10
@@ -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:
|
nodes:
|
||||||
- id: ulite6
|
- id: ulite6
|
||||||
build: uv pip install -e .
|
path: dora-ulite6
|
||||||
path: dora_ulite6/main.py
|
build: pip install .
|
||||||
inputs:
|
inputs:
|
||||||
tick: dora/timer/millis/10
|
tick: dora/timer/millis/100
|
||||||
|
request: caller/request
|
||||||
outputs:
|
outputs:
|
||||||
- status
|
- response
|
||||||
|
- motion_state
|
||||||
- joint_pose
|
- joint_pose
|
||||||
- tcp_pose
|
- tcp_pose
|
||||||
|
- robot_state
|
||||||
env:
|
env:
|
||||||
ROBOT_IP: "192.168.1.192"
|
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_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
|
||||||
|
|||||||
+128
@@ -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"
|
||||||
@@ -1 +1 @@
|
|||||||
"""Dora ULite6 node package."""
|
"""Dora node for a UFactory Lite6 arm: telemetry out, commands in."""
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
)
|
||||||
@@ -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())
|
||||||
|
|
||||||
@@ -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']})"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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."""
|
||||||
@@ -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)
|
||||||
+152
-1023
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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}
|
||||||
@@ -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()
|
||||||
@@ -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: <manifest dir>/<entrypoint>
|
||||||
|
# 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.
|
||||||
+31
-17
@@ -1,28 +1,42 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "dora-ulite6"
|
name = "dora-ulite6"
|
||||||
version = "0.1.2"
|
version = "0.3.0"
|
||||||
license = "MIT"
|
description = "Dora node for a UFactory Lite6 arm: motion and control services, and joint and tool-pose telemetry"
|
||||||
authors = [
|
readme = "README.md"
|
||||||
{ name = "Dora" }
|
license = { text = "MIT" }
|
||||||
]
|
# dora-rs 1.0 requires 3.11; the driver uses zip(strict=) and PEP 604 unions.
|
||||||
description = "Dora node for controlling the UFactory Lite6 robot"
|
requires-python = ">=3.11"
|
||||||
|
|
||||||
requires-python = ">=3.12"
|
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dora-rs == 0.5.0",
|
"dora-rs >= 1.0.1",
|
||||||
"numpy < 2.0.0",
|
"numpy >= 2.0",
|
||||||
"scipy >= 1.14.0",
|
"pyarrow >= 17.0",
|
||||||
|
"scipy >= 1.14",
|
||||||
"xarm-python-sdk >= 1.17.3",
|
"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]
|
[project.scripts]
|
||||||
dora-ulite6 = "dora_ulite6.main:main"
|
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"]
|
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"]
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -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")
|
||||||
@@ -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())
|
||||||
@@ -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"
|
||||||
@@ -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]
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -1,48 +1,9 @@
|
|||||||
version = 1
|
version = 1
|
||||||
revision = 3
|
revision = 3
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.11"
|
||||||
|
resolution-markers = [
|
||||||
[[package]]
|
"python_full_version >= '3.12'",
|
||||||
name = "annotated-doc"
|
"python_full_version < '3.12'",
|
||||||
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" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -56,36 +17,39 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dora-rs"
|
name = "dora-rs"
|
||||||
version = "0.5.0"
|
version = "1.0.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
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 = "pyarrow" },
|
||||||
{ name = "pyyaml" },
|
{ 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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]]
|
[[package]]
|
||||||
name = "dora-ulite6"
|
name = "dora-ulite6"
|
||||||
version = "0.1.1"
|
version = "0.3.0"
|
||||||
source = { virtual = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "dora-rs" },
|
{ name = "dora-rs" },
|
||||||
{ name = "fastapi" },
|
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
|
||||||
{ name = "numpy" },
|
{ name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
||||||
{ name = "scipy" },
|
{ name = "pyarrow" },
|
||||||
{ name = "uvicorn" },
|
{ 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" },
|
{ name = "xarm-python-sdk" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -97,52 +61,17 @@ dev = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "dora-rs", specifier = "==0.5.0" },
|
{ name = "dora-rs", specifier = ">=1.0.1" },
|
||||||
{ name = "fastapi", specifier = ">=0.109.0" },
|
{ name = "numpy", specifier = ">=2.0" },
|
||||||
{ name = "numpy", specifier = "<2.0.0" },
|
{ name = "pyarrow", specifier = ">=17.0" },
|
||||||
{ name = "scipy", specifier = ">=1.14.0" },
|
{ name = "scipy", specifier = ">=1.14" },
|
||||||
{ name = "uvicorn", specifier = ">=0.27.0" },
|
|
||||||
{ name = "xarm-python-sdk", specifier = ">=1.17.3" },
|
{ name = "xarm-python-sdk", specifier = ">=1.17.3" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "pytest", specifier = ">=8.1.1" },
|
{ name = "pytest", specifier = ">=8.1" },
|
||||||
{ name = "ruff", specifier = ">=0.9.1" },
|
{ name = "ruff", specifier = ">=0.9" },
|
||||||
]
|
|
||||||
|
|
||||||
[[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" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -156,27 +85,169 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "numpy"
|
name = "numpy"
|
||||||
version = "1.26.4"
|
version = "2.4.6"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "26.0"
|
version = "26.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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 = [
|
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]]
|
[[package]]
|
||||||
@@ -190,145 +261,59 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyarrow"
|
name = "pyarrow"
|
||||||
version = "23.0.1"
|
version = "25.0.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
|
{ 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 = "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" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pygments"
|
name = "pygments"
|
||||||
version = "2.20.0"
|
version = "2.21.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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 = [
|
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]]
|
[[package]]
|
||||||
name = "pytest"
|
name = "pytest"
|
||||||
version = "9.0.2"
|
version = "9.1.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
@@ -337,9 +322,9 @@ dependencies = [
|
|||||||
{ name = "pluggy" },
|
{ name = "pluggy" },
|
||||||
{ name = "pygments" },
|
{ 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 = [
|
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]]
|
[[package]]
|
||||||
@@ -348,6 +333,15 @@ version = "6.0.3"
|
|||||||
source = { registry = "https://pypi.org/simple" }
|
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" }
|
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 = [
|
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/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/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
{ url = "https://files.pythonhosted.org/packages/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]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.15.8"
|
version = "0.16.7"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]]
|
[[package]]
|
||||||
name = "scipy"
|
name = "scipy"
|
||||||
version = "1.17.1"
|
version = "1.17.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
resolution-markers = [
|
||||||
|
"python_full_version < '3.12'",
|
||||||
|
]
|
||||||
dependencies = [
|
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" }
|
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 = [
|
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/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/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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "starlette"
|
name = "scipy"
|
||||||
version = "1.0.0"
|
version = "1.18.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
resolution-markers = [
|
||||||
|
"python_full_version >= '3.12'",
|
||||||
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anyio" },
|
{ name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" } },
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
|
||||||
]
|
]
|
||||||
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 = [
|
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" },
|
{ 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" },
|
||||||
[[package]]
|
{ 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" },
|
||||||
name = "typing-extensions"
|
{ 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" },
|
||||||
version = "4.15.0"
|
{ 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" },
|
||||||
source = { registry = "https://pypi.org/simple" }
|
{ 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" },
|
||||||
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" }
|
{ 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" },
|
||||||
wheels = [
|
{ 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/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" },
|
{ 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" },
|
||||||
[[package]]
|
{ 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" },
|
||||||
name = "typing-inspection"
|
{ 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" },
|
||||||
version = "0.4.2"
|
{ 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" },
|
||||||
source = { registry = "https://pypi.org/simple" }
|
{ 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" },
|
||||||
dependencies = [
|
{ 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" },
|
||||||
{ name = "typing-extensions" },
|
{ 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" },
|
||||||
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" }
|
{ 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" },
|
||||||
wheels = [
|
{ 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/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" },
|
{ 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" },
|
||||||
[[package]]
|
{ 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" },
|
||||||
name = "uvicorn"
|
{ 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" },
|
||||||
version = "0.42.0"
|
{ 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" },
|
||||||
source = { registry = "https://pypi.org/simple" }
|
{ 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" },
|
||||||
dependencies = [
|
{ 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" },
|
||||||
{ name = "click" },
|
{ 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" },
|
||||||
{ name = "h11" },
|
{ 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" },
|
||||||
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" }
|
{ 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" },
|
||||||
wheels = [
|
{ 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/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/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]]
|
[[package]]
|
||||||
name = "xarm-python-sdk"
|
name = "xarm-python-sdk"
|
||||||
version = "1.17.3"
|
version = "1.18.4"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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 = [
|
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" },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user