Author SHA1 Message Date
cristhian e0f5e332ca docs: rewrite the README as a technical reference
validate / validate (pull_request) Successful in 58m41s
validate / test (pull_request) Successful in 58m43s
validate / markdownlint (pull_request) Successful in 58m35s
2026-09-17 17:03:25 -03:00
cristhian b237f4cc9a update requires-python to 3.12 2026-09-17 17:03:25 -03:00
cristhian e3fc538e5d docs: simplify the README and record the pad layout
validate / validate (pull_request) Successful in 58m44s
validate / test (pull_request) Successful in 58m45s
validate / markdownlint (pull_request) Successful in 58m40s
The README carried a lot of rationale that AGENTS.md already holds; what is
left is the tables a consumer wires against. It gains the swept axis and
button indices, and says plainly that the axes check themselves at runtime
and the buttons cannot.
2026-09-16 18:06:12 -03:00
cristhian eede2e5774 update dataflow-gamepad to drive the arm instead of the bench
It now runs joy, teleop and dora-ulite6 with the probe declared and the
console shut, so a pad can be checked against the arm that will actually
move. ROBOT_IP in both arm dataflows points at the bench arm.

The run recipe drops dora up + dora start: dora 1.0.1 refuses to start a
dataflow holding git: nodes from a coordinator, reporting that no build was
run straight after one that succeeded. That costs the file dora topic, which
needs a coordinator to read the wire.
2026-09-16 18:06:12 -03:00
cristhian 7a0847893c fix gamepad layout and refuse a pad whose axes disagree
An Xbox One S pad over Bluetooth numbers its controls differently from the
indices this node shipped with: the right stick is on axes 2 and 3, the
triggers on 4 and 5, and the buttons carry gaps at 2 and 5. Against the old
layout an untouched pad reported a resting trigger as full right-stick
deflection, which asked for 30 deg/s of pitch while lying on the desk, held
the lease for ever so GAMEPAD_IDLE_RELEASE_S never fired, and moved a real
arm into a controller error. X toggled freedrive and LB was the E-STOP, so
the resulting latch could not be released from the pad at all.

Layout now carries the swept indices. Each pad is also measured the first
time it is seen at rest: a stick must rest near centre and a trigger near
the bottom of its travel, and a pad that does not fit reads as absent and is
named in the log rather than driving an arm from a numbering nobody checked.
The measured rest becomes each axis's zero, so a worn stick's tilt is
subtracted rather than deadzoned away and a trigger that reads 0 until it
first moves stops reading as half pressed.

Rest is never re-measured while the pad is in use, because doing that under
a held stick would record the deflection as centre and the arm would move
when the operator let go. The probe drives from the same calibration, so
what it prints is still what the arm would get, and it shows the mismatch.

Tests name their controls through axes_for and buttons_for instead of
numbering them; the positional lists are why a wrong index passed a green
suite.
2026-09-16 18:06:06 -03:00
cristhian 7c1c33f5b1 Merge pull request 'Read the gamepad from dora-joy, and speak a robot-agnostic contract' (#2) from feat/robot-agnostic-teleop into main
validate / validate (push) Successful in 58m41s
validate / test (push) Successful in 58m44s
validate / markdownlint (push) Successful in 58m40s
Reviewed-on: #2
2026-09-16 17:52:06 +00:00
16 changed files with 697 additions and 713 deletions
+27 -4
View File
@@ -46,16 +46,19 @@ uv sync --group dev # plus pytest and ruff
```bash
dora run dataflow.yml --uv # this node plus a bench stand-in, no pad
dora run dataflow-gamepad.yml --uv # the same, with dora-joy pulled in by git:
dora run dataflow-robot.yml # the pad and a real arm, both pulled in by git:
dora run dataflow-gamepad.yml # the same, with the probe declared and no console
```
`dataflow.yml` is self-contained on purpose — it resolves from this checkout alone, which
is what lets CI validate it. Keep it that way; the pad belongs in `dataflow-gamepad.yml`.
is what lets CI validate it. Keep it that way; the pad belongs in the other two. Neither
of those takes `--uv`: dora 1.0.1 installs a `git:` node into a venv inside its own clone
and then looks for its entrypoint in the dataflow's venv. README.md has the detail.
## Test
```bash
uv run pytest # 112 tests, no gamepad, arm, browser or daemon needed
uv run pytest # 149 tests, no gamepad, arm, browser or daemon needed
uv run ruff check .
uv run ruff format --check .
```
@@ -74,6 +77,14 @@ a source goes away**.
- **`dora_teleop/joy.py`** — The pad as it arrives from `dora-joy`. `Sample.present` is
the load-bearing field: an unplugged pad reads as *absent*, not as centred, so "the
operator let go" and "the pad fell off the desk" stay distinguishable.
**`Calibration` is the other half of that seam.** It measures a pad the first time it is
seen at rest and hands the rest of the node a reading as the layout's ideal pad would
have sent it, so a worn stick's tilt is subtracted rather than deadzoned and a trigger
that rests at 0 stops being a special case. A pad whose resting axes do not fit `Layout`
reads as *absent* and is never corrected: one reading cannot tell a trigger at -1.0 from
a stick held hard over, and guessing wrong drives a real arm. It is not re-measured
while the pad is in use — doing that under a held stick would record the deflection as
centre, and the arm would move when the operator let go.
**`JoyInput.read` is the other safety-critical function in this repo.** Moving the pad
into another process added a way for it to go away that did not exist before — that
process dying — and from here that looks like silence rather than like an unplugged pad.
@@ -90,7 +101,12 @@ a source goes away**.
units: mm/s and deg/s, base frame. `is_active` lives here rather than on `Sample`
because it needs the layout: **a trigger rests at -1.0**, so the pad-is-in-use test has
to normalise triggers exactly as `velocity` does, or every connected pad reads as in use
for ever and `GAMEPAD_IDLE_RELEASE_S` never fires.
for ever and `GAMEPAD_IDLE_RELEASE_S` never fires. `rest_zeros` and `canonical` are the
same concern one layer out: they check a resting pad against `Layout` and map it onto
the ideal one. They are the only defence against a wrong index, which is not a
theoretical failure — an Xbox One S pad reporting its triggers on 4 and 5 against a
layout expecting them on 2 and 5 read as a full-speed pitch command from an untouched
pad, held the lease for ever, and moved a real arm into a controller error.
- **`dora_teleop/arbiter.py`** — The lease. One place that answers "why did it not move",
rather than one answer per input path. Ported from the monolithic repo's `RobotService`
lease because the console is already built on it — it gates its controls on
@@ -170,6 +186,13 @@ belongs here, and a port nothing wires reads as supported while having never run
pair; nothing can enforce the second from here.
- **An unplugged pad, a dead `dora-joy` or a departed browser stops the arm immediately**,
without waiting out any timer beyond the one that detects it.
- **A pad is driven from only after it has been seen at rest**, and a pad whose rest does
not fit `Layout` is not driven from at all. This covers the axes and **cannot cover the
buttons**: an unpressed button reads the same on every pad, so a wrong button index is
silent until somebody presses it and gets another verb. Sweeping a new pad with the
probe is the only check, and it is worth doing before the arm is powered. The cost is that a pad held when it appears
is ignored until it is let go; the alternative is guessing a numbering from one reading,
against a real arm.
- **Both control sources go through `mapping`.** The web path used to bypass it and pass
a velocity straight through, which is why the console had to speak mm/s. One conversion
point or none.
+83 -396
View File
@@ -1,433 +1,120 @@
# dora-teleop
A dora-rs node that hands a robot arm to a human. Pick up a gamepad and move the arm, or
open a browser and move it from there — one of the two at a time, decided here.
dora-rs teleoperation node: normalises two control sources — a gamepad read from
`dora-joy`, and a web console served by this node itself — into one owner's lease, and
emits velocity set-points and robot-agnostic commands. It carries no hardware driver: pad
I/O lives in `dora-joy`, arm I/O in a robot node.
It drives [`dora-ulite6`](https://gitea.liria.cl/liria/dora-ulite6) through that node's
ordinary `jog` and `request` ports, reads the pad from
[`dora-joy`](https://gitea.liria.cl/liria/dora-joy), and **serves the browser console
itself** on one port. It contains no robot driver and no gamepad driver: it is the piece
in the middle that decides what the arm should be doing, and that decision is the only
thing it owns. It touches no hardware at all.
**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.12+, `uv`.
## The gamepad
## System
The pad itself is [`dora-joy`](https://gitea.liria.cl/liria/dora-joy), which publishes raw
axes and buttons and knows nothing about arms. Everything below — the deadzone, the
response curve, which button is which, and what any of it means in mm/s — happens here.
```mermaid
flowchart LR
pad([gamepad]) --> joy
browser([browser]) -- "ws: held directions, verbs" --> web
web -- "ui_state snapshot" --> browser
joy["dora-joy — sibling repo"]
subgraph teleop["dora-teleop"]
web["web console — built in, HTTP + ws on one port"]
core["command arbitration"]
end
joy -- "joy: axes, buttons, present" --> core
web -- "held directions, ±1" --> core
core -- "twist SI · command verbs (standard)" --> arm
core -- "jog · request (deprecated)" --> arm
arm -. "state · poses · response" .-> core
arm["robot node"]
```
Analog and blended — several axes at once, and how fast depends on how far you push. That
is the whole reason a pad beats the console, whose ring sectors ask for one direction at
the speed the dial is set to. The console can still hold two at once — a sector and a Z
pill — but it cannot ask for a third of full speed on one axis and full on another.
| Control | Does | Units |
|-------------|-------------------------------------------|-------|
| Left stick | Move along X (up/down) and Y (left/right) | mm/s |
| Triggers | Lift on the right, lower on the left | mm/s |
| Right stick | Roll (left/right) and pitch (up/down) | deg/s |
| Bumpers | Yaw, left and right | deg/s |
| A | Close the tool | — |
| B | Open the tool | — |
| X | Release the E-STOP and clear the arm's error | — |
| Y | Toggle freedrive | — |
| Back | **E-STOP** — latches until X | — |
Up on the stick sends the arm away from you, along **+X** in the robot's base frame —
the same convention the web console's dial uses, so the two agree.
Indices are SDL's, which is what most Xbox- and PlayStation-style pads report. A pad that
disagrees is a change to `Layout` in `dora_teleop/mapping.py` rather than a new
environment variable: an index nobody can verify from outside is worse than a line of code
somebody can read. The layout lives here rather than in `dora-joy` for the same reason —
`dora-joy` does not know what a button is for, which is what makes it reusable.
`home` is deliberately **not** on the pad. It is a large unattended motion and belongs
behind a deliberate click, not a thumb resting on a face button.
**Back latches.** It stops the arm, tells the controller to stop, and then keeps the arm
still no matter what the sticks do — a stop somebody had to reach for is not undone by the
next thumb movement. X is the only way back, and it does both halves: releases the latch
here and clears the error on the controller. Neither button is gated on the lease, because
an idle pad hands the arm back two seconds after the stop it just made, and a latch you
must first take the arm back to release is a trap.
### Checking your pad
## Quick start
```bash
dora up
dora start dataflow-gamepad.yml --uv --name teleop-bench
uv run dora-teleop-probe # in another terminal
uv sync
dora run dataflow.yml --uv # teleop + bench stand-in: no pad, no arm
```
Prints the raw axes, the buttons it sees, and the velocity this node would send — live,
from the same `joy` stream the node reads. **It cannot move anything.** It attaches as a
dynamic node, publishes nothing and never talks to the arm; it only reads.
```text
axes (6 reported)
0 left stick X +0.600 ----------|-----#----
1 left stick Y -0.800 --#-------|----------
2 left trigger -1.000 #---------|----------
...
buttons (6 reported)
5:yaw right
velocity this would send to the arm
vx +38.89 mm/s forward / back <--
vy +23.53 mm/s left / right <--
vz +20.39 mm/s up / down <--
wz +30.00 deg/s yaw <--
moving: yes
asking for the arm: yes
```
Run this before trusting the table above. The indices are SDL's documented layout, not
observed ones, and a pad that numbers its axes differently is much better found here than
by watching an arm move the wrong way. Two things to look for:
- **Every axis is listed, including ones the layout does not use** — an axis marked
`unused` that moves when you push a stick means the layout needs changing.
- **A bar that does not sit centred with the stick released** is a worn pad announcing
itself, and a reason to raise `DEADZONE`.
It goes through the same `velocity()` the node runs, on the same readings, so what it
prints is what the arm would be sent.
For a pad whose layout is not known at all — which index is even which — use
`dora-joy-probe` from the `dora-joy` repo, which needs no dataflow and no coordinator.
## Speaking to any robot
Nothing above is specific to one arm. Both control sources are reduced to a **normalised**
six-vector — `[-1, 1]` per degree of freedom, meaning a fraction of full speed — and only
then scaled, in one function. A gamepad reaches that vector through the deadzone and the
response curve, which exist to correct a worn physical stick; the browser sends it already
clean, because a browser has no worn centre.
That is what lets the same node and the same console drive a robot in mm/s and one in m/s:
neither source ever knew the difference. It is also why a gamepad and the console asking
for the same deflection produce byte-identical output.
`twist` is SI — m/s and rad/s — because that is unambiguous and is what a UR or MoveIt
takes. Configuration stays in mm/s and deg/s, because `60 mm/s` is a number a person
reading a dataflow can judge.
`command` carries verbs rather than service names: `gripper_close` rather than `grasp`,
so a robot that swaps a vacuum for a two-finger gripper needs no change at either end.
The full contract, including the reserved `joint_jog` port for joint-space robots, is in
the vault at `resources/robotics/teleop-contract.md`.
## The lease
Two sources, one arm. Whoever holds the lease drives; the other is ignored.
- The **gamepad takes it implicitly**, the first time a stick moves, if nobody holds it.
Nobody should have to reach for a browser to use the thing in their hands.
- It **gives it back** after `GAMEPAD_IDLE_RELEASE_S` untouched, so a pad left on the
bench does not lock the browser out forever.
- The **browser takes it explicitly**, with the console's TAKE CONTROL button, and gives
it back with RELEASE. It can preempt a held arm, because an operator who can see the arm
is better placed than this code to decide they should have it.
- **E-STOP is never gated.** From either source, held lease or not. Somebody reaching for
the stop is the one moment when asking who owns the arm is the wrong question.
**The lease binds only what goes through this node.** `dora-ulite6` has no idea it exists.
Another node publishing `request` will preempt an operator mid-motion and nothing here can
stop it — if that matters for your dataflow, do not wire one.
## The deadman
An arm under a velocity command keeps moving until told otherwise, so the question is
never whether to start but whether anything has happened that means it must stop. Four
layers, outermost last:
1. **The repeat** (`JOG_KEEPALIVE_S`, 0.2 s) while a source is driving.
2. **The pad's freshness** (`JOY_TTL_S`, 0.2 s). The pad is another process now, and that
process dying looks like silence rather than like an unplugged pad — so silence means
the same thing. `dora-joy` publishes on every tick whether or not the pad moved, which
is what makes silence unambiguous.
3. **This node's watchdog** (`JOG_TTL_S`, 0.4 s) stops the arm when the owner goes quiet —
and stops it *immediately*, without waiting out the timer, when the gamepad reports
itself gone or the last browser disconnects. An unplugged pad is not a centred pad.
4. **The arm's own expiry** (`VELOCITY_COMMAND_TTL_S` in `dora-ulite6`, 0.5 s), enforced by
the controller whether or not this process is alive.
Keep them in that order, each shorter than the one behind it. A backstop that fires before
the thing it is backing up backs up nothing. `JOY_TTL_S >= JOG_TTL_S` stops the node at
startup rather than letting that happen quietly.
On shutdown the node sends one all-zero velocity before exiting. A motion lives in the
controller, not in this process.
The stop is sent **once**, not every tick. A stream of zeroes would keep `dora-ulite6`
believing a jog is live and hold `robot_state` at `moving` forever.
### What goes on the wire is the change
A velocity is a set-point the arm holds, not a frame in a sequence. This node sends one
when it **changes** — a new direction, or the stop — and otherwise repeats the current one
only every `JOG_KEEPALIVE_S` (0.2 s), which exists solely so the arm's own expiry never
fires mid-motion.
It used to repeat every tick. On a real xArm that means the controller is handed the same
velocity twenty times a second and re-plans the same motion each time, which an operator
feels as the arm moving in steps rather than travelling. Keep the repeat well under the
arm's `VELOCITY_COMMAND_TTL_S` and no faster than it needs to be.
**A change never waits for the repeat.** Releasing a button is acted on the tick it
happens, so sending less does not cost anything at the operator's end.
## Ports
| Direction | Name | Description |
|-----------|----------------|------------------------------------------------------|
| in | `tick` | 50 ms — change detection, deadman, telemetry |
| in | `joy` | One pad reading from `dora-joy` |
| in | `response` | An answer from the arm |
| in | `motion_state` | How a motion ended |
| in | `robot_state` | The arm's own state |
| in | `tcp_pose` | Tool-tip pose, six float32, mm and degrees |
| in | `joint_pose` | Joint angles, six float32, degrees |
| out | `twist` | **Standard.** `{linear, angular, frame}`, SI |
| out | `command` | **Standard.** `{action, value}`, robot-agnostic verbs |
| out | `jog` | *Deprecated.* mm/s + deg/s, for `dora-ulite6` |
| out | `request` | *Deprecated.* A service call, `request_id` in metadata |
| out | `ui_state` | The console's telemetry snapshot, as JSON — see below |
Wire `twist` **or** `jog`, never both: they carry the same intent, and a robot reading
both is driven twice. Same for `command` and `request`.
The browser is not on this table. It reaches the node over the node's own HTTP and
WebSocket port rather than through the dataflow, so there is nothing to wire for it —
`ui_state` is published for anyone else watching and has no consumer in the stacks below.
## Environment Variables
| Variable | Default | Description |
|--------------------------|---------|--------------------------------------------------------|
| `SPEED_MM_S` | `60.0` | Translation speed at full deflection |
| `SPEED_DEG_S` | `30.0` | Rotation speed at full deflection |
| `DEADZONE` | `0.12` | Stick travel near centre to ignore |
| `EXPO` | `0.4` | How much of the curve is cubic; 0 is linear |
| `JOG_TTL_S` | `0.4` | Owner silence before the arm is stopped |
| `JOY_TTL_S` | `0.2` | `joy` silence before the pad reads as gone |
| `GAMEPAD_IDLE_RELEASE_S` | `2.0` | Untouched pad before it hands the arm back |
| `STATE_POLL_S` | `0.5` | How often to ask the arm what telemetry does not carry |
A value that cannot be read, a `DEADZONE` outside the stick, an `EXPO` off the curve or a
`JOY_TTL_S` at or above `JOG_TTL_S` stops the node at startup rather than the first time
somebody picks up the pad.
`JOYSTICK_INDEX` moved to `dora-joy` along with the pad.
## Usage
The real stack is three nodes: this one, the pad and the arm. The console comes with
this node.
```yaml
- id: teleop
git: ssh://git@gitea.liria.cl:5001/liria/dora-teleop.git
tag: v0.3.0
build: pip install .
path: dora-teleop
inputs:
tick: dora/timer/millis/50
joy:
source: joy/joy
queue_size: 1
queue_policy: drop_oldest
response: ulite6/response
motion_state: ulite6/motion_state
robot_state: ulite6/robot_state
tcp_pose: ulite6/tcp_pose
joint_pose: ulite6/joint_pose
outputs: [jog, request, ui_state]
env:
WEB_PORT: "7000"
```
Wire `ulite6`'s `request` and `jog` from this node, and add `dora-joy` on a 20 ms tick for
the pad — `queue_size: 1` matters, because it ticks faster than this node does and an arm
should be driven by the newest reading or by none.
Then open `http://<the machine running teleop>:7000` for the console. Nothing else needs
wiring: the node serves the page and holds the browsers' sockets, and the page finds the
socket on its own origin.
Leave `joy` unwired for a browser-only console; the node starts without it. Set
`WEB_PORT: "0"` for a pad-only one, and no port is opened.
**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+ and `uv`. No
hardware: the pad and the arm are other nodes, and the browser is any browser.
### Driving a real arm
`dataflow-robot.yml` is the one that moves a robot: the pad, this node and
[`dora-ulite6`](https://gitea.liria.cl/liria/dora-ulite6), with no bench and no browser.
Both sibling nodes come in by `git:`, so it runs from a fresh clone with nothing else
checked out.
```bash
source .venv/bin/activate # see the note below before reaching for --uv
dora build dataflow-robot.yml # clones and installs both, needs the network
dora run dataflow-robot.yml
```
**`--uv` does not work for a dataflow with `git:` nodes** (dora 1.0.1). `dora build --uv`
installs each `git:` node into a venv inside its own clone directory, while `dora run
--uv` resolves the entrypoint by running `uv run which <name>` from the *dataflow's*
directory — which only ever sees the dataflow's own venv. The node is installed where the
spawner does not look, and the run fails with `Could not find source path dora-joy within
uv`. Building into one activated environment, as above, puts every node's entrypoint
somewhere the spawner finds it. The venv needs `pip` for that (`uv pip install pip`),
since `uv` does not create one.
`dataflow.yml` is unaffected and `--uv` is fine there: its only Python node is this repo,
which is already in this repo's venv.
Set `ROBOT_IP` and `TOOL_TYPE` in that file to match the arm in front of you; the defaults
are `dora-ulite6`'s own, `192.168.1.192` and a vacuum tool. `INIT_MODE` is `none`, so
nothing moves until somebody pushes a stick.
The deadman timings have to stay ordered, each below the one behind it, and the file pins
all of them so the chain is visible in one place:
| Layer | Setting | Default |
|---|---|---|
| Pad reading goes stale | `JOY_TTL_S` (teleop) | 0.2 s |
| Held velocity repeated | `JOG_KEEPALIVE_S` (teleop) | 0.2 s |
| Owner goes quiet | `JOG_TTL_S` (teleop) | 0.4 s |
| Jog sender goes quiet | `VELOCITY_COMMAND_TTL_S` (ulite6) | 0.5 s |
The last one is enforced by `dora-ulite6` itself from v0.3.1 on, not by the controller:
the velocity is set once and held, so that node stops the arm rather than letting a
controller-side timer do it.
`dora` type-checks a dataflow before it fetches a `git:` source, so it cannot read the
arm's manifest from here. The `output_types:` block on the `ulite6` node repeats what
`dora-ulite6` v0.3.1 annotates, which is also what pinning that tag asserts — re-check it
when moving to a newer one.
The console is already there — `dataflow-robot.yml` sets `WEB_PORT` to 7000, so opening
`http://<that machine>:7000` gets you the same arm the pad drives, under the same lease.
### Running it with no arm and no browser
`dataflow.yml` in this repo is **not** that stack. It is this node plus a bench stand-in,
with **no pad**: pulling `dora-joy` in by `git:` would cost that file the one property it
has, which is that it resolves and runs from this checkout alone, with no network and no
sibling clones.
```bash
dora run dataflow.yml --uv # bench only, browser path
dora run dataflow-gamepad.yml --uv # the same bench with a real pad
```
`dataflow-gamepad.yml` adds `dora-joy` by `git:` and declares the probe. It needs the
network on its first build.
The bench answers every service, publishes plausible telemetry, and prints each velocity
it is sent:
```text
jog 1 [ +38.89, +23.53, +0.00, +0.00, +0.00, +0.00]
jog 2 [ +38.89, +23.53, +0.00, +0.00, +0.00, +0.00]
jog 3 [ +0.00, +0.00, +0.00, +0.00, +0.00, +0.00] <- stop
```
It is **not a simulator**: it never integrates the velocity or moves the pose, and a jog
is printed and forgotten. It closes the loop so this node's behaviour can be watched —
the lease changing hands, the deadman firing when you let go — not so the arm can be
pretended at.
Open `http://localhost:7000` and drive the robot from the console. The bench answers
services and publishes telemetry so the loop closes.
### Watching the wire
`dataflow.yml` sets `debug: enable_debug_inspection: true`, so dora will let you read what
is actually on the wire without the bench printing anything. Run it under a coordinator
rather than `dora run`:
Every dataflow sets `debug: enable_debug_inspection: true`, so dora can inspect the wire
without any node printing. This needs a coordinator, so it works for the arm-free dataflow:
```bash
dora up
dora start dataflow-gamepad.yml --uv --name teleop-bench
```
Then, in another terminal:
```bash
dora start dataflow.yml --uv --name teleop-bench
dora topic list -d teleop-bench # every edge in the graph
dora topic echo -d teleop-bench joy/joy # what the pad is reporting
dora topic echo -d teleop-bench teleop/jog # every velocity, live
dora topic echo -d teleop-bench teleop/ui_state # what the console would draw
dora topic hz -d teleop-bench teleop/jog --duration 10 # the re-send cadence
dora stop --name teleop-bench && dora down
```
`hz` is the one that checks the timings this node promises. The `get_state` poll measures
at 1.88 Hz against a configured `STATE_POLL_S` of 0.5, and with a pad held over the
deadzone `teleop/jog` should sit near the 50 ms tick.
## Running with hardware
Tear it down with `dora stop --name teleop-bench && dora down`.
| Dataflow | Nodes | Needs |
|------------------------|--------------------------------------|--------------------|
| `dataflow.yml` | teleop + bench stand-in | nothing |
| `dataflow-robot.yml` | joy + teleop + ulite6 | a pad and an arm |
| `dataflow-gamepad.yml` | the same, plus the probe, no console | a pad and an arm |
## The console
## Gamepad
`dora_teleop/web/index.html` is the browser half: two analog sticks, a lift lane and a
yaw lane, plus gripper, freedrive, home, the lease buttons and E-STOP over a telemetry
readout. Plain HTML with no build step — the node serves the file as it stands, and a
console you cannot fix without a toolchain is the wrong thing to have between a person and
a robot.
| Control | Does (base frame) | Index | Units |
|----------------------|----------------------------------------------------------------|--------------|-------|
| Left stick X / Y | Translate in the horizontal plane: `+X` stick up, `+Y` stick right | axes 0, 1 | mm/s |
| Left / right trigger | Translate along Z: right trigger lifts (`+Z`), left lowers | axes 4, 5 | mm/s |
| Right stick X / Y | Rotate: `+roll` stick right, `+pitch` stick up | axes 2, 3 | deg/s |
| LB / RB | Yaw: RB is `+yaw` | buttons 6, 7 | deg/s |
| A | Close the tool | button 0 | — |
| B | Open the tool | button 1 | — |
| X | Release the E-STOP and clear the arm's error | button 3 | — |
| Y | Toggle freedrive | button 4 | — |
| Back | **E-STOP** — latches until X | button 11 | — |
**Laid out like the arm's own pendant.** Each ring is quartered into arc sectors you hold,
with the out-of-plane axis as a pair of pills above it — the same shape as xArm Studio's
jog control on the controller at `:18333`, and deliberately the same directions, because
two consoles driving one arm that disagree about which way is `+Y` is a trap rather than a
preference.
The sticks move the arm in its horizontal X/Y plane only; the triggers are the one path
to Z. Up on the stick sends the arm away from the operator, the same `+X` convention the
console draws.
| Ring | Sectors | Pills above |
|-----------|------------------------------------------------|----------------|
| translate | `+X` north, `−X` south, `Y+` **west**, `Y−` east | `−Z` `+Z` |
| rotate | `+pitch` north, `−pitch` south, `−roll` west, `+roll` east | `−yaw` `+yaw` |
## Inputs and outputs
**`Y+` is on the left**, matching xArm Studio and the right-hand rule with `+X` away from
the operator and `+Z` up. It is the one direction that does not read as "screen right is
right", and it is that way on purpose.
| Direction | Name | Description |
|-----------|----------------|--------------------------------------------------------|
| in | `tick` | 50 ms — change detection, deadman, telemetry |
| in | `joy` | One pad reading from `dora-joy` |
| in | `response` | An answer from the arm |
| in | `motion_state` | How a motion ended |
| in | `robot_state` | The arm's own state |
| in | `tcp_pose` | Tool-tip pose, six float32, mm and degrees |
| in | `joint_pose` | Joint angles, six float32, degrees |
| out | `twist` | **Standard.** `{linear, angular, frame}`, SI |
| out | `command` | **Standard.** `{action, value}`, robot-agnostic verbs |
| out | `jog` | *Deprecated.* mm/s + deg/s, for `dora-ulite6` |
| out | `request` | *Deprecated.* A service call, `request_id` in metadata |
| out | `ui_state` | The console's telemetry snapshot, as JSON |
Directions are held, not analog: speed comes from the dial, and every held direction is a
separate pointer, so two fingers work at once — driving `+X` while lifting on `+Z` is one
gesture.
## Environment variables
The node serves it on `WEB_PORT` (7000) and holds the WebSocket on `/ws` of that same
port, so the console is one URL. That work happens on a thread of its own and meets the
control loop at two points only — the messages the next tick drains, and the snapshot
handed back — because a browser that stops reading must never be able to stall the thread
that stops the arm.
**The browser's deadman is the socket itself.** The last one closing releases the lease
and stops the arm, which is why the tab closing mid-jog does not leave it travelling.
Hold a direction to move, release to stop. The page re-sends its jog every 150 ms against
the node's 400 ms deadline, so letting go stops the robot even if the release is lost. It
sends a **fraction of full deflection**, never a speed, so it never learns the robot's
units; the speed dial sets that fraction. Every control is dimmed and inert unless the
console holds the lease, so one that would be refused looks refused.
If `WEB_PORT` is already taken the node **refuses to start** and says so, rather than
carrying on with a console nobody can open.
`tests/test_console.py` checks the page against `ui.py` — every message type it sends is
one the node serves, and every telemetry key it reads is one the node sends. Neither
language catches a rename, and the wrong place to discover one is on a machine that moves.
| Variable | Default | Description |
|--------------------------|-----------|--------------------------------------------------------|
| `SPEED_MM_S` | `60.0` | Translation speed at full deflection |
| `SPEED_DEG_S` | `30.0` | Rotation speed at full deflection |
| `DEADZONE` | `0.12` | Stick travel near centre to ignore |
| `EXPO` | `0.4` | How much of the curve is cubic; 0 is linear |
| `JOG_TTL_S` | `0.4` | Owner silence before the arm is stopped |
| `JOG_KEEPALIVE_S` | `0.2` | How often an unchanged velocity is repeated |
| `JOY_TTL_S` | `0.2` | `joy` silence before the pad reads as gone |
| `GAMEPAD_IDLE_RELEASE_S` | `2.0` | Untouched pad before it hands the arm back |
| `STATE_POLL_S` | `0.5` | How often to ask the arm what telemetry does not carry |
| `WEB_HOST` | `0.0.0.0` | Address the console binds to |
| `WEB_PORT` | `7000` | Console port; `0` opens no port at all |
## Development
```bash
uv sync --group dev
uv run pytest # 112 tests, no gamepad, arm, browser or daemon needed
uv run pytest # 149 tests, no gamepad, arm, browser or daemon needed
uv run ruff check .
uv run ruff format --check .
```
Every file here is testable without hardware, because there is no hardware. The pad
arrives on `joy` as a plain `Sample`.
+57 -26
View File
@@ -1,23 +1,29 @@
# The same bench, with a real gamepad and a probe.
# The pad, a real arm and the probe. No bench and no browser.
#
# `dataflow.yml` is what CI validates, and it deliberately has no pad in it: the
# pad is `dora-joy`, another repo, and pulling it in by `git:` would cost that
# file its one property — that it resolves and runs from this checkout alone.
# This file is the other half. It needs the network on first build, and a pad.
# This file is the other half. It needs the network on first build, a pad, and
# an arm.
#
# The bench answers services and publishes telemetry but never moves anything.
# What this is for is watching the node decide: the lease changing hands, the
# deadman firing when you let go, and the arm being stopped when the pad — or
# the node reading it — goes away.
# It is `dataflow-robot.yml` with the probe attached and the console shut, for
# the one job the probe exists for: checking that the pad in your hands has the
# layout `mapping.py` assumes, against the arm that will actually move. Use
# `dataflow-robot.yml` for ordinary driving.
#
# Run it under a coordinator rather than `dora run`, so `dora topic` can read the
# wire and so the probe has something to attach to:
# source .venv/bin/activate # not --uv; README.md says why
# dora build dataflow-gamepad.yml
# dora run dataflow-gamepad.yml
#
# dora up
# dora start dataflow-gamepad.yml --uv --name teleop-bench
# dora-teleop-probe # in another terminal
# dora topic echo -d teleop-bench teleop/jog
# dora stop --name teleop-bench && dora down
# `dora run`, not `dora up` + `dora start`: dora 1.0.1 refuses to start a
# dataflow holding `git:` nodes from a coordinator — "node joy has git source,
# but no `dora build` was run yet", straight after a build that succeeded. It is
# the same shape of gap as the `--uv` one above, and it costs this file
# `dora topic`, which needs a coordinator to read the wire.
#
# ROBOT_IP and TOOL_TYPE below name the arm in front of you: the bench arm at
# 192.168.1.162, with a vacuum tool. Read
# `The gamepad` in README.md before picking it up — Back is the E-STOP.
strict_types: true
@@ -47,10 +53,11 @@ nodes:
source: joy/joy
queue_size: 1
queue_policy: drop_oldest
response: bench/response
robot_state: bench/robot_state
tcp_pose: bench/tcp_pose
joint_pose: bench/joint_pose
response: ulite6/response
motion_state: ulite6/motion_state
robot_state: ulite6/robot_state
tcp_pose: ulite6/tcp_pose
joint_pose: ulite6/joint_pose
outputs:
- twist
- command
@@ -60,8 +67,13 @@ nodes:
env:
SPEED_MM_S: "60"
SPEED_DEG_S: "30"
# This one is about the pad, so the console stays shut. Set a port to
# bring it up alongside and watch the lease move between the two.
# Keep the deadman layers ordered: JOY_TTL_S < JOG_TTL_S < the arm's own
# VELOCITY_COMMAND_TTL_S, which is 0.5 s below. Each backs up the one
# inside it, and a backstop that fires first backs up nothing.
JOY_TTL_S: "0.2"
JOG_TTL_S: "0.4"
# This one is about the pad, so the console stays shut and no port is
# opened. `dataflow-robot.yml` is the one that serves it.
WEB_PORT: "0"
# Declared, not spawned: `dora-teleop-probe` attaches to this id when you run
@@ -71,15 +83,34 @@ nodes:
inputs:
joy: joy/joy
- id: bench
path: examples/bench.py
- id: ulite6
git: https://gitea.liria.cl/liria/dora-ulite6.git
tag: v0.3.1
build: pip install .
path: dora-ulite6
inputs:
tick: dora/timer/millis/200
jog: teleop/jog
tick: dora/timer/millis/100
request: teleop/request
ui_state: teleop/ui_state
jog: teleop/jog
outputs:
- response
- robot_state
- tcp_pose
- motion_state
- joint_pose
- tcp_pose
- robot_state
# dora type-checks before it fetches a `git:` source, so it cannot read the
# arm's own manifest from here. These three repeat what `dora-ulite6`
# v0.3.1 annotates; `response` and `motion_state` stay untyped there and so
# stay untyped here. Re-check them when moving to a new tag.
output_types:
joint_pose: std/core/v1/Float32
tcp_pose: std/core/v1/Float32
robot_state: std/core/v1/String
env:
ROBOT_IP: "192.168.1.162"
TOOL_TYPE: "vacuum"
# The outermost deadman, enforced by the controller whether or not any of
# this is alive. Every timer above must stay below it.
VELOCITY_COMMAND_TTL_S: "0.5"
# The pad does not home. Nothing moves until somebody pushes a stick.
INIT_MODE: "none"
+3 -3
View File
@@ -16,8 +16,8 @@
# then looks for its entrypoint in this dataflow's venv instead. README.md has the
# detail. `dataflow.yml` has no `git:` nodes and is fine with --uv.
#
# Set ROBOT_IP and TOOL_TYPE below to match the arm in front of you. The
# defaults are `dora-ulite6`'s own: 192.168.1.192 and a vacuum tool.
# ROBOT_IP and TOOL_TYPE below name the arm in front of you: the bench arm at
# 192.168.1.162, with a vacuum tool.
#
# The web console comes with the node: it serves the page and holds the
# browsers' sockets itself, so `http://<this machine>:7000` is a second way to
@@ -102,7 +102,7 @@ nodes:
tcp_pose: std/core/v1/Float32
robot_state: std/core/v1/String
env:
ROBOT_IP: "192.168.1.192"
ROBOT_IP: "192.168.1.162"
TOOL_TYPE: "vacuum"
# The outermost deadman, enforced by the controller whether or not any of
# this is alive. Every timer above must stay below it.
+3 -2
View File
@@ -5,8 +5,9 @@
# manifest from `path:` and CI needs no gamepad, no robot and no sibling clones.
#
# There is **no gamepad here**, because the pad is another node and wiring it in
# by `git:` would cost this file the one property it has. For a real pad on a
# real bench, use `dataflow-gamepad.yml`.
# by `git:` would cost this file the one property it has. For a real pad, use
# `dataflow-robot.yml`, or `dataflow-gamepad.yml` to check the pad with the
# probe — both of which drive a real arm.
#
# This is **not** the real stack. The bench answers services and publishes
# telemetry but never moves anything. README.md carries the four-node wiring a
+6
View File
@@ -53,6 +53,12 @@ inputs:
pad node is indistinguishable from an unplugged pad as far as the arm is
concerned.
Each pad is measured the first time it is seen sitting still, so a stick
that no longer centres has its tilt subtracted rather than deadzoned away.
A pad whose resting axes do not fit the layout this node compiles in — a
trigger where a stick should be, say — reads the same as no pad at all
and is named in the log, rather than being guessed at against a real arm.
Leave it unwired for a browser-only console. The node says so and starts.
response:
required: false
+75 -3
View File
@@ -12,11 +12,19 @@ unplugged pad. So silence past `JOY_TTL_S` reads as *absent*, which is the same
answer, because a reading nobody is refreshing is not evidence of anything.
`dora-joy` publishes on every tick whether or not the pad moved, so silence
means one thing only.
The other thing this seam does is :class:`Calibration`: no two pads rest in
quite the same place, and one that rests somewhere the layout does not expect
is a pad this node must not drive an arm from. Every reading the loop sees has
been through it, so the rest of the node only ever handles a pad that behaves.
"""
import time
from dataclasses import dataclass, field
from dora_teleop import mapping
from dora_teleop.log import log
@dataclass(frozen=True)
class Sample:
@@ -52,17 +60,81 @@ def decode(event: dict) -> Sample:
)
class Calibration:
"""Where the pad in front of us rests, and whether the layout fits it.
Captured from the first reading of a pad that is sitting still, and kept
until that pad goes away. It is not re-taken while the pad is in use, which
would be the one way to get this badly wrong: an operator holding a stick
when it happened would have that deflection recorded as centre, and the arm
would then move whenever they let go.
A pad whose resting axes do not fit the layout is reported **absent** rather
than corrected. The numbering cannot be guessed from one reading — a trigger
resting at -1.0 and a stick held hard over look identical — and guessing
wrong drives a real arm. `dora-teleop-probe` shows what the pad reports, and
`Layout` in :mod:`dora_teleop.mapping` is where a pad that disagrees is
written down.
"""
def __init__(self, layout: mapping.Layout = mapping.LAYOUT):
self.layout = layout
self.mismatch: str | None = None
self._zeros: list[float] | None = None
@property
def known(self) -> bool:
"""Whether a pad has been measured at rest and may be driven from."""
return self._zeros is not None
def forget(self) -> None:
"""Drop what was learned, so the next pad to appear is measured afresh."""
self._zeros = None
self.mismatch = None
def apply(self, sample: Sample) -> Sample:
"""Return the reading as the layout's ideal pad would have sent it.
An absent pad passes straight through and takes the calibration with
it. A pad that does not fit the layout comes back absent, which every
path in this node already treats as a reason to stop.
"""
if not sample.present:
self.forget()
return sample
if self._zeros is None or len(self._zeros) != len(sample.axes):
try:
self._zeros = mapping.rest_zeros(sample.axes, self.layout)
except ValueError as wrong:
# Logged once per pad: this runs on every reading, and a pad
# that does not fit the layout does not fit it fifty times a
# second.
if self.mismatch is None:
log(
f"ignoring the gamepad: {wrong}. Run dora-teleop-probe and check Layout in mapping.py"
)
self.mismatch = str(wrong)
return ABSENT
self.mismatch = None
return Sample(
axes=mapping.canonical(sample.axes, self._zeros, self.layout),
buttons=sample.buttons,
present=True,
)
class JoyInput:
"""The latest reading, and how long ago it arrived."""
def __init__(self, ttl_s: float):
def __init__(self, ttl_s: float, layout: mapping.Layout = mapping.LAYOUT):
self.ttl_s = ttl_s
self.calibration = Calibration(layout)
self._sample = ABSENT
self._seen_at = 0.0
def accept(self, sample: Sample, moment: float) -> None:
"""Take one reading off the wire."""
self._sample = sample
"""Take one reading off the wire, calibrated to the pad that sent it."""
self._sample = self.calibration.apply(sample)
self._seen_at = moment
def read(self, moment: float) -> Sample:
+117 -16
View File
@@ -34,26 +34,34 @@ MM_PER_M = 1000.0
class Layout:
"""Which axis and button index does what, in SDL's numbering.
The defaults are the layout SDL reports for an Xbox-style pad, which is also
what it reports for most PlayStation-style ones. A pad that disagrees is a
change here rather than a new environment variable: an index nobody can
verify from the outside is worse than a line of code somebody can read.
These are what an Xbox One S pad reports over Bluetooth, swept one control
at a time: sticks on 0-3, both triggers last, and buttons with gaps at 2 and
5. A pad that disagrees is a change here rather than a new environment
variable: an index nobody can verify from the outside is worse than a line
of code somebody can read. `dora-teleop-probe` is how you check.
**The axes can be checked at runtime and the buttons cannot.**
:func:`rest_zeros` refuses a pad whose resting axes say this numbering is
wrong, because an axis at rest has a value to compare. An unpressed button
reads the same on every pad, so a wrong button index is silent — it was
found here by `clear` toggling freedrive and `yaw_left` firing the E-STOP.
The probe, and pressing each button once, is the only check there is.
"""
left_x: int = 0
left_y: int = 1
left_trigger: int = 2
right_x: int = 3
right_y: int = 4
right_x: int = 2
right_y: int = 3
left_trigger: int = 4
right_trigger: int = 5
yaw_left: int = 4 # LB
yaw_right: int = 5 # RB
grasp: int = 0 # A
release: int = 1 # B
clear: int = 2 # X
freedrive: int = 3 # Y
estop: int = 6 # Back
clear: int = 3 # X
freedrive: int = 4 # Y
yaw_left: int = 6 # LB
yaw_right: int = 7 # RB
estop: int = 11 # Back
LAYOUT = Layout()
@@ -69,6 +77,16 @@ TOOL_FRAME = "tool"
# would mean a stick could claim the arm and then not move it.
ACTIVITY = 0.3
# Where an untouched control sits once :func:`canonical` has had it: a stick at
# centre, a trigger at the bottom of its travel.
STICK_REST = 0.0
TRIGGER_REST = -1.0
# How far an axis may rest from those and still be believed. Wide enough for a
# worn stick's tilt and for a pad that reports a trigger it has never felt as 0,
# and far short of the full deflection a wrongly numbered axis shows.
REST_TOLERANCE = 0.25
def deadzone(value: float, width: float) -> float:
"""Ignore travel near centre, and rescale what is left to the full range.
@@ -98,11 +116,13 @@ def expo(value: float, factor: float) -> float:
def trigger(value: float) -> float:
"""Return a trigger's travel as 0 at rest and 1 fully pressed.
SDL reports a trigger from -1 to 1, unlike a stick, and a trigger that has
never been touched sometimes reads 0 instead of -1 until it first moves.
Clamping keeps that from reading as half pressed.
SDL reports a trigger from -1 to 1, unlike a stick, so `TRIGGER_REST` is
where an untouched one sits. A trigger that has never moved sometimes reads
0 instead, which this function alone cannot tell from half pressed —
:func:`canonical` is what fixes that, by measuring the pad's own rest before
anything reaches here.
"""
return max(0.0, min(1.0, (value + 1.0) / 2.0))
return max(0.0, min(1.0, (value - TRIGGER_REST) / (1.0 - TRIGGER_REST)))
def axis(values: list[float], index: int) -> float:
@@ -115,6 +135,87 @@ def pressed(buttons: list[bool], index: int) -> bool:
return bool(buttons[index]) if index < len(buttons) else False
def axis_names(layout: Layout = LAYOUT) -> dict[int, str]:
"""Name each axis index the layout uses."""
return {
layout.left_x: "left stick X",
layout.left_y: "left stick Y",
layout.left_trigger: "left trigger",
layout.right_x: "right stick X",
layout.right_y: "right stick Y",
layout.right_trigger: "right trigger",
}
def rest_zeros(axes: list[float], layout: Layout = LAYOUT) -> list[float]:
"""Return where each axis of an untouched pad sits, to measure deflection from.
An untouched pad is the one moment this node can check `layout` against the
thing in front of it: a stick rests near centre and a trigger near the
bottom of its travel, so an axis resting anywhere else means the numbering
is wrong, or a control is being held, or the pad is broken. All three look
the same from here and all three raise, because all three would otherwise
drive the arm from a reading nobody made — a trigger read as a stick asks
for full speed while sitting untouched on the desk.
Parameters
----------
axes
One reading, taken while nobody is touching the pad.
layout
Which index is which control.
Returns
-------
list of float
The resting value of each axis, in the order they were reported.
Raises
------
ValueError
If any axis rests where the layout says it cannot, naming the index,
what the layout calls it, and what it read.
"""
names = axis_names(layout)
triggers = (layout.left_trigger, layout.right_trigger)
for index, value in enumerate(axes):
if index in triggers:
# Either end is a resting trigger: -1 is what SDL reports, and 0 is
# what some pads report until the trigger first moves.
if abs(value - TRIGGER_REST) <= REST_TOLERANCE or abs(value - STICK_REST) <= REST_TOLERANCE:
continue
elif abs(value - STICK_REST) <= REST_TOLERANCE:
continue
name = names.get(index, "an axis the layout does not use")
raise ValueError(f"axis {index} rests at {value:+.3f}, which is not where {name} sits")
return list(axes)
def canonical(axes: list[float], zeros: list[float], layout: Layout = LAYOUT) -> list[float]:
"""Return this pad's axes as the layout's ideal pad would have reported them.
Sticks come back resting at `STICK_REST` and triggers at `TRIGGER_REST`,
whatever this particular pad does when untouched, so everything downstream
is written once for a pad that behaves. A stick that no longer centres, and
a trigger that reads 0 until it first moves, both stop being special cases
here rather than in every function that reads one.
Deflection is measured from `zeros` rather than from the ideal, which is the
whole point: the tilt is subtracted, not deadzoned away, so the travel that
is left still reaches full speed.
"""
triggers = (layout.left_trigger, layout.right_trigger)
out = []
for index, value in enumerate(axes):
zero = zeros[index] if index < len(zeros) else STICK_REST
if index in triggers:
travel = (value - zero) / (1.0 - zero) if zero < 1.0 else 0.0
out.append(TRIGGER_REST + (1.0 - TRIGGER_REST) * max(0.0, min(1.0, travel)))
else:
out.append(max(-1.0, min(1.0, value - zero)))
return out
def from_pad(axes: list[float], buttons: list[bool], config, layout: Layout = LAYOUT) -> list[float]:
"""Map one gamepad reading to the normalised six-vector.
+30 -18
View File
@@ -35,7 +35,7 @@ from dora import Node
from dora_teleop import joy, mapping
from dora_teleop.config import Config
from dora_teleop.mapping import LAYOUT
from dora_teleop.mapping import LAYOUT, axis_names
# What each velocity component is, in the order the arm takes them.
COMPONENTS = (
@@ -50,18 +50,6 @@ COMPONENTS = (
CLEAR = "\033[H\033[J"
def axis_names(layout=LAYOUT) -> dict[int, str]:
"""Name each axis index the layout uses."""
return {
layout.left_x: "left stick X",
layout.left_y: "left stick Y",
layout.left_trigger: "left trigger",
layout.right_x: "right stick X",
layout.right_y: "right stick Y",
layout.right_trigger: "right trigger",
}
def button_names(layout=LAYOUT) -> dict[int, str]:
"""Name each button index the layout uses."""
return {
@@ -89,8 +77,15 @@ def bar(value: float, width: int = 21) -> str:
return "".join(cells)
def render(sample, velocity: list[float], config: Config) -> str:
"""Build the whole display for one reading."""
def render(sample, driven, velocity: list[float], config: Config, mismatch: str | None = None) -> str:
"""Build the whole display for one reading.
``sample`` is the pad exactly as it was reported, because seeing the raw
numbers is the point of this tool. ``driven`` is the same reading after
calibration — what the node actually decides from — and ``velocity`` is what
it would send. A pad this node refuses reaches here as an absent ``driven``
and a ``mismatch`` saying why.
"""
lines = ["dora-teleop probe — Ctrl-C to stop", ""]
if not sample.present:
@@ -102,6 +97,18 @@ def render(sample, velocity: list[float], config: Config) -> str:
]
return "\n".join(lines)
if mismatch:
lines += [
" this pad does not match the layout, and the arm will not be driven from it.",
"",
f" {mismatch}",
"",
" Either something was being held when it was plugged in, or the indices",
" below are numbered differently on this pad. The axes are raw, so they",
" say which: fix `Layout` in dora_teleop/mapping.py to match them.",
"",
]
names = axis_names()
lines.append(f" axes ({len(sample.axes)} reported)")
for index, value in enumerate(sample.axes):
@@ -121,7 +128,7 @@ def render(sample, velocity: list[float], config: Config) -> str:
lines += [
"",
f" moving: {moving}",
f" asking for the arm: {'yes' if mapping.is_active(sample) else 'no'}",
f" asking for the arm: {'yes' if mapping.is_active(driven) else 'no'}",
]
return "\n".join(lines)
@@ -137,14 +144,19 @@ def main() -> None:
args = parser.parse_args()
config = Config.from_env()
calibration = joy.Calibration()
node = Node(args.name)
for event in node:
if event["type"] != "INPUT" or event["id"] != "joy":
continue
sample = joy.decode(event)
velocity = mapping.velocity(sample.axes, sample.buttons, config) if sample.present else [0.0] * 6
print(CLEAR + render(sample, velocity, config), flush=True)
# Through the same calibration the node drives from, so what this prints
# stays what the arm would get. What is displayed is still the raw
# reading: the indices are what a pad is checked against.
driven = calibration.apply(sample)
velocity = mapping.velocity(driven.axes, driven.buttons, config) if driven.present else [0.0] * 6
print(CLEAR + render(sample, driven, velocity, config, calibration.mismatch), flush=True)
if __name__ == "__main__":
+2 -2
View File
@@ -6,8 +6,8 @@ readme = "README.md"
license = { text = "MIT" }
authors = [{ name = "Liria" }]
# dora-rs 1.0 requires 3.11; the node uses PEP 604 unions and zip(strict=).
requires-python = ">=3.11"
# dora-rs 1.0 requires 3.11; this node standardises on 3.12.
requires-python = ">=3.12"
# No hardware dependency. The pad is `dora-joy` and the arm is `dora-ulite6`;
# this node decides, and serves the console that asks it to.
+48 -2
View File
@@ -7,6 +7,7 @@ import pytest
from dora_teleop.config import Config
from dora_teleop.joy import Sample
from dora_teleop.mapping import LAYOUT, TRIGGER_REST
# The shape `dora-joy` publishes. Declared here rather than inferred for the same
# reason it is declared there: pyarrow reads an empty `axes` list as `list<null>`,
@@ -66,13 +67,58 @@ def stick(axes=None, buttons=None, present=True):
values = list(axes or [])
values += [0.0] * (6 - len(values))
# A trigger at rest reads -1, and a test that forgot to say so would look
# like both triggers held down.
for index in (2, 5):
# like both triggers held down. Which indices those are comes from the
# layout, so a test never has to be edited when a pad renumbers them.
for index in (LAYOUT.left_trigger, LAYOUT.right_trigger):
if axes is None or index >= len(axes):
values[index] = -1.0
return Sample(axes=values, buttons=list(buttons or []), present=present)
def axes_for(
left_x=0.0,
left_y=0.0,
right_x=0.0,
right_y=0.0,
left_trigger=TRIGGER_REST,
right_trigger=TRIGGER_REST,
):
"""Lay out a pad's axes by what each control is rather than by its index.
A test that numbers its axes says nothing about which control it meant, and
goes on passing while meaning something else once a pad renumbers them.
"""
values = [0.0] * 6
values[LAYOUT.left_x] = left_x
values[LAYOUT.left_y] = left_y
values[LAYOUT.right_x] = right_x
values[LAYOUT.right_y] = right_y
values[LAYOUT.left_trigger] = left_trigger
values[LAYOUT.right_trigger] = right_trigger
return values
def buttons_for(
grasp=False, release=False, clear=False, freedrive=False, yaw_left=False, yaw_right=False, estop=False
):
"""Lay out a pad's buttons by what each one does rather than by its index.
The same reason as :func:`axes_for`, and more pressing: an unpressed button
reads identically on every pad, so a test that numbers them is the only
place a wrong index could ever have been caught.
"""
wanted = {
LAYOUT.grasp: grasp,
LAYOUT.release: release,
LAYOUT.clear: clear,
LAYOUT.freedrive: freedrive,
LAYOUT.yaw_left: yaw_left,
LAYOUT.yaw_right: yaw_right,
LAYOUT.estop: estop,
}
return [wanted.get(index, False) for index in range(max(wanted) + 1)]
def joy_message(sample):
"""Build the Arrow row `dora-joy` would publish for one reading."""
return pa.array(
+24 -17
View File
@@ -6,7 +6,7 @@ from dora_teleop import main as loop
from dora_teleop.arbiter import GAMEPAD, WEB
from dora_teleop.config import Config
from dora_teleop.joy import Sample
from tests.conftest import RecordingNode, input_event, joy_message, stick
from tests.conftest import RecordingNode, buttons_for, input_event, joy_message, stick
STOP = [0.0] * 6
PUSHED = [0.0, -1.0]
@@ -47,7 +47,14 @@ def ticks(node, state, count=1):
def drive(node, state, sample, count=1):
"""Hold one reading for `count` ticks, the way a real pad and joy node would."""
"""Hold one reading for `count` ticks, the way a real pad and joy node would.
A pad this node has not yet seen at rest is sent one resting reading first,
because a real one is always at rest before a thumb reaches it — and the
node refuses to drive an arm from a pad it has never seen still.
"""
if not state.joy.calibration.known:
send_joy(node, state, stick())
for _ in range(count):
send_joy(node, state, sample)
loop._tick(node, state)
@@ -271,7 +278,7 @@ def test_a_latched_estop_keeps_the_arm_still_under_a_pushed_stick(clock):
drive(node, state, stick(axes=PUSHED))
node.clear()
drive(node, state, stick(axes=PUSHED, buttons=[False] * 6 + [True]))
drive(node, state, stick(axes=PUSHED, buttons=buttons_for(estop=True)))
node.clear()
jogs = drive(node, state, stick(axes=PUSHED), count=4)
@@ -282,11 +289,11 @@ def test_the_pad_can_release_the_latch_it_set(clock):
# A pad-only dataflow has no console RESET, so a latch nothing on the pad
# can release would strand the arm until the process was restarted.
node, state = build()
drive(node, state, stick(buttons=[False] * 6 + [True]))
drive(node, state, stick(buttons=buttons_for(estop=True)))
assert state.view.estop is True
node.clear()
drive(node, state, stick(buttons=[False, False, True]))
drive(node, state, stick(buttons=buttons_for(clear=True)))
assert state.view.estop is False
assert "clear" in node.services()
@@ -296,20 +303,20 @@ def test_the_latch_is_released_without_holding_the_arm(clock):
# An idle pad hands the lease back two seconds after the stop it just made,
# so gating the release on the lease would be a trap.
node, state = build()
drive(node, state, stick(buttons=[False] * 6 + [True]))
drive(node, state, stick(buttons=buttons_for(estop=True)))
state.arbiter.release(GAMEPAD)
state.arbiter.take(WEB, clock.t)
node.clear()
drive(node, state, stick(buttons=[False, False, True]))
drive(node, state, stick(buttons=buttons_for(clear=True)))
assert state.view.estop is False
def test_the_arm_drives_again_once_the_latch_is_released(clock):
node, state = build()
drive(node, state, stick(buttons=[False] * 6 + [True]))
drive(node, state, stick(buttons=[False, False, True]))
drive(node, state, stick(buttons=buttons_for(estop=True)))
drive(node, state, stick(buttons=buttons_for(clear=True)))
node.clear()
jogs = drive(node, state, stick(axes=PUSHED))
@@ -322,7 +329,7 @@ def test_a_latched_estop_stops_the_browser_too(clock):
# through a stop somebody made on the gamepad.
node, state = build()
state.clients = 1
drive(node, state, stick(buttons=[False] * 6 + [True]))
drive(node, state, stick(buttons=buttons_for(estop=True)))
state.arbiter.release(GAMEPAD)
state.arbiter.take(WEB, clock.t)
node.clear()
@@ -337,7 +344,7 @@ def test_the_estop_button_works_without_the_pad_holding_the_arm(clock):
node, state = build()
state.arbiter.take(WEB, clock.t)
drive(node, state, stick(buttons=[False] * 6 + [True]))
drive(node, state, stick(buttons=buttons_for(estop=True)))
assert "stop" in node.services()
@@ -402,7 +409,7 @@ def test_a_button_asks_in_verbs_as_well_as_in_services(clock):
# what dora-ulite6 reads until it does.
node, state = build()
drive(node, state, stick(buttons=[True]))
drive(node, state, stick(buttons=buttons_for(grasp=True)))
assert node.actions() == ["gripper_close"]
assert "grasp" in node.services()
@@ -410,10 +417,10 @@ def test_a_button_asks_in_verbs_as_well_as_in_services(clock):
def test_every_verb_the_pad_can_ask_for_is_robot_agnostic(clock):
node, state = build()
drive(node, state, stick(buttons=[False, True]))
drive(node, state, stick(buttons=[False] * 3 + [True]))
drive(node, state, stick(buttons=[False] * 6 + [True]))
drive(node, state, stick(buttons=[False, False, True]))
drive(node, state, stick(buttons=buttons_for(release=True)))
drive(node, state, stick(buttons=buttons_for(freedrive=True)))
drive(node, state, stick(buttons=buttons_for(estop=True)))
drive(node, state, stick(buttons=buttons_for(clear=True)))
assert node.actions() == ["gripper_open", "freedrive", "stop", "clear"]
@@ -426,6 +433,6 @@ def test_the_first_press_after_the_pad_has_been_idle_is_not_dropped(clock):
node, state = build()
assert state.arbiter.owner is None
drive(node, state, stick(buttons=[True]), count=3)
drive(node, state, stick(buttons=buttons_for(grasp=True)), count=3)
assert node.actions() == ["gripper_close"]
+93 -4
View File
@@ -1,9 +1,12 @@
"""The pad as it arrives on the wire, and what silence on that wire means."""
import pyarrow as pa
import pytest
from dora_teleop import mapping
from dora_teleop.joy import ABSENT, JoyInput, Sample, decode
from tests.conftest import input_event, joy_message, stick
from dora_teleop.mapping import LAYOUT
from tests.conftest import axes_for, input_event, joy_message, stick
def joy_event(sample):
@@ -41,21 +44,107 @@ def test_a_message_with_no_rows_reads_as_absent():
def test_a_fresh_reading_is_handed_back():
joy = JoyInput(ttl_s=0.2)
sample = stick(axes=[0.5])
joy.accept(sample, 100.0)
joy.accept(stick(), 100.0)
joy.accept(stick(axes=[0.5]), 100.0)
assert joy.read(100.1) is sample
assert joy.read(100.1).axes[0] == 0.5
def test_a_reading_nobody_is_refreshing_reads_as_absent():
# dora-joy publishes every tick, so silence means it died — and a dead pad
# node is indistinguishable from an unplugged pad as far as the arm cares.
joy = JoyInput(ttl_s=0.2)
joy.accept(stick(), 100.0)
joy.accept(stick(axes=[0.5]), 100.0)
assert joy.read(100.5) is ABSENT
# --- the pad this node is actually holding ---
def test_a_pad_is_measured_where_it_rests_and_the_tilt_is_subtracted():
# A stick that no longer centres is the ordinary kind of worn pad. Its rest
# becomes zero, so it asks for nothing until it is actually pushed.
joy = JoyInput(ttl_s=0.2)
tilted = axes_for(left_x=0.2, left_y=-0.15)
joy.accept(stick(axes=tilted), 100.0)
reading = joy.read(100.1)
assert reading.present is True
assert reading.axes[LAYOUT.left_x] == pytest.approx(0.0)
assert reading.axes[LAYOUT.left_y] == pytest.approx(0.0)
assert mapping.is_active(reading) is False
def test_the_tilt_is_subtracted_rather_than_deadzoned_so_full_travel_remains():
joy = JoyInput(ttl_s=0.2)
joy.accept(stick(axes=axes_for(left_x=0.2)), 100.0)
joy.accept(stick(axes=axes_for(left_x=1.0)), 100.0)
assert joy.read(100.1).axes[LAYOUT.left_x] == pytest.approx(0.8)
def test_a_trigger_that_rests_at_zero_does_not_read_as_half_pressed():
# Some pads report a trigger as 0 until it first moves. Read as SDL's -1..1
# that is half travel, which would lift the arm on its own.
joy = JoyInput(ttl_s=0.2)
joy.accept(stick(axes=axes_for(right_trigger=0.0)), 100.0)
reading = joy.read(100.1)
assert mapping.trigger(reading.axes[LAYOUT.right_trigger]) == pytest.approx(0.0)
assert mapping.is_active(reading) is False
def test_a_pad_that_does_not_fit_the_layout_reads_as_absent():
# The bug this exists for: a trigger on an index the layout calls a stick
# rests at -1.0, which is full deflection — an untouched pad on the desk
# asking for full speed, and holding the lease so nothing can take it back.
joy = JoyInput(ttl_s=0.2)
wrong = list(axes_for())
wrong[LAYOUT.right_y] = -1.0
joy.accept(stick(axes=wrong), 100.0)
assert joy.read(100.1) is ABSENT
assert "right stick Y" in joy.calibration.mismatch
def test_a_pad_held_when_it_appears_is_refused_until_it_is_let_go():
# A stick held at startup and a wrongly numbered axis look identical in one
# reading, so the safe answer is the same for both: no arm until it rests.
joy = JoyInput(ttl_s=0.2)
joy.accept(stick(axes=axes_for(left_y=-1.0)), 100.0)
assert joy.read(100.1) is ABSENT
joy.accept(stick(), 100.2)
assert joy.read(100.3).present is True
def test_rest_is_not_re_measured_while_the_pad_is_in_use():
# Re-measuring under a held stick would record that deflection as centre,
# and the arm would then move the moment the operator let go.
joy = JoyInput(ttl_s=0.2)
joy.accept(stick(), 100.0)
joy.accept(stick(axes=axes_for(left_y=-1.0)), 100.1)
joy.accept(stick(axes=axes_for(left_y=-1.0)), 100.2)
assert joy.read(100.3).axes[LAYOUT.left_y] == pytest.approx(-1.0)
def test_a_pad_that_goes_away_is_measured_again_when_it_comes_back():
# The second pad is not the first one, and may not rest where it did.
joy = JoyInput(ttl_s=0.2)
joy.accept(stick(axes=axes_for(left_x=0.2)), 100.0)
joy.accept(Sample(present=False), 100.1)
joy.accept(stick(axes=axes_for(left_x=-0.2)), 100.2)
assert joy.read(100.3).axes[LAYOUT.left_x] == pytest.approx(0.0)
def test_nothing_ever_arriving_reads_as_absent():
# A dataflow with no `joy` wired at all, which is a browser-only console.
assert JoyInput(ttl_s=0.2).read(100.0) is ABSENT
+88 -12
View File
@@ -3,8 +3,8 @@
import pytest
from dora_teleop import mapping
from dora_teleop.mapping import VX, VY, VZ, WX, WY, WZ
from tests.conftest import stick
from dora_teleop.mapping import LAYOUT, VX, VY, VZ, WX, WY, WZ
from tests.conftest import axes_for, buttons_for, stick
def test_a_stick_at_rest_asks_for_nothing(config):
@@ -29,15 +29,15 @@ def test_pushing_the_stick_right_moves_the_arm_along_positive_y(config):
def test_the_right_trigger_lifts_and_the_left_one_lowers(config):
up = mapping.velocity(stick(axes=[0, 0, -1.0, 0, 0, 1.0]).axes, [], config)
down = mapping.velocity(stick(axes=[0, 0, 1.0, 0, 0, -1.0]).axes, [], config)
up = mapping.velocity(axes_for(right_trigger=1.0), [], config)
down = mapping.velocity(axes_for(left_trigger=1.0), [], config)
assert up[VZ] == pytest.approx(config.speed_mm_s)
assert down[VZ] == pytest.approx(-config.speed_mm_s)
def test_both_triggers_together_cancel(config):
velocity = mapping.velocity(stick(axes=[0, 0, 1.0, 0, 0, 1.0]).axes, [], config)
velocity = mapping.velocity(axes_for(left_trigger=1.0, right_trigger=1.0), [], config)
assert velocity[VZ] == 0.0
@@ -51,15 +51,15 @@ def test_an_untouched_trigger_does_not_read_as_half_pressed(config):
def test_the_right_stick_turns_the_tool_in_degrees_per_second(config):
velocity = mapping.velocity(stick(axes=[0, 0, -1, 1.0, -1.0, -1]).axes, [], config)
velocity = mapping.velocity(axes_for(right_x=1.0, right_y=-1.0), [], config)
assert velocity[WX] == pytest.approx(config.speed_deg_s)
assert velocity[WY] == pytest.approx(config.speed_deg_s)
def test_the_bumpers_yaw_in_opposite_directions(config):
right = mapping.velocity(stick().axes, [False] * 5 + [True], config)
left = mapping.velocity(stick().axes, [False] * 4 + [True, False], config)
right = mapping.velocity(stick().axes, buttons_for(yaw_right=True), config)
left = mapping.velocity(stick().axes, buttons_for(yaw_left=True), config)
assert right[WZ] == pytest.approx(config.speed_deg_s)
assert left[WZ] == pytest.approx(-config.speed_deg_s)
@@ -68,7 +68,7 @@ def test_the_bumpers_yaw_in_opposite_directions(config):
def test_several_axes_move_at_once(config):
# The whole reason a pad beats the console's four-sector dial: the dial can
# only ever ask for one axis.
velocity = mapping.velocity(stick(axes=[1.0, -1.0, -1, 0, 0, 1.0]).axes, [], config)
velocity = mapping.velocity(axes_for(left_x=1.0, left_y=-1.0, right_trigger=1.0), [], config)
assert velocity[VX] > 0
assert velocity[VY] > 0
@@ -129,14 +129,90 @@ def test_a_nudged_stick_is_asking_for_the_arm():
def test_a_held_trigger_is_asking_for_the_arm():
assert mapping.is_active(stick(axes=[0.0, 0.0, 1.0])) is True
assert mapping.is_active(stick(axes=axes_for(left_trigger=1.0))) is True
def test_a_held_button_is_asking_for_the_arm():
assert mapping.is_active(stick(buttons=[True])) is True
assert mapping.is_active(stick(buttons=buttons_for(grasp=True))) is True
def test_an_axis_the_layout_does_not_use_still_counts():
# A pad reporting its d-pad as two extra axes is asking for the arm when
# somebody presses it, whether or not this node maps it to anything.
assert mapping.is_active(stick(axes=[0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 1.0])) is True
assert mapping.is_active(stick(axes=axes_for() + [1.0])) is True
# --- reading a pad that is not the layout's ideal one ---
def test_a_resting_pad_measures_as_resting():
zeros = mapping.rest_zeros(axes_for())
assert zeros == axes_for()
def test_a_small_tilt_is_accepted_as_rest():
# A worn stick is an ordinary pad, not a broken one.
assert mapping.rest_zeros(axes_for(left_x=0.2, right_y=-0.2))
def test_an_axis_resting_where_the_layout_says_it_cannot_is_refused():
wrong = axes_for()
wrong[LAYOUT.right_y] = -1.0
with pytest.raises(ValueError, match="right stick Y"):
mapping.rest_zeros(wrong)
def test_the_refusal_names_the_index_and_what_it_read():
# It is read off a terminal by somebody deciding what to put in `Layout`.
wrong = axes_for()
wrong[LAYOUT.right_x] = 0.9
with pytest.raises(ValueError, match=r"axis 2 rests at \+0.900"):
mapping.rest_zeros(wrong)
def test_a_trigger_resting_at_either_end_is_accepted():
# -1 is what SDL reports; 0 is what some pads report until it first moves.
assert mapping.rest_zeros(axes_for(left_trigger=-1.0, right_trigger=0.0))
def test_an_axis_the_layout_does_not_use_still_has_to_rest():
# A d-pad reported as two extra axes counts towards `is_active`, so one
# stuck at an extreme would hold the arm as surely as a named one.
with pytest.raises(ValueError, match="axis 6"):
mapping.rest_zeros(axes_for() + [1.0])
def test_canonical_puts_a_tilted_stick_back_at_centre():
tilted = axes_for(left_x=0.2)
canonical = mapping.canonical(tilted, mapping.rest_zeros(tilted))
assert canonical[LAYOUT.left_x] == pytest.approx(0.0)
def test_canonical_puts_a_trigger_back_at_its_own_rest():
zeroed = axes_for(left_trigger=0.0, right_trigger=0.0)
canonical = mapping.canonical(zeroed, mapping.rest_zeros(zeroed))
assert canonical[LAYOUT.left_trigger] == pytest.approx(mapping.TRIGGER_REST)
assert canonical[LAYOUT.right_trigger] == pytest.approx(mapping.TRIGGER_REST)
def test_a_trigger_measured_from_zero_still_reaches_full_travel():
zeros = mapping.rest_zeros(axes_for(right_trigger=0.0))
canonical = mapping.canonical(axes_for(right_trigger=1.0), zeros)
assert mapping.trigger(canonical[LAYOUT.right_trigger]) == pytest.approx(1.0)
def test_a_calibrated_pad_at_rest_asks_for_nothing(config):
tilted = axes_for(left_x=0.2, left_y=-0.15, right_trigger=0.0)
canonical = mapping.canonical(tilted, mapping.rest_zeros(tilted))
assert mapping.velocity(canonical, [], config) == [0.0] * 6
+39 -12
View File
@@ -2,7 +2,7 @@
from dora_teleop import mapping, probe
from dora_teleop.joy import Sample
from tests.conftest import stick
from tests.conftest import buttons_for, stick
def test_a_centred_stick_puts_the_marker_in_the_middle():
@@ -28,7 +28,7 @@ def test_a_reading_beyond_full_deflection_stays_on_the_bar():
def test_no_gamepad_says_so_and_says_the_arm_is_being_held_still(config):
# Absence covers both "no pad" and "dora-joy stopped sending", and the probe
# must not imply the arm is waiting on something it is not.
drawn = probe.render(Sample(present=False), [0.0] * 6, config)
drawn = probe.render(Sample(present=False), Sample(present=False), [0.0] * 6, config)
assert "no gamepad" in drawn
assert "hold still" in drawn
@@ -39,30 +39,31 @@ def test_every_axis_the_pad_reports_is_shown_even_the_unused_ones(config):
# for, so nothing may be hidden.
sample = Sample(axes=[0.0] * 8, buttons=[], present=True)
drawn = probe.render(sample, [0.0] * 6, config)
drawn = probe.render(sample, sample, [0.0] * 6, config)
assert "unused" in drawn
assert "axes (8 reported)" in drawn
def test_a_pressed_button_is_named(config):
sample = stick(buttons=[True])
sample = stick(buttons=buttons_for(grasp=True))
drawn = probe.render(sample, [0.0] * 6, config)
drawn = probe.render(sample, sample, [0.0] * 6, config)
assert "grasp" in drawn
def test_nothing_pressed_says_so_rather_than_showing_an_empty_line(config):
drawn = probe.render(stick(), [0.0] * 6, config)
drawn = probe.render(stick(), stick(), [0.0] * 6, config)
assert "none pressed" in drawn
def test_the_velocity_is_shown_in_the_units_the_arm_takes(config):
velocity = mapping.velocity(stick(axes=[0.0, -1.0]).axes, [], config)
pushed = stick(axes=[0.0, -1.0])
velocity = mapping.velocity(pushed.axes, [], config)
drawn = probe.render(stick(axes=[0.0, -1.0]), velocity, config)
drawn = probe.render(pushed, pushed, velocity, config)
assert "mm/s" in drawn
assert "deg/s" in drawn
@@ -70,13 +71,14 @@ def test_the_velocity_is_shown_in_the_units_the_arm_takes(config):
def test_a_still_arm_is_reported_as_still(config):
drawn = probe.render(stick(), [0.0] * 6, config)
drawn = probe.render(stick(), stick(), [0.0] * 6, config)
assert "told to hold still" in drawn
def test_a_moving_arm_is_reported_as_moving(config):
drawn = probe.render(stick(axes=[0.0, -1.0]), [10.0, 0, 0, 0, 0, 0], config)
pushed = stick(axes=[0.0, -1.0])
drawn = probe.render(pushed, pushed, [10.0, 0, 0, 0, 0, 0], config)
assert "moving: yes" in drawn
@@ -84,5 +86,30 @@ def test_a_moving_arm_is_reported_as_moving(config):
def test_whether_the_pad_is_asking_for_the_arm_is_shown(config):
# The probe is where a resting trigger claiming the arm would have been
# visible, so it says what `is_active` thinks rather than leaving it implied.
assert "asking for the arm: no" in probe.render(stick(), [0.0] * 6, config)
assert "asking for the arm: yes" in probe.render(stick(axes=[0.0, -1.0]), [0.0] * 6, config)
rest, pushed = stick(), stick(axes=[0.0, -1.0])
assert "asking for the arm: no" in probe.render(rest, rest, [0.0] * 6, config)
assert "asking for the arm: yes" in probe.render(pushed, pushed, [0.0] * 6, config)
def test_a_pad_that_does_not_fit_the_layout_is_called_out(config):
# This is the tool for exactly that pad, so the reason goes on the screen
# rather than only in the node's log.
wrong = stick(axes=[0.0, 0.0, 0.0, -1.0, -1.0, -1.0])
mismatch = "axis 3 rests at -1.000, which is not where right stick Y sits"
drawn = probe.render(wrong, Sample(present=False), [0.0] * 6, config, mismatch)
assert "does not match the layout" in drawn
assert mismatch in drawn
assert "asking for the arm: no" in drawn
def test_the_raw_axes_are_still_shown_for_a_pad_that_does_not_fit(config):
# The numbers are the evidence: they are what `Layout` gets corrected from.
wrong = stick(axes=[0.0, 0.0, 0.0, -1.0, -1.0, -1.0])
drawn = probe.render(wrong, Sample(present=False), [0.0] * 6, config, "axis 3")
assert "-1.000" in drawn
assert "axes (6 reported)" in drawn
Generated
+2 -196
View File
@@ -1,10 +1,6 @@
version = 1
revision = 3
requires-python = ">=3.11"
resolution-markers = [
"python_full_version >= '3.12'",
"python_full_version < '3.12'",
]
requires-python = ">=3.12"
[[package]]
name = "aiohappyeyeballs"
@@ -31,24 +27,6 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" },
{ url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" },
{ url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" },
{ url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" },
{ url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" },
{ url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" },
{ url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" },
{ url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" },
{ url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" },
{ url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" },
{ url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" },
{ url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" },
{ url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" },
{ url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" },
{ url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" },
{ url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" },
{ url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" },
{ url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" },
{ url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" },
{ url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" },
@@ -169,8 +147,7 @@ name = "dora-rs"
version = "1.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "numpy" },
{ name = "pyarrow" },
{ name = "pyyaml" },
]
@@ -223,22 +200,6 @@ version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" },
{ url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" },
{ url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" },
{ url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" },
{ url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" },
{ url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" },
{ url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" },
{ url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" },
{ url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" },
{ url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" },
{ url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" },
{ url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" },
{ url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" },
{ url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" },
{ url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" },
{ url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" },
{ url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" },
{ url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" },
{ url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" },
@@ -346,26 +307,6 @@ version = "6.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/14/95/989c1b5ca17b72128661530cd6e351a0a83cda9a4d6c036e9ed976c18931/multidict-6.8.0.tar.gz", hash = "sha256:5cd4637ce76312ba1e05eb9c5193fec231f64fee0944e135fa1e951242355b37", size = 122412, upload-time = "2026-09-09T13:57:57.967Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/37/90216392620b6ef8704eb0bc055141745de396121067e98f1f72bdac33c3/multidict-6.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1b8986d4313dcee7c932837d16a535f1840b827bac1ea7c5c4c80751d0423794", size = 85033, upload-time = "2026-09-09T13:53:18.786Z" },
{ url = "https://files.pythonhosted.org/packages/2b/bb/e01b8cf906479b2fa046e992b84a9d9f39c1ed4058acc353004cd9a04df9/multidict-6.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2622fe114c0bd66ca5c461859357587f5a5e35ee5ff49fc5643d1bc78dbb41c6", size = 51008, upload-time = "2026-09-09T13:53:20.044Z" },
{ url = "https://files.pythonhosted.org/packages/c5/da/35d70c920812d9ddc6f295f6426457665194335fbc35aa0b96716aba219f/multidict-6.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26a7aafc992e78872e2c8c1f7248c0e01139cf9020a7781b0c064fa566832712", size = 50232, upload-time = "2026-09-09T13:53:21.356Z" },
{ url = "https://files.pythonhosted.org/packages/91/6b/4c988a7c0daa4fbffc6080ed3c37b3a67cf225ba1de69d10a19ca1dd8d0d/multidict-6.8.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:05c2e90c5289c5f7436ba2c25812a5fbdaa1c1bc11c8d8d3bbf64f5cd7c633dd", size = 271678, upload-time = "2026-09-09T13:53:22.716Z" },
{ url = "https://files.pythonhosted.org/packages/73/2b/22c7de8a72fc5c36390e8049d86d842b032ac7c87ade035a3dafb7df4ffc/multidict-6.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7941ef106ca1f2c62314a13c7ed913bcf49641f3efdc12864d588e17870920ac", size = 270283, upload-time = "2026-09-09T13:53:24.235Z" },
{ url = "https://files.pythonhosted.org/packages/ce/f4/c1428318f945c57c016ba690338af41f87f18a7d3a7ef3227b1440a2c169/multidict-6.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a5a7ee1217949ddd43c6b7bcf70d5c22193bb50e8c695386de5905325e93ce9f", size = 245306, upload-time = "2026-09-09T13:53:25.656Z" },
{ url = "https://files.pythonhosted.org/packages/8b/92/a37f7519fb32b0bf43b0540292effe60edaf0691959214b227795bd3d56a/multidict-6.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b62b7e0025aa48dec11e125e655d1157985a5fdcec04b1ad500101ad072b891", size = 279567, upload-time = "2026-09-09T13:53:27.162Z" },
{ url = "https://files.pythonhosted.org/packages/51/fe/a93c2ce417401863cc88ecf6561577625c140990d412e37f347a1c03a144/multidict-6.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea880d441be7c510106bc56064be39266d948aef94ad4955e8784690019a5d9f", size = 282526, upload-time = "2026-09-09T13:53:28.927Z" },
{ url = "https://files.pythonhosted.org/packages/f2/9d/6bb4f84fdd82acfa09dc312ac133e7f76cbf2370004447f2a90e65e5d63f/multidict-6.8.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab83fdd8cf307353edba9c427c17a3a021c2522d690f5633dd9f72d28b48ccca", size = 272604, upload-time = "2026-09-09T13:53:30.595Z" },
{ url = "https://files.pythonhosted.org/packages/3a/97/df0a30a4d786d313f24b39cb96edaaa3bbfe83a0b309577c81a797783ec5/multidict-6.8.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3d1f48582686a0a3b81e9b43234766cc96697df72081af3f48107bd3f34d34e5", size = 250786, upload-time = "2026-09-09T13:53:32.15Z" },
{ url = "https://files.pythonhosted.org/packages/6f/72/e59a917680d00214ba41f9fec19a8bec48f3bdf62656bc4377f37ae30947/multidict-6.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:563661919f603374c40cf45ffcd25535c12b8954203569a2ab1cee5265871cf4", size = 265419, upload-time = "2026-09-09T13:53:33.943Z" },
{ url = "https://files.pythonhosted.org/packages/47/21/0eb8868982ff07c1a2faaef7502ee0be32ef247dc1bf27881c51e7f4b20d/multidict-6.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2ba9933e8f35fe4a70f540b837254c4055da82dc3a9e500a8f95e61498083a15", size = 258934, upload-time = "2026-09-09T13:53:35.662Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a6/0586396716faf950c10ffbe733e4a57b4eeda9f7073e60c09bd4a05a766e/multidict-6.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:68d40b2bace413f3231f5729d3fcfb1837fd31c4907e241b5d43211bfd76f3c2", size = 273168, upload-time = "2026-09-09T13:53:37.131Z" },
{ url = "https://files.pythonhosted.org/packages/31/79/7197af20190d0d832be3b18582be46c224f64f5ba1cd35d32068d6af31ed/multidict-6.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a9e246f67ac038568b854ed7c5578e4c6af1f742359901a8fcc3603ff1358df6", size = 275884, upload-time = "2026-09-09T13:53:38.579Z" },
{ url = "https://files.pythonhosted.org/packages/f7/f7/af60573e25ffecc09e805464580d579338cf1a44332ab52757038435ed60/multidict-6.8.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:29b6e7bc4442a56cf8e0dc1cabf3fdc77cd533568d6829fc76a1effd2ce332ec", size = 246967, upload-time = "2026-09-09T13:53:40.172Z" },
{ url = "https://files.pythonhosted.org/packages/6b/02/4459f8c5025ab034d3d9af9a34bbde11319016cff2f327362c8ec43a80a1/multidict-6.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:82780eb8bf59e8fb25dd081fde6e058805045d6374a7f2f877effc826ca4434b", size = 272358, upload-time = "2026-09-09T13:53:41.868Z" },
{ url = "https://files.pythonhosted.org/packages/51/99/680d3522ab51a77094d31d7958c9f5989499a01ffbe5aebe11d25212b385/multidict-6.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2196ba6df392c3574acadd14ef87550f3611349c8618564de324b806a7a31cee", size = 267450, upload-time = "2026-09-09T13:53:43.646Z" },
{ url = "https://files.pythonhosted.org/packages/4a/04/d0c773805b0aea171287b01a82e4c28c59ef2c2d93e8047394765181363f/multidict-6.8.0-cp311-cp311-win32.whl", hash = "sha256:b8b7aa75146266fd3e2a2437cf69ae188688c04ab8665b163d4257b46c1e0c83", size = 46847, upload-time = "2026-09-09T13:53:45.161Z" },
{ url = "https://files.pythonhosted.org/packages/71/f8/1a959771a4dcd3224bd7bb40054f66b98ba5b20d6b74fd273f548f887e0a/multidict-6.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:b03ca066b47b18b205cc080dca6f76cbd159f8cdd33a02a0700164c13b37e463", size = 51549, upload-time = "2026-09-09T13:53:46.448Z" },
{ url = "https://files.pythonhosted.org/packages/64/7c/3a74b11599a9d8f3cfbb78b9c5cac3ff3cdc17278e4c00329c7c18dcaff9/multidict-6.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:54af1266710cb0f305127ae0b970aff8d208057f8a29cd6e1db99b0114947035", size = 48020, upload-time = "2026-09-09T13:53:47.767Z" },
{ url = "https://files.pythonhosted.org/packages/13/83/a4621577679149ea001806f5963f3fc687c391c1bd5217157be2278863f5/multidict-6.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:22a310ad37672a261e55a8b5e28d0ae08cfb68abb1f46418ccd19835c3b8e836", size = 84146, upload-time = "2026-09-09T13:53:49.163Z" },
{ url = "https://files.pythonhosted.org/packages/09/00/236b063f3e606055a3a9ba8faa5d40e6c688b059a58056b055f213476f46/multidict-6.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bb8c7da8c861391f7ae48e3593762be2dabe405109e01aec520fbe1a6d15d14b", size = 51049, upload-time = "2026-09-09T13:53:50.46Z" },
{ url = "https://files.pythonhosted.org/packages/91/9d/954b139bfa969855f2d4cb5ae7b7d44dd7106f754305b6e21a9068213aa7/multidict-6.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10456943903744ae1249728161c96bd9d2f7eb5ee17fcc2ffda2dc32e1bb36c7", size = 49362, upload-time = "2026-09-09T13:53:51.878Z" },
@@ -498,95 +439,10 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b1/ee/be4e1a4b7a2b27f4fb6936510d4bebcb41b0562c946930ad26916e069cf9/multidict-6.8.0-py3-none-any.whl", hash = "sha256:75daa15ca16d6285eb2e104b2f05ee6f8d9836c68da3ce5c85f615a0450eed0e", size = 16297, upload-time = "2026-09-09T13:57:56.106Z" },
]
[[package]]
name = "numpy"
version = "2.4.6"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.12'",
]
sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" },
{ url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" },
{ url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" },
{ url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" },
{ url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" },
{ url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" },
{ url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" },
{ url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" },
{ url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" },
{ url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" },
{ url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" },
{ url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" },
{ url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" },
{ url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" },
{ url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" },
{ url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" },
{ url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" },
{ url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" },
{ url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" },
{ url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" },
{ url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" },
{ url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" },
{ url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" },
{ url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" },
{ url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" },
{ url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" },
{ url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" },
{ url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" },
{ url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" },
{ url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" },
{ url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" },
{ url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" },
{ url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" },
{ url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" },
{ url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" },
{ url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" },
{ url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" },
{ url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" },
{ url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" },
{ url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" },
{ url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" },
{ url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" },
{ url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" },
{ url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" },
{ url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" },
{ url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" },
{ url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" },
{ url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" },
{ url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" },
{ url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" },
{ url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" },
{ url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" },
{ url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" },
{ url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" },
{ url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" },
{ url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" },
{ url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" },
{ url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" },
{ url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" },
{ url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" },
{ url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" },
{ url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" },
{ url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" },
{ url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" },
{ url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" },
{ url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" },
{ url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" },
]
[[package]]
name = "numpy"
version = "2.5.3"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.12'",
]
sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" },
@@ -680,23 +536,6 @@ version = "0.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b3/9a/9fbf4e4ec0c2d7f1c32519fff782ef467859b8faa9fbc5331a96f6395d43/propcache-0.5.4.tar.gz", hash = "sha256:ff6b113f50bc066a698db5d944d2c6dc7507168dd3341e255a8892fd0715a558", size = 61545, upload-time = "2026-09-16T00:17:14.386Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/40/14b21e505b7921617466576423f188a5c9caddfdaa1cf4b2b8a83d8fe216/propcache-0.5.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:897d1ddf6716e8f47200f7aad9a0efa6cc7586df66c6defa572f9eab379c078e", size = 86393, upload-time = "2026-09-16T00:14:06.9Z" },
{ url = "https://files.pythonhosted.org/packages/e7/4b/5a52e1a7b43563f7d408814194bb23cc8bf214eb6b86639b667a33a8d0d0/propcache-0.5.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9cbfff4423eef4cc6cafc021469641a2b835f610b2647a6c5281903e21b8670d", size = 50431, upload-time = "2026-09-16T00:14:08.025Z" },
{ url = "https://files.pythonhosted.org/packages/05/cf/b5248180bf056cc76acc60c9c6e8c0ebbfdbd1c6cffd31fd14996927b7c8/propcache-0.5.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3fc24f209c1b7f7f688b66b98293954f5504279760999b58920ee12dd8471c1d", size = 52116, upload-time = "2026-09-16T00:14:09.114Z" },
{ url = "https://files.pythonhosted.org/packages/86/a8/7c6cd6bfead1a11f2e411e688640e6d26574cb0bde7dcaa7423b0b65ed7a/propcache-0.5.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62530ca89187827e4a4fe733f971abe81a7542eeea48ff61995f19b64d7199c8", size = 238729, upload-time = "2026-09-16T00:14:10.357Z" },
{ url = "https://files.pythonhosted.org/packages/5c/b4/442715b2e980df51be52d203549279e027728f24c80b00b5e525e31cd5ea/propcache-0.5.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fc3f7599528db40b1efa0889a620116e2704144495273d66066e8164e45838", size = 246121, upload-time = "2026-09-16T00:14:11.735Z" },
{ url = "https://files.pythonhosted.org/packages/bc/5d/df0684fc2b1732a01a7bec26d7897369022712422d25b09c37ce7dbc88a2/propcache-0.5.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f2d880ff60f45898f4acfa152aac8d04e3ee627d90ff4003491bf92239d5757", size = 251735, upload-time = "2026-09-16T00:14:13.053Z" },
{ url = "https://files.pythonhosted.org/packages/c7/06/519a5ebb48b6f94beb48396e55c905f12246a25c3a3608a7ec7bceabf50e/propcache-0.5.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e9368e87a3efc285e559131092c5db643eb8e56de4ee42064d5baec22ef2bb5", size = 235381, upload-time = "2026-09-16T00:14:14.398Z" },
{ url = "https://files.pythonhosted.org/packages/3c/07/1e0a9bb310830f2245edbd5cd3c6d24a783c053c4efd8e08e386e513c940/propcache-0.5.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:004e685b315646c410771836e72a44f143bbe624f29653a42687815069a303d5", size = 208973, upload-time = "2026-09-16T00:14:15.715Z" },
{ url = "https://files.pythonhosted.org/packages/62/5c/9324fab27d6088eecc47fe4332bf7aaf8c1ded93c36f558391e8a06d41a7/propcache-0.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:594eb4c6ec35e7179b058481f4e9f02521b56de16fa577c4b85c76fb1bf8a9f8", size = 233897, upload-time = "2026-09-16T00:14:17.25Z" },
{ url = "https://files.pythonhosted.org/packages/9c/a3/570d92fc952eae93b676f3a1568f4b89264102abd3c982ab6a9ebec58dcf/propcache-0.5.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2dba2f02d2d5c09ef8a0e6c1a42aeaa451f4be9898cb00b04fe98717da2eb23b", size = 223512, upload-time = "2026-09-16T00:14:18.87Z" },
{ url = "https://files.pythonhosted.org/packages/65/47/26810d889d89bba31db397e6a88f8984af775f5ed6bad0a29dce84324cff/propcache-0.5.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c3ef2818d63bc86071e9d2989ae75a1bc32b8f7059cfd9f5abbbee70c32e2ed6", size = 239043, upload-time = "2026-09-16T00:14:20.366Z" },
{ url = "https://files.pythonhosted.org/packages/89/2d/f9c47691aa024c8299a3afacd78d22a01ab57eb627b481b6089708e71017/propcache-0.5.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:dd2ac8f5b643454c2cc6b6118b13da16e88f4a6434fc3ba61aca384029f04f36", size = 208218, upload-time = "2026-09-16T00:14:21.801Z" },
{ url = "https://files.pythonhosted.org/packages/74/6b/d510c0c378cabbf9d0ac7b663af6d00f2e9074073d93b20c85f24aa5c071/propcache-0.5.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4054acf80d40456a0537f2913b349718649d8d6458a14ab7f48d0ce28c30869d", size = 240301, upload-time = "2026-09-16T00:14:23.121Z" },
{ url = "https://files.pythonhosted.org/packages/3d/80/c80f6adaaa1e51f0db2dce8c9b3714d94ec45e358a21f9a1910b10b40a80/propcache-0.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:40e94adb1e7d39ff28a8bd8d8b8fbd1df6b9f40976dbe379134f1ce058e532dd", size = 230785, upload-time = "2026-09-16T00:14:24.458Z" },
{ url = "https://files.pythonhosted.org/packages/d9/e2/c32a7df3f39caa7f11b2eb37ea5b6960a6946f2bc7c4b8ff97bbdf6d6b6e/propcache-0.5.4-cp311-cp311-win32.whl", hash = "sha256:9f86f7259efe2c951f43e57d471c9b41daa5bfc7db9f67189059cf1ae6d77fd9", size = 42747, upload-time = "2026-09-16T00:14:25.715Z" },
{ url = "https://files.pythonhosted.org/packages/0a/8a/3db6a3543d8101263b4c52978b6276a04ead2caff2c5ab880d934f47bd89/propcache-0.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:e904d4d01f36bd6e197590be1533c44e06058771e0746dd073a8ebb3ef880858", size = 46268, upload-time = "2026-09-16T00:14:26.996Z" },
{ url = "https://files.pythonhosted.org/packages/41/07/5222e2665bbf6e45847492ecbf3b9f3e4975a0ae300e5fd465df7d48ce55/propcache-0.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:d42a9a856a4a6e2f6c10f1318c07e7daa498d6593abe745c71dae4521a26ca39", size = 43547, upload-time = "2026-09-16T00:14:28.143Z" },
{ url = "https://files.pythonhosted.org/packages/71/cd/348d58f142aebc4873345c6b31087629182ca6e0f2b3caeaa528cf882eba/propcache-0.5.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b28f41fa3b8c6900457f858ec5b03998f3a6d535fbc1bb2edec5961ea05ec429", size = 87285, upload-time = "2026-09-16T00:14:29.362Z" },
{ url = "https://files.pythonhosted.org/packages/df/f4/f3ffaee281b276da854ac1d7a6a506d26cbc62ea2e623756f1d0a4a1ba1a/propcache-0.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dcbf346a318a5e30063f547630b02bb787ce2f45b6368d5da143660b6a3835d8", size = 50984, upload-time = "2026-09-16T00:14:30.473Z" },
{ url = "https://files.pythonhosted.org/packages/25/88/1d7df7201750b37765ef2b23bc1c526c028dadde80afa0f57a118fc01182/propcache-0.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87a3caecf8095e48dc72f84bfa42e23a848cf410cc9cc13031fba4869b706a21", size = 52460, upload-time = "2026-09-16T00:14:31.692Z" },
@@ -808,13 +647,6 @@ version = "25.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3d/e3/27f57f80141379d60defe6703eb50a707325706f07fedfd1312c7a751995/pyarrow-25.0.1.tar.gz", hash = "sha256:9150a83248bfed9813ea3c3af74c3856c1984d444aa28e58bf7733b9750ddf6a", size = 1201653, upload-time = "2026-08-10T12:40:53.904Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/8b/0d23b47702fcfe8b3618d5292035099675c5a1c48258932350c08020f7b5/pyarrow-25.0.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:51093dd9e10325fbdb3c10a2ae7c4806e5c822d94e74ae4938b26524a3323fee", size = 35946180, upload-time = "2026-08-10T12:37:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/d8/17/707d17a5476c55a9541fde0db8213ac30979a792864d72415f176ba50c45/pyarrow-25.0.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:eb6203482ff3746a5632303a7279ae0b5a304c46985b49ed1378cb350ea6728d", size = 37644787, upload-time = "2026-08-10T12:37:25.795Z" },
{ url = "https://files.pythonhosted.org/packages/c1/b2/cdc98ecf1a6408280bc3a6a07054cdd99a3f4670acc0545d383ce113e87d/pyarrow-25.0.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:880523be3d29efcf83d3998835d206118ccf35e3871dbd2fb60408cf6b007a80", size = 46834633, upload-time = "2026-08-10T12:37:33.604Z" },
{ url = "https://files.pythonhosted.org/packages/c8/6e/d3fafc41f378b2c65be43b827798c0fae42049a641c8526633ed3eb573e2/pyarrow-25.0.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:25f8720bf6387d5dc2ebd2622112de630760419e4b66134405dd24110d15f37e", size = 50065507, upload-time = "2026-08-10T12:37:40.565Z" },
{ url = "https://files.pythonhosted.org/packages/d5/12/8d0698954b8c3001844a898e0a6900bebe83d7ee40c11195174c5122f324/pyarrow-25.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4facd65742a024a4a366328a1d2292062d72d6e023c1b7dda8d4c37544933a25", size = 49955690, upload-time = "2026-08-10T12:37:46.644Z" },
{ url = "https://files.pythonhosted.org/packages/d3/0b/1ecb936ac6409e90a34d58eea1c7cec09a9ae6d2141b9e49ad01a2b1ea47/pyarrow-25.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aa0559502e1cd6254d6814614085dd9c5a3dd0419362978a936a3f68a9e5c3df", size = 53128198, upload-time = "2026-08-10T12:37:52.531Z" },
{ url = "https://files.pythonhosted.org/packages/8e/1c/5236033550633c9b7377b2a53660b2bbb06cb06dc09c4356332d67643ca1/pyarrow-25.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:62cd0d785b8aa6675ee355f9fc02252a340f4441257c42674937826fd7594325", size = 27857263, upload-time = "2026-08-10T12:37:56.943Z" },
{ url = "https://files.pythonhosted.org/packages/a6/e2/9ab15b88cbfac28e16419ce5439ec29234c5172cb8259301b4ba639bdec0/pyarrow-25.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:df961f2e7ae9cf496459259d798652c70625f6c080650d6952f8c04053c58ee9", size = 35861559, upload-time = "2026-08-10T12:38:02.567Z" },
{ url = "https://files.pythonhosted.org/packages/58/79/a0036dbe1eabe1f73127427342f1d99982584c4a2cde2651d6c93499c6f6/pyarrow-25.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:cc4aa407fde9fc660be3939e49ea31f50f3e9fec17c0ec63159f7711edd3efc9", size = 37628383, upload-time = "2026-08-10T12:38:09.083Z" },
{ url = "https://files.pythonhosted.org/packages/13/49/d93a57d375f4bf0cf82913dd6bb54acafde83dd993be2282c81ac5616cad/pyarrow-25.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:4340f0ba6c1d2e13f21658de1d7c662ca2545018568d0030a1e9afca159d87e3", size = 46820190, upload-time = "2026-08-10T12:38:15.458Z" },
@@ -876,15 +708,6 @@ version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
@@ -970,23 +793,6 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/75/16/e8be8e2fb175bbf41a0680381a319f1199fae256588241a2ac8677eafb49/yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7", size = 246245, upload-time = "2026-09-15T19:35:02.264Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/b3/2cea721d495ca57f8f414aa4867ee263f486b274e07463158cf52f02ba7f/yarl-1.25.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9d693bf4bf534e9ba3ae2780cfd577f5135629f7b5ac653490859d0b77864865", size = 143794, upload-time = "2026-09-15T19:30:26.946Z" },
{ url = "https://files.pythonhosted.org/packages/55/e6/cd145cff8e5cf60b8b3c41fbecfa2a45028a8dec3fbc52bec03595ff3d3b/yarl-1.25.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ab2054c5531af2a9ba7b69b8ec91e4f884420e83a8c5e579b013084cb57e5e5d", size = 103993, upload-time = "2026-09-15T19:30:28.661Z" },
{ url = "https://files.pythonhosted.org/packages/ce/7c/fbb40fe2d53747c40aa36a9e2bd2178a202f942bda0b670f3306d4aefbce/yarl-1.25.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:564fdc7085d2245ab84f88882fdb1d6ac0723124bff6ded35bfb1c00f812630d", size = 104010, upload-time = "2026-09-15T19:30:31.069Z" },
{ url = "https://files.pythonhosted.org/packages/f0/0b/5a516f70641092283f57cf3670bdb75e7327bcc0dcb5038697e4dfbfd569/yarl-1.25.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acae6b45d1ace09b6ba3876da43b88366ef368f73b988c7f57e14231753d4420", size = 116444, upload-time = "2026-09-15T19:30:32.894Z" },
{ url = "https://files.pythonhosted.org/packages/b6/8a/d1a627f827b0a404ae0f5647cab0534959081cde13b0756a783469fb3b5e/yarl-1.25.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1fb2a01ba8cd9c5d2c5dc1ec35e0fc951d04b4f037541d4ac090c993ce58b3d7", size = 107565, upload-time = "2026-09-15T19:30:35.188Z" },
{ url = "https://files.pythonhosted.org/packages/aa/cf/c8e0aaec886840a6c4480fe44eaec7cd4319f79a563b79321473be79c56f/yarl-1.25.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e92b6bcc741b86d67606c40d3cb9c7cc8e6c737f81e31f4a94efc204456c92e3", size = 125006, upload-time = "2026-09-15T19:30:37.1Z" },
{ url = "https://files.pythonhosted.org/packages/50/26/0cce366d54a93cdc8342965dc7663385e4db161625cc1e8b18e786753d24/yarl-1.25.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:72c34ac7ad4314c19362d5ce27626dcc8429bd30bbf8c179f4234078851f9492", size = 128717, upload-time = "2026-09-15T19:30:38.904Z" },
{ url = "https://files.pythonhosted.org/packages/95/0c/a71501bbc1a674ff72c4d6c2b75f4d9a5af819f5244c3a7558080a8802c5/yarl-1.25.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5add7b4ca7afeea91d52e4d4e4db3b1fe9885b71f07054560d8c4296b7441a2", size = 117728, upload-time = "2026-09-15T19:30:40.809Z" },
{ url = "https://files.pythonhosted.org/packages/e7/6f/c3267ca01defeed9ed9c4ff9b17bd54915432c405945233265b707475d1c/yarl-1.25.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:def538065f9e4d4cf1ae164bd59aba00dfa84f03923e0de4c3788f252d6bcd17", size = 116224, upload-time = "2026-09-15T19:30:42.818Z" },
{ url = "https://files.pythonhosted.org/packages/8f/69/fad57ee52d648431718ee0f1f68966a99c1352c3924688ffbdcc9d3fe51a/yarl-1.25.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a191bfdb30a79b98e5d175d75285f9fcb78bf0e46ba5efda042e1c72071a0de", size = 116299, upload-time = "2026-09-15T19:30:44.966Z" },
{ url = "https://files.pythonhosted.org/packages/2a/d4/6a8c1e29f33338687ca278cba0a8fbf6525a322c2c02a9a500ccbe041152/yarl-1.25.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:71f42c5b9a948c113bbdebfa544598321431d064ff959d32e99b1feb61d68345", size = 108625, upload-time = "2026-09-15T19:30:46.874Z" },
{ url = "https://files.pythonhosted.org/packages/e8/d2/3a35ae791c9cb6522c106923ff25c3230d999091e5e65511bca23bbd9914/yarl-1.25.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:72849d892954be4d09e569b8b831ac39ce58417fedc767d4308a0fe542018a40", size = 124515, upload-time = "2026-09-15T19:30:49.082Z" },
{ url = "https://files.pythonhosted.org/packages/be/fd/2b022109a6b4af0f7dc371cf7500af380b0d4f034010243e1b0ce218dc93/yarl-1.25.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:efb01a106f971cb3752856bca2318bbdf7f01bd8823779c461586cbe5ffd5258", size = 115711, upload-time = "2026-09-15T19:30:51.208Z" },
{ url = "https://files.pythonhosted.org/packages/18/59/f7586271136c3ddb0126bbfe661844699369b76b555fe38c4efe86870b2e/yarl-1.25.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a1daf47cd95a7c3a63456336bc5aaa8c86dd3a47d07ed3d0e76132ae4666a5a1", size = 122751, upload-time = "2026-09-15T19:30:53.535Z" },
{ url = "https://files.pythonhosted.org/packages/a0/0b/07f7a2d881f7e16c385b47fc1753382600847cad305dcc7fa0c25828acf8/yarl-1.25.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9489e6abf47ba37f332075a91444c7cfedb03e6ce99fbb2f116bfe1ce810da3b", size = 117983, upload-time = "2026-09-15T19:30:55.277Z" },
{ url = "https://files.pythonhosted.org/packages/aa/9d/8cdceec66a9b940700cb45931741403f045b162afd79bcb93c41cadd0972/yarl-1.25.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7306dee25b8a0e737363f347362b875094b4dc4e367311470656ae420fdbf8e", size = 102894, upload-time = "2026-09-15T19:30:57.606Z" },
{ url = "https://files.pythonhosted.org/packages/17/f1/7ec357db1d3ad2863542d71e8fe64a126bbec17b134cd7d30951196809a5/yarl-1.25.1-cp311-cp311-win_arm64.whl", hash = "sha256:abb1384477f5901d436b5d2e5465954de46ea6098f59163d243660b5c4461d35", size = 98609, upload-time = "2026-09-15T19:30:59.705Z" },
{ url = "https://files.pythonhosted.org/packages/75/b3/cd32ac66ae622b854c2df0ac52106dda220d361b65a64fde7d5b3684aa3f/yarl-1.25.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d7aa6debf92a1dd14cb5280b083a764169a13cfb23a452111160274ed989f4", size = 144798, upload-time = "2026-09-15T19:31:01.821Z" },
{ url = "https://files.pythonhosted.org/packages/61/fb/a2c52a8007c2051ba74662afb112ecf3d00346af4c25e33df9d80fd14fb8/yarl-1.25.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:83d4a37e4b95da4d8bda930d6d35b75b4cdadbacbb4980cae290ea3100b5d51d", size = 104583, upload-time = "2026-09-15T19:31:04.05Z" },
{ url = "https://files.pythonhosted.org/packages/be/dd/ee38aec8e09fdf957e50d4085453fbe202f56c6c3b4cf07b81cdb4f09ee9/yarl-1.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e029648f9c951db30e98a7d7ec90835db88ec4b32820efe2a9bdc2287e032eb6", size = 104325, upload-time = "2026-09-15T19:31:06.338Z" },