From 1375b891d6956c5c7e69fe5c3fb991554b1d3a6e Mon Sep 17 00:00:00 2001 From: Kingston Date: Mon, 20 Apr 2026 14:42:54 +0800 Subject: [PATCH 01/17] Add pi0.5 subtask probe experiments Research scaffolding for the subtask probe: DROID eval pipeline (sample extraction, subtask generation, action eval, metrics, visualization), Comet-style hierarchical reasoner, ForeAct foreshadow reconstruction, dual-runtime benchmark, and FINDINGS.md. All paths cache under .experiments_cache/ (gitignored). Kept in a separate tree from src/hosting/ per the plan to land the production pieces on main without the experiments subtree. Co-Authored-By: Claude Opus 4.7 (1M context) --- experiments/subtask_probe/FINDINGS.md | 628 ++++++++++++++++++ experiments/subtask_probe/__init__.py | 0 experiments/subtask_probe/decode_jax.py | 320 +++++++++ .../subtask_probe/droid_eval/README.md | 154 +++++ .../subtask_probe/droid_eval/__init__.py | 0 .../droid_eval/comet_style/__init__.py | 9 + .../droid_eval/comet_style/_gemini_utils.py | 118 ++++ .../droid_eval/comet_style/gemini_reasoner.py | 85 +++ .../comet_style/openai_compat_reasoner.py | 95 +++ .../droid_eval/comet_style/reasoner_base.py | 470 +++++++++++++ .../droid_eval/comet_style/run.py | 328 +++++++++ .../droid_eval/compute_metrics.py | 372 +++++++++++ .../subtask_probe/droid_eval/constants.py | 32 + .../droid_eval/extract_droid_samples.py | 403 +++++++++++ .../droid_eval/foreact_eval/README.md | 80 +++ .../droid_eval/foreact_eval/__init__.py | 6 + .../foreact_eval/generate_foresight.py | 257 +++++++ .../generate_foresight_lerobot.py | 281 ++++++++ .../foreact_eval/generate_subtasks.py | 278 ++++++++ .../droid_eval/foreact_eval/planner.py | 259 ++++++++ .../foreact_eval/visualize_foreact.py | 217 ++++++ .../droid_eval/generate_subtasks.py | 243 +++++++ .../droid_eval/generate_subtasks_gemini.py | 283 ++++++++ .../droid_eval/run_action_eval.py | 146 ++++ experiments/subtask_probe/droid_eval/utils.py | 151 +++++ .../droid_eval/visualize_results.py | 502 ++++++++++++++ .../droid_eval/visualize_subtasks.py | 278 ++++++++ .../subtask_probe/dual_runtime_benchmark.py | 371 +++++++++++ 28 files changed, 6366 insertions(+) create mode 100644 experiments/subtask_probe/FINDINGS.md create mode 100644 experiments/subtask_probe/__init__.py create mode 100644 experiments/subtask_probe/decode_jax.py create mode 100644 experiments/subtask_probe/droid_eval/README.md create mode 100644 experiments/subtask_probe/droid_eval/__init__.py create mode 100644 experiments/subtask_probe/droid_eval/comet_style/__init__.py create mode 100644 experiments/subtask_probe/droid_eval/comet_style/_gemini_utils.py create mode 100644 experiments/subtask_probe/droid_eval/comet_style/gemini_reasoner.py create mode 100644 experiments/subtask_probe/droid_eval/comet_style/openai_compat_reasoner.py create mode 100644 experiments/subtask_probe/droid_eval/comet_style/reasoner_base.py create mode 100644 experiments/subtask_probe/droid_eval/comet_style/run.py create mode 100644 experiments/subtask_probe/droid_eval/compute_metrics.py create mode 100644 experiments/subtask_probe/droid_eval/constants.py create mode 100644 experiments/subtask_probe/droid_eval/extract_droid_samples.py create mode 100644 experiments/subtask_probe/droid_eval/foreact_eval/README.md create mode 100644 experiments/subtask_probe/droid_eval/foreact_eval/__init__.py create mode 100644 experiments/subtask_probe/droid_eval/foreact_eval/generate_foresight.py create mode 100644 experiments/subtask_probe/droid_eval/foreact_eval/generate_foresight_lerobot.py create mode 100644 experiments/subtask_probe/droid_eval/foreact_eval/generate_subtasks.py create mode 100644 experiments/subtask_probe/droid_eval/foreact_eval/planner.py create mode 100644 experiments/subtask_probe/droid_eval/foreact_eval/visualize_foreact.py create mode 100644 experiments/subtask_probe/droid_eval/generate_subtasks.py create mode 100644 experiments/subtask_probe/droid_eval/generate_subtasks_gemini.py create mode 100644 experiments/subtask_probe/droid_eval/run_action_eval.py create mode 100644 experiments/subtask_probe/droid_eval/utils.py create mode 100644 experiments/subtask_probe/droid_eval/visualize_results.py create mode 100644 experiments/subtask_probe/droid_eval/visualize_subtasks.py create mode 100644 experiments/subtask_probe/dual_runtime_benchmark.py diff --git a/experiments/subtask_probe/FINDINGS.md b/experiments/subtask_probe/FINDINGS.md new file mode 100644 index 0000000..c699df8 --- /dev/null +++ b/experiments/subtask_probe/FINDINGS.md @@ -0,0 +1,628 @@ +# pi0.5 Subtask Generation Probe: Findings + +## Core Result + +**The public pi0.5 base checkpoint contains working subtask text generation capability.** Using JAX with the correct prompt format (`"Task: {task}. Subtask: "`), the model generates coherent English subtask text with high confidence (60-87% top-1 probability). + +| Prompt | Generated Subtask | Confidence | +|---|---|---| +| "pick up the red cup and place it on the shelf" | "pick up cup" | 78% | +| "fold the towel neatly" | "fold the towel" | 87% | +| "open the drawer and put the block inside" | "pull out the drawer" | 34% | +| "stack the blue block on top of the red block" | "pick up the paper" | 57% | +| "wipe the table with the sponge" | "wipe the spill" | 61% | + +No retraining needed. No new head needed. The existing `embed_tokens.weight` matrix serves as the lm_head via weight tying (`dot(hidden_state, embed_tokens.T) -> vocab logits`). + +## What Makes It Work + +Two things were required, both non-obvious: + +1. **Prompt format**: `"Task: {task}. Subtask: "` -- NOT the action format `"Task: {task}, State: {state};\nAction: "`. The model has distinct modes triggered by the prompt suffix. The paper describes this conceptually but never specifies the exact format string. + +2. **Autoregressive decoding loop**: Embed prefix -> forward through PaliGemma backbone -> KV cache -> project last hidden state to vocab via `dot(h, embed_table.T)` -> take argmax -> embed that token -> forward with KV cache -> repeat. + +## What Doesn't Work + +### PyTorch path is broken for autoregressive text generation + +The PyTorch model (via HuggingFace transformers) gets the first token approximately right but degrades to Unicode garbage on the second token onwards. We tried: + +- **v1-v2**: Wrong prompt format (`"Action: "` suffix). Got Unicode attractors (ⓙ, ⤙, Ẁ, etc.) +- **v3**: Correct prompt format + proper AR loop through `GemmaModel.forward()`. First token correct ("put" at 43%), second token garbage. Cause: HuggingFace's `create_causal_mask` intermediary in `GemmaModel.forward()` creates a causal mask that conflicts with the bidirectional prefix attention. +- **v3 + 2D mask fix**: Passing 2D padding masks instead of 4D float masks. Improved first token confidence but didn't fix continuation. +- **v4**: Bypassed `GemmaModel.forward()` entirely, manually iterating through decoder layers with custom 4D masks. Same result: first token OK, continuation broken. + +**Root cause**: The PyTorch and JAX implementations produce different hidden states from the same input. The first token prediction differs between them ("put" vs "pick" for the same prompt), and continuation diverges completely. + +**Confirmed NOT the weights**: We tested with lerobot/pi05_base (independently converted by HuggingFace team, fp32) and our openpi conversion (bf16). Both produce identical results. Weight values are bitwise identical between the two checkpoints (max diff = 0.0 across all tested keys). Both are straight JAX→PyTorch conversions with uniform precision (no selective mixed precision). The `to_bfloat16_for_selected_params` step in our Modal conversion script is a deployment optimization for `torch.compile` stability, not part of the upstream conversion. + +**Root cause identified**: Side-by-side JAX vs PyTorch comparison revealed: +- A minimal 3-token prefix (no images): AR step cos_sim = **0.999** -- nearly identical. KV cache works correctly. +- Full 968-token prefix (768 image + 200 language): AR step cos_sim = **0.30** -- complete divergence. + +The KV cache mechanism is correct. The problem is that the **prefix forward** already puts slightly different values into the cache (cos_sim 0.998 per position). With 968 positions of slightly-wrong cached key/value vectors, the attention scores during the AR step accumulate these errors and produce a completely different weighted sum. This is a numerical amplification issue: a 0.2% per-position error in the prefix becomes a 70% error in the AR step's attention output over 968 cached positions. + +The prefix error likely comes from the image embedding pipeline (SigLIP implementation differences between JAX Flax and PyTorch HuggingFace) or from attention precision differences (the JAX code uses float32 for attention logits while PyTorch may use bfloat16 in some paths). + +### JAX works, PyTorch doesn't + +All successful subtask generation implementations in the community use JAX: +- @BrunoFANG1 (openpi#701) -- JAX, partial subtask text from base checkpoint +- @LisavilaLee (openpi_with_subtask fork) -- JAX, full implementation with position fix +- Our probe -- JAX works, PyTorch doesn't + +## Architecture + +pi0.5 is a two-expert transformer: +- **PaliGemma backbone** (Gemma-2B + SigLIP): processes images + text, dim 2048 +- **Action expert** (Gemma-300M): processes noisy actions + timestep, dim 1024 +- Both share attention through 18 layers (fused Q/K/V) +- **Action output**: `action_out_proj: Linear(1024, action_dim)` on the action expert hidden states +- **Text output**: `dot(paligemma_hidden, embed_tokens.T)` on the backbone hidden states (weight-tied lm_head) + +The model has two modes selected by prompt format: +- `"...; Action: "` → action generation (flow matching, iterative denoising) +- `"... Subtask: "` → text generation (autoregressive, standard LM decoding) + +## Paper Context + +The pi0.5 paper describes two training stages: +1. **Pre-training**: Discrete tokens, web data, subtask prediction (HL data), cross-embodiment data. This is where the subtask text capability comes from. +2. **Post-training**: Adds the action expert, flow matching for continuous actions, specializes for mobile manipulation. + +The public checkpoint includes the action expert, so it has been through post-training. The subtask capability persists but generates short sequences (3-4 tokens) before hitting EOS with dummy images, likely because: +- Post-training may have partially degraded the LM capability +- Dummy/zero images provide no visual context for the model to describe +- The exact prompt format may not match what PI used internally + +Community reports indicate ~100 gradient steps of LM fine-tuning on subtask data produces full-quality subtask text. + +## Files + +| File | Purpose | +|---|---| +| `decode_jax.py` | **Working** JAX subtask generation probe | +| `hybrid_prompt_experiment.py` | JAX subtask → PyTorch action, three prompt variants | +| `dual_runtime_benchmark.py` | JAX + PyTorch coexistence on single GPU, latency benchmarks | +| `compare_jax_pytorch.py` | Side-by-side JAX vs PyTorch hidden state comparison (root cause diagnosis) | +| `latency_profile.py` | Latency breakdown and JIT optimization tests (WIP) | + +## How to Run (JAX, working) + +```bash +# On L40S (Seoul instance): +ssh ubuntu@43.200.36.250 + +# Ensure JAX checkpoint is downloaded +cd ~/openpi +source $HOME/.local/bin/env +uv run python -c "from openpi.shared import download; download.maybe_download('gs://openpi-assets/checkpoints/pi05_base')" + +# Run the probe +uv run python experiments/subtask_probe/decode_jax.py +``` + +## Hybrid Prompt Experiment (2026-04-14) + +### Question + +The pi0.5 action prompt format (`"Task: X, State: S;\nAction: "`) is completely different from the subtask prompt format (`"Task: X. Subtask: "`). Can we inject JAX-generated subtask text into the action prompt without retraining? + +### Method + +Generated subtasks via JAX (Phase 1), then compared PyTorch action outputs across three prompt variants (Phase 2): + +1. **Baseline**: `"Task: X, State: S;\nAction: "` (standard, no subtask) +2. **Hybrid A**: `"Task: X. Subtask: Y, State: S;\nAction: "` (subtask injected before state) +3. **Hybrid B**: `"Task: X (Y), State: S;\nAction: "` (subtask in parentheses) + +Used zero images, zero state, fixed random seed. Both models loaded sequentially on a single L40S (JAX freed before PyTorch loaded via `XLA_PYTHON_CLIENT_PREALLOCATE=false`). + +### Results + +| Task | Subtask (JAX) | Baseline vs Hybrid A | Baseline vs Hybrid B | +|---|---|---|---| +| pick up red cup → shelf | "put the blue cup in the bin" | cos=0.36, L2=94% | cos=-0.19, L2=135% | +| fold the towel neatly | "fold the towel" | cos=0.34, L2=265% | cos=0.26, L2=168% | +| open drawer, put block | (garbage — zero images) | cos=-0.06, L2=205% | cos=-0.11, L2=130% | +| wipe table with sponge | "1No" (degraded) | cos=-0.24, L2=679% | cos=0.43, L2=130% | + +### Interpretation + +1. **The model IS conditioning on subtask text.** Cosine similarities of 0.36 and -0.06 mean completely different action trajectories — not noise, but a different policy output. +2. **Prompt format matters.** Hybrid A ≠ Hybrid B, confirming the model parses the text structure, not just bag-of-words. +3. **Cannot assess quality with zero images.** All actions are meaningless without visual context. The experiment proves the *mechanism* works (text changes actions) but not whether it produces *better* actions. Need real robot images. +4. **Subtask quality degrades with zero images.** 2/4 prompts produced garbage subtasks. Real images should fix this (the model needs visual context to describe what it sees). + +### Two-Phase Inference Architecture (from paper) + +The pi0.5 paper (Section V.E, Figure 3, Figure 7) describes: + +``` +Phase 1 — Subtask generation (autoregressive text, PaliGemma backbone): + Prompt: "Task: clean the bedroom. Subtask: " + images + state + → AR decode → "pick up pillow" + +Phase 2 — Action generation (flow matching, action expert): + Prompt: "Task: clean the bedroom. Subtask: pick up pillow" + images + state + → 10 denoising steps → action chunk [a_t:t+H] +``` + +Key detail: the prompt formats are **different modes** of the same model: +- `"Task: X. Subtask: "` triggers autoregressive text generation (PaliGemma LM head) +- `"Task: X, State: S;\nAction: "` triggers flow-matching action generation (action expert) + +@LisavilaLee's implementation (`build_full_observation`) splices generated subtask tokens into the padding of the subtask-format prompt, then runs `sample_actions` on that. This means the action step uses `"Task: X. Subtask: Y"` as its prompt — NOT the standard `"Task: X, State: S;\nAction: "` format. This requires ~100 gradient steps of fine-tuning to teach the model to produce actions from the subtask prompt format. + +Our hybrid approach (injecting subtask text into the standard action format) is an alternative that avoids retraining, but the quality is unvalidated — needs real images to assess. + +### DROID Evaluation with Real Images (2026-04-16) + +Ran the full eval pipeline against 10 DROID episodes (276 frames) using the deployed two-phase server (Seoul, g6e.2xlarge). Subtask generation uses the base JAX checkpoint (pi05_base); action generation uses the DROID PyTorch checkpoint (swatery/pi05_droid_base). + +**Prompt format comparison (Hybrid A vs Hybrid B):** + +Tested two ways to inject subtask text into the action prompt: +- Hybrid A: `"{instruction}. Subtask: {subtask}"` (closer to pre-training format) +- Hybrid B: `"{instruction} ({subtask})"` (parenthetical) + +Results were statistically indistinguishable (Wilcoxon p=0.89). Both improved over baseline similarly. **Conclusion: the format doesn't matter, only the presence of subtask text.** Going forward, we use only Hybrid A (now called "subtask" condition) since it's closer to the pre-training prompt format. + +**Noise control was critical:** + +Initial results showed no significant difference (p=0.96) between baseline and subtask conditions. Investigation revealed that each server request gets independent random noise in the flow matching denoising loop. The noise-induced L2 variance (~0.76) was 76x larger than the prompt effect (~0.01), completely drowning the signal. After adding a `seed` field to the obs dict that sets `torch.manual_seed()` before denoising, all 3 conditions for the same frame get identical noise. With controlled noise: **p=0.025** (significant), subtask is closer to ground truth 55% of the time. + +**DROID checkpoint cannot generate subtask text:** + +Tested `gs://openpi-assets/checkpoints/pi05_droid` for subtask generation — produces empty strings (immediate EOS) on all 276 frames. The DROID fine-tuning completely destroyed the LM head's text generation capability. The base checkpoint (pi05_base) is the correct choice for the subtask planner. This confirms the paper's note that post-training degrades the subtask capability. + +**Image format issues discovered and fixed:** + +Three bugs prevented the server from processing DROID images correctly: + +1. **Camera name mapping**: The subtask generator expects `base_0_rgb`/`left_wrist_0_rgb` but clients send embodiment-specific names (`cam_high` for ALOHA, `observation/exterior_image_1_left` for DROID). Fixed: auto-detect embodiment from key names. + +2. **Image normalization**: The subtask generator's `_build_subtask_observation` expected float32 [-1,1] but clients send uint8 [0,255]. No normalization was applied. Fixed: added `_normalize_image()` that handles uint8→float32, CHW→HWC, and [0,1]→[-1,1] automatically. + +3. **Aspect ratio distortion**: DROID images are 180x320 (16:9). The extraction script resized to 224x224 with plain `tf.image.resize`, squishing the images. The model's own `preprocess_observation` uses `resize_with_pad` which preserves aspect ratio by adding black padding. Fixed: store original dimensions, let the server's preprocessing handle resize. + +These fixes improved diversity (228 unique subtasks across 276 frames, up from identical outputs), but **Unicode garbage persists in ~51% of frames** (141/276). Quality is highly variable by episode — ep_0005 produces 100% valid English, while ep_0004 and ep_0007 produce 0%. Examples of garbage: `셍踯≎𝟻셍ᔑ毟⢱Ꮸ𨨏ѱ`, `শՔ䭈⠤ǎƞᇃḡᵐ䁱჻ັ` (Korean, CJK, math symbols, emoji, Cyrillic mixed together). + +**Root cause**: The base checkpoint's LM head was degraded by post-training. The logit distribution is flattened — non-English tokens (CJK, Korean, etc.) sometimes receive higher probability than correct English tokens. Greedy argmax picks whatever is highest, regardless of language. + +**Fix: ASCII vocabulary masking.** Before argmax at each decode step, set logits for all non-ASCII tokens to -inf. This forces generation from English-only tokens. This is a standard industry technique — the same approach as vLLM's `allowed_token_ids`, HuggingFace's `LogitsProcessor`, and OpenAI's `logit_bias` API parameter. Implemented in `SubtaskGenerator._build_ascii_vocab_mask()`. The mask is built once at init (scanning all 257K PaliGemma vocabulary tokens) and applied as a `jnp.where` before every argmax — zero runtime cost, deterministic, JIT-compatible. + +### Dual Runtime Coexistence Test (2026-04-14) + +Confirmed both JAX and PyTorch models loaded simultaneously on a single L40S using `XLA_PYTHON_CLIENT_MEM_FRACTION=0.5`: + +| Resource | Usage | +|---|---| +| JAX (subtask model) | 6.4 GB VRAM / 22.7 GB limit | +| PyTorch (action model) | 7.1 GB VRAM | +| **Total GPU** | **~13.6 GB / 46 GB** | +| JAX subtask latency (first call, JIT) | ~64s | +| JAX subtask latency (warm, eager) | **~14s** | +| PyTorch action latency | **~280ms** | +| Total two-phase (warm) | **~14.2s** | + +Memory is not the bottleneck — 13.6GB out of 46GB leaves plenty of headroom. A bigger instance is unnecessary for memory. + +**The latency bottleneck is JAX eager-mode AR decoding (~14s warm).** The breakdown is: +- Prefix forward (SigLIP image encoding + 18 transformer layers over 968 tokens → KV cache): majority of time +- AR loop (3-5 token generations with growing KV cache): each step retraces because KV cache shape changes + +Attempted to profile the exact breakdown with JIT optimization tests but the profiling script was OOM-killed on system RAM (32GB). The 30GB system RAM may be tight when both JAX and PyTorch runtimes plus XLA compilation buffers are active. This is a system RAM constraint, not GPU VRAM. + +**Latency reduction options (untested, for next session):** +1. **JIT-compile the prefix forward** — fixed shape, should compile well. The AR loop is harder because KV cache grows per step. +2. **Pre-allocate KV cache** to max size (prefix + max_gen_tokens) and use `jax.lax.while_loop` for fully JIT AR generation. +3. **Cache subtasks aggressively** — subtask only needs regeneration when the visual scene changes significantly, not every action cycle. At 14s per subtask, caching is essential. +4. **Larger system RAM** — profiling was killed at 32GB. A g6e.2xlarge (64GB RAM, same L40S GPU) would allow JIT compilation without OOM. + +### Hosting Architecture + +Both runtimes coexist on a single L40S (48GB VRAM) with `XLA_PYTHON_CLIENT_MEM_FRACTION=0.5`. Two separate instances are NOT needed for memory reasons. However: + +- **Single instance, two processes** is viable if latency can be reduced to <1s via JIT compilation +- **Two instances** only makes sense if system RAM (32GB on g6e.xlarge) is the bottleneck for JIT compilation, since the profiling script OOMed — a single g6e.2xlarge (64GB RAM) would be cheaper than two g6e.xlarge instances +- The two-phase inference is opaque behind QUIC — client sends `{task, images, state}`, gets back `{actions}` + +The subtask refresh rate is a design choice. The paper's Figure 7 shows subtask predictions changing frame-by-frame as the scene evolves, suggesting periodic re-generation (not just once per task). Given the 14s latency, aggressive caching is necessary until JIT optimization is done. + +## Checkpoint Conversion Notes + +### JAX → PyTorch conversion pipeline + +The stock openpi conversion script (`examples/convert_jax_model_to_pytorch.py`) does a **straight conversion** — uniform precision (float32 or bfloat16), no selective mixed precision. This is what the HuggingFace/LeRobot team used to produce `lerobot/pi05_base`. + +Our Modal conversion script (`convert_checkpoint_modal.py`) adds an extra post-conversion step: `to_bfloat16_for_selected_params()`, which keeps layernorms, vision patch embeddings, and position embeddings in float32 while converting everything else to bfloat16. This is a **deployment optimization for `torch.compile` stability** (prevents fp32/bf16 matmul crashes), not a correctness requirement. Standard inference without `torch.compile` works fine with uniform precision. + +### DROID checkpoint conversion (completed 2026-04-16) + +Converted `gs://openpi-assets/checkpoints/pi05_droid` → PyTorch on the Seoul g6e.2xlarge instance using the stock openpi conversion script. Uploaded to HuggingFace: **`swatery/pi05_droid_base`** + +```bash +uv run python examples/convert_jax_model_to_pytorch.py \ + --checkpoint_dir ~/.cache/openpi/openpi-assets/checkpoints/pi05_droid \ + --output_path ~/.cache/openpi/openpi-assets/checkpoints/pi05_droid_pytorch \ + --config_name pi05_droid \ + --precision bfloat16 +``` + +Output: `model.safetensors` (6.8GB, bfloat16), `config.json` (action_horizon=15, pi05=True). No assets directory was copied (DROID norm stats are in a separate location). + +Previous Modal attempt (2026-04-14) hit import bugs and client disconnect issues — running directly on AWS was simpler. + +## Next Steps + +### Validate with real robot images (DROID) + +3. **Download DROID dataset samples** — need actual robot camera frames to test subtask generation and action quality. DROID provides multi-camera observations with task labels, matching pi0.5's expected input format. + +4. **Test subtask generation with real images using DROID checkpoint** — feed actual robot workspace images into the JAX subtask generator with the DROID-finetuned checkpoint. Expect longer, more specific subtask text (vs. the 3-4 token outputs with zero images on the base checkpoint). + +5. **Test hybrid prompt action quality with real images** — compare the baseline (no subtask) vs. hybrid (with subtask) action outputs on real images. If the subtask-conditioned actions are measurably different AND more semantically aligned with the task, the hybrid approach works without retraining. + +### Fix PyTorch AR generation + +6. **Force float32 for the entire prefix forward** instead of bfloat16. The JAX code computes attention logits and RMSNorm variance in float32 but the PyTorch path may use bfloat16 in some places. Eliminating precision loss in the prefix would reduce per-position error and may prevent the amplification. Quick test -- just set `model.to(torch.float32)` before the prefix forward. + +7. **Audit SigLIP image embedding differences** between JAX (`openpi/src/openpi/models/siglip.py`) and HuggingFace's SigLIP implementation. The 768 image tokens contribute the most cached positions and are likely the largest source of error. + +8. **Audit attention precision paths**. The JAX gemma.py explicitly does `jnp.einsum(..., preferred_element_type=jnp.float32)` for attention logits. The PyTorch `eager_attention_forward` may not enforce float32 for the QK matmul. + +### Production integration + +9. **Two-process serving architecture** — JAX subtask service (port 8001) + PyTorch action service (port 8000) behind the existing QUIC endpoint. The action service calls the subtask service on localhost before each action generation cycle. + +10. **Subtask caching and refresh policy** — decide how often to re-generate subtasks. Options: every action chunk, every N steps, or when visual change exceeds a threshold. @LisavilaLee's code caches by prompt only (never refreshes), but the paper's Figure 7 shows it should update with the scene. + +### Future optimization: Flash attention for prefix forward + +The prefix forward processes ~968 tokens through 18 transformer layers. Each layer computes self-attention via explicit einsums in `gemma.py`: + +```python +logits = jnp.einsum("BTKGH,BSKH->BKGTS", q, k) # materializes full 968×968 matrix in HBM +probs = jax.nn.softmax(masked_logits, axis=-1) +encoded = jnp.einsum("BKGTS,BSKH->BTKGH", probs, v) +``` + +`jax.nn.dot_product_attention(q, k, v, is_causal=True, implementation='cudnn')` would replace all three ops with a single fused kernel that tiles the computation in GPU SRAM instead of materializing the full attention matrix in HBM. It handles GQA natively (Q `[B, T, 8, H]` against K/V `[B, T, 1, H]`). + +**Estimated impact**: Attention is ~40-60% of each layer's compute. Flash attention typically gives 2-3x speedup on the attention kernel. For the prefix forward (~1s JIT'd), this could save 200-400ms. For AR decode steps (~5ms each, query is single token), the benefit is negligible. + +**Blockers**: Requires modifying `gemma.py` (upstream openpi code shared by all models). Would need a compelling reason — the 200-400ms saving on prefix is nice but not critical given the larger wins from JIT-compiling the decode loop. Also requires cuDNN availability (L40S has it, but needs correct CUDA/cuDNN versions). Changing the `implementation` parameter requires recompilation (different XLA graph), so it's a deploy-time choice, not runtime-switchable. + +**Alternative approaches considered**: +- **Flax NNX `MultiHeadAttention` with `decode=True`**: Has built-in pre-allocated KV cache with `dynamic_update_slice` and `init_cache()`. Would enable `jax.lax.while_loop` for AR decode. But requires replacing Gemma's custom attention module — same upstream modification concern. +- **`chex.dataclass`**: Registers dataclasses as JAX pytrees. Would clean up a `DecodeState` carry struct if we used `while_loop`, but the JIT-unrolled approach doesn't need one. +- **`chex.assert_shape()`**: JIT-compatible shape validation. Nice for development but doesn't affect performance. + +## Comparison with openpi_with_subtask Fork (2026-04-17) + +Deep comparison of our `SubtaskGenerator` against @LisavilaLee's `openpi_with_subtask` fork to understand why our DROID eval produces nonsense subtasks. + +**Finding: the subtask generation code is functionally identical.** Same prompt format, same cleaning logic (lowercase, strip punctuation), same greedy argmax decoding, same SigLIP image encoding, same KV cache approach. There is no hidden inference-time trick in the fork. + +The fork's quality advantage comes entirely from ~100 gradient steps of fine-tuning with: +1. `token_ar_mask` — causal attention on subtask tokens, bidirectional on prefix +2. `token_loss_mask` — CE loss applied only to subtask portion, not prefix +3. Identity subtask training (`high_prompt = low_prompt = prompt`) + +One additional difference affects action quality (not subtask text quality): the fork's `build_full_observation()` splices generated subtask TOKEN IDs directly back into the padded prompt region, maintaining exact token fidelity. Our approach converts tokens→text→re-tokenizes as a new string, which can produce different token boundaries. + +### ASCII Vocabulary Masking (2026-04-17) + +**Problem:** ~51% of DROID eval frames produce Unicode garbage (Korean, CJK, math symbols, emoji, Cyrillic). The base checkpoint's degraded LM head assigns higher probability to non-English tokens than correct English tokens on many inputs. + +**Solution:** ASCII vocabulary masking — before argmax at each decode step, set logits for all non-ASCII tokens to `-inf`. Implemented in `SubtaskGenerator._build_ascii_vocab_mask()`: + +1. At init: scan all 257K PaliGemma vocabulary tokens via SentencePiece `id_to_piece()` +2. Mark token as valid if its piece (with `▁` treated as space) is fully ASCII +3. Store as `jnp.array` boolean mask — embedded as XLA constant in JIT-compiled graph +4. Before every argmax: `logits = jnp.where(valid_vocab_mask, logits, -1e9)` + +**Industry context:** This is the same technique as: +- **vLLM** `allowed_token_ids` — restrict sampling to a set of token IDs +- **HuggingFace** `LogitsProcessor` — arbitrary logit manipulation before sampling +- **OpenAI API** `logit_bias` — per-token logit adjustments +- **Constrained decoding** (`outlines`, `guidance`, `lm-format-enforcer`) — enforce regex/grammar on output (more powerful but heavier; these libraries are PyTorch-only, not compatible with our JAX backend) + +**Properties:** Zero runtime cost, deterministic, JIT-compatible. Does not change the model — only filters the output vocabulary at decode time. + +**Status:** Implemented, awaiting re-run of DROID eval to measure impact. Expect Unicode garbage rate to drop from ~51% to ~0%. English subtask quality should be unchanged since the mask only removes non-ASCII tokens — the correct English token was always in the distribution, just sometimes ranked below a non-English token. + +## Subtask Prompt-Format Sweep (2026-04-17) + +Ran all 276 DROID frames against 4 subtask-generation prompt formats on the Seoul g6e.2xlarge, swapped at runtime via the admin HTTP endpoint (`PATCH /config` with `subtask_prompt_format`). Results below use a stricter "usable" metric: printable ASCII only (`str.isprintable() and str.isascii()`), since the earlier `isascii()`-only vocab mask let control characters `\x16`, `\x19`, `\x1d` through. + +| Format | Prompt | Printable usable | Unique | Mean chars | Most common output | +|---|---|---|---|---|---| +| **default** | `Task: {task}. Subtask: ` | 166/276 (60%) | 89 | 23.1 | `'move the pan'` (9×) | +| raw | `{task}` | 13/276 (5%) | 6 | 20.1 | mostly empty | +| numbered | `Task: {task}.\n` | 169/276 (61%) | 87 | 31.4 | `'No, move the arms home'` (19×) | +| listprime | `Task: {task}. Subtask: 1` | 233/276 (84%) | 69 | 19.3 | `'No progress'` (32×) | + +### Decision: keep the default format + +Although `listprime` has higher printable-output coverage (84% vs 60%), its outputs skew toward self-critique phrases: 72/233 are variants of `'No progress'`, `'No movement'`, `'No significant movement'`, `'No skill'`. These aren't subtasks — they're the model describing the action history. Coverage went up, usefulness went down. + +`default` still produces the cleanest *imperative subtasks* when it works — `'pick up lid'`, `'wipe the spill'`, `'open the drawer'`, `'move the pan'`. This matches the pre-training format the paper describes. The 38% control-char garbage it produces is a *mask* problem, not a *prompt* problem, and is solved separately below. + +`raw` (bare instruction, no suffix cue) is effectively broken — the model EOSes immediately. Confirms the hypothesis that the `"Subtask: "` / newline suffix is a required mode-selector for AR text generation. + +`numbered` produces longer outputs than default but drifts into state descriptions ("No, the blue ring is in the gripper and then place the blue ring in the basket") rather than next-action subtasks. + +### Vocab mask tightening: printable + ASCII + +The existing `_build_ascii_vocab_mask` used `piece.isascii()`, which admits control characters (0x00–0x1F, 0x7F). These are legitimately ASCII but not text. After tightening to `piece.isascii() and piece.isprintable()`, the mask excludes those tokens at decode time, eliminating the control-char garbage class that accounted for 38% of "default" outputs. + +`str.isprintable()` is the right stdlib primitive here — it excludes control chars while keeping letters, digits, spaces, and punctuation. No extra dependency needed. For broader language filtering (e.g., "is this really English?") the industry-standard options are `langdetect`, `lingua-py`, or `fasttext` with `lid.176.bin`, but those are post-hoc heuristics; vocab masking at decode time is strictly cheaper and deterministic. + +### Bugs fixed during the sweep + +Two bugs in the admin-endpoint deploy were blocking the test and had to be fixed before any prompt format could run: + +1. **`serve.py:210`** was initializing `RuntimeConfig.subtask_prompt_format` from `SubtaskConfig.prompt_template` — but those are different strings. `prompt_template` is the *action* prompt format (`"{task}. Subtask: {subtask}"`) and contains `{subtask}`, which the subtask tokenizer's `.format(task=...)` call then raised `KeyError: 'subtask'` on. Seoul had been in a container crash-loop since the admin-endpoint deploy went out. Fix: use `RuntimeConfig()` with its default. + +2. **`admin_server.py:to_dict()`** used `dataclasses.asdict()`, which deep-copies every field recursively — including `_lock: threading.Lock`, which can't be pickled. Every `GET /config` and `PATCH /config` call returned an empty reply and the server logged a pickle error. Fix: build the dict manually via `fields(self)`, skipping underscore-prefixed fields. + +### Deployment config fix + +The admin endpoint was only reachable during the sweep because of a one-off `docker run -p 127.0.0.1:8001:8001` invocation. Terraform's `user_data.yaml.tftpl` only published `8000:8000` and `5555:5555/udp` — on a fresh cloud-init (stop→start or full redeploy) the admin port would have been lost. + +Added `-p 127.0.0.1:8001:8001` to `infra/modules/regional_inference_instance/templates/user_data.yaml.tftpl`. Bound to `127.0.0.1` (localhost on the host), not `0.0.0.0` — the admin endpoint has no auth and should never be internet-reachable. Operators reach it via SSH port forwarding: `ssh -L 8001:127.0.0.1:8001 ubuntu@` then `curl localhost:8001/config` from their laptop. No security-group change needed since the port isn't exposed publicly. + +## DROID duration distribution (2026-04-18) + +Measured on 3000 successful DROID v1.0.1 episodes streamed sequentially from `gs://gresearch/robotics/droid/1.0.1`: + +| stat | steps | seconds @15 Hz | +|---|---|---| +| mean | 305 | 20.3 | +| p50 | 234 | 15.6 | +| p95 | 792 | 52.8 | +| p99 | 1222 | 81.5 | +| max | 2324 | 154.9 | + +Only ~0.1% of episodes hit 2 min and most of the longest ones have empty language instructions. Naive "first N episodes with duration ≥ 120s" returns zero matches in the first 2000 scanned. + +**Selection strategy used for the long-horizon eval**: top-K longest with filters, implemented in `extract_droid_samples.py` via `--scan_episodes`, `--min_duration_s`, `--require_multi_step`. Scan 5000 episodes, reject empty instructions, floor at 60s, require the multi-step keyword heuristic (`pick…place`, `and then`, `put…in`, etc.), keep the 5 longest in an in-memory min-heap. Deterministic ~10-min GCS scan on a cloud box, guarantees we exercise the long-horizon regime where subtask conditioning is hypothesized to help. + +## Comet-Style Hierarchical Subtask Generation (2026-04-18) + +Experiment: test the `plan → critique → subtask` scaffold from **openpi-comet** (`src/openpi/shared/client.py`) on our long-horizon DROID cache, using two off-the-shelf VLM backends. Code: `experiments/subtask_probe/droid_eval/comet_style/`. + +### Paper vs. code: what we're actually testing + +Comet's **paper** (arxiv 2512.10071v3) reports their 0.3453 Q-score from π₀.₅ + RFT + expanded pre-training (§4.1-4.3). It **does not describe any reasoner/planner VLM**. The `client.py` plan/critique/subtask loop is activated only when `fine_grained_level > 0` (`eval_b1k_wrapper.py:59`), a training-data knob the paper doesn't ablate. Their released checkpoints (`pi05-b1kpt12-cs32`, `pi05-b1kpt50-cs32`) are `fine_grained_level=0`. §5 lists "more structured long-horizon reasoning" as **future work**. + +Conclusion: **the scaffold is exploratory code that wasn't part of their reported result.** We're not replicating a paper claim; we're testing the scaffold's idea on DROID with our own VLM backends. This is also why the scaffold's default reasoner endpoint (`b5k2m9x7-pre-exp011-043-32000.xenon.lepton.run`) is dead and the default model name (`Qwen3-VL-30B-A3B-Instruct`) is just a kwarg default never actually called. + +### Scaffold architecture + +Backend-agnostic `BaseReasoner` in `comet_style/reasoner_base.py` with two backends: +- **Gemini** (`gemini_reasoner.py`): `gemini-robotics-er-1.6-preview` via `google-genai`, with 120s request timeout and retry-on-transient-network-error (not just 429). +- **OpenAI-compatible** (`openai_compat_reasoner.py`): any vLLM-hosted VLM — we ran `Qwen3-VL-30B-A3B-Instruct` FP8-quantized on a g6e.2xlarge (L40S 48GB, 64GB RAM) in us-west-2. + +### Structured output is load-bearing + +Off-the-shelf VLMs do not reliably emit structured output for Comet's prompts out of the box. Initial runs showed: +- `generate_plan`: Gemini emitted a prose paragraph, Qwen-8B emitted a markdown numbered list. Neither parsed as JSON → fell back to a single-step plan = the global task verbatim. +- `generate_subtask`: Gemini Robotics-ER returned ~150-word reasoning paragraphs; Qwen-8B echoed the global task. +- `plan_critique`: free-form prose, wording varied every call, triggered the `if updated != plan_status` reset on every frame — effectively resetting `subtask_history` constantly, losing the hierarchical structure. + +Fix: enforce a schema on **all three** VLM calls via the backend's native structured-output API (Gemini `response_schema` / vLLM `response_format=json_schema`). Three schemas in `reasoner_base.py`: +- `PLAN_SCHEMA`: `{"type": "array", "items": {"type": "string"}, "minItems": 2, "maxItems": 10}` +- `SUBTASK_SCHEMA`: `{"type": "object", "properties": {"subtask": {"type": "string", "maxLength": 120}}}` +- `CRITIQUE_SCHEMA`: `{"type": "object", "properties": {"statuses": {"type": "array", "items": {"type": "string", "enum": ["done", "in_progress", "not_started"]}}}}` + +Refactored state: `plans: list[str]` + `plan_statuses: list[PlanStepStatus]` (parallel lists, canonical), `plan_status: str` becomes a derived property. The reset-on-change gate now compares status lists structurally, not prose strings. + +### Structured critique: 7× speedup, clean progression + +| Variant | Mean latency/replan | Unique subtasks (111-frame ep) | Plan progression | +|---|---|---|---| +| Qwen-30B + free-form critique | 5.74 s | 21 | Stuck 65 frames on "locate/search/scan" | +| Qwen-30B + **structured critique** | **0.81 s** | **5** | Clean monotonic: move → grasp → move to dish → place | + +Latency drop is output-token count: free-form critique emitted ~300-500-token paragraphs, structured critique emits ~5-15 tokens (enum list). No change in input, no change in model. + +### History stride matters (and Comet's hardcoded 5 is correct) + +`sample_images` walks the image history with stride=5 by default (Comet's original value, tuned for their 30 Hz sim buffer). With our 1 Hz cache (`--frame_subsample=15`), we assumed stride=1 would be better ("just give the model consecutive frames"). Tested both on the full 5-episode run: + +| Episode | Task | stride=5 transitions | stride=1 transitions | +|---|---|---|---| +| ep_0000 | cube + dish (simple) | **8** | 80 | +| ep_0001 | multi-step kitchen | 24 | **8** | +| ep_0002 | turnip plushie + duster | 90 | 82 (semantic oscillation, see below) | +| ep_0003 | cloths + markers | **4** | 8 | +| ep_0004 | bottle + blind | **4** | 5 | + +**stride=5 wins on 3/5 episodes and draws on 1.** The original intuition ("stride=5 is over-sparse for 1 Hz cache") was wrong: when history has ≥40 entries, stride=5 gives 8 images spanning ~40 seconds, providing the **temporal-contrast signal** the reasoner needs to detect progression ("40 s ago arm was here, now it's there"). stride=1 gives 8 consecutive seconds — too narrow a window for slow manipulation tasks, where the model over-interprets frame-to-frame motion. + +Default kept at `--history_stride=5`. + +### Two-call design: intentional, not wasteful + +Every replan fires two VLM calls (`plan_critique` + subtask-selection). We considered merging into one call with a `{statuses, subtask}` schema (halves cost/latency). The **load-bearing reason** to keep them separate (documented in `BaseReasoner` docstring): + +1. The subtask prompt is built from the **post-critique** `plan_status`. Merged calls force the model to emit both fields in one generation with pre-critique context — fine for reasoning models, risks inconsistent outputs on non-reasoning models (e.g. Gemini Robotics-ER at `thinking_budget=0`). +2. The `subtask_history` reset gate runs between the two calls. If the critique changed `plan_statuses`, `last_subtask` in the subtask prompt becomes `"None"` — merged calls can't apply this reset mid-generation. +3. Graceful degradation: a malformed critique keeps the old statuses and still runs subtask; a malformed merged call loses both. + +Merged-call mode is a reasonable follow-up if API cost ever becomes the bottleneck. + +### DROID frames continue past task completion + +~55% of frames in a cache-selected long-horizon episode are **post-task** — the operator retracted the arm, adjusted, or just held position until the fixed recording window closed. This dilutes Phase 2 L2 metrics (we're measuring whether pi0.5 predicts operator-idle behavior, not task execution). Added `all_steps_done(plan_statuses)` short-circuit so the reasoner skips VLM calls once every plan step is marked `done`, reusing the last real subtask. Pending a Phase 2 split-metrics run that reports pre-completion vs. post-completion separately. + +### Multi-object tasks produce semantic oscillation, not a bug + +ep_0002 (`"Place the turnip plushie on the table, then the duster on the box..."`) shows the reasoner flipping between `"place the turnip plushie on the table"` and `"place the duster on the box"` every 15 cached frames, regardless of stride. Not a scaffold bug: the task genuinely has two parallel placement sub-goals and each frame's "active step" depends on which object is more visually salient. Real finding worth reporting in the write-up — Comet's scaffold assumes strictly sequential plan steps, which doesn't fit every task structure. + +### Action-horizon alignment: the cache was wrong + +The previous 10-step-subsampled cache (`.experiments_cache/droid_eval_2min`, 1.5 Hz) was not aligned with pi0.5's `action_horizon=15`. Per-frame behavior-cloning eval with misaligned cache means every cached frame is a "fresh state reset," not a closed-loop decision point. Re-extracted with `--frame_subsample=15` → `.experiments_cache/droid_eval_ah15` (5 episodes, 475 frames total, 1 Hz, 1 cached frame = 1 action horizon of real time). + +### Serving infrastructure + +- **Local runs** (`run.py` from laptop): Gemini backend. ~0.8 s/replan, but WAN latency amplifies — the initial Gemini Comet run was killed at 50-min hang due to a `Server disconnected` not caught by the 429-only retry. Retry logic broadened to cover transient 5xx/disconnect errors; request timeout hard-capped at 120 s per call. +- **Remote runs** (on the vLLM host itself): Qwen backend. Runs the CLI directly on US West 2 (code + cache rsynced to `~/comet_eval/`), hits `http://localhost:8000/v1` — no SSH tunnel fragility. +- **US West 2 instance** resized from `g6e.xlarge` (32 GB RAM) to `g6e.2xlarge` (64 GB) + EBS grown from 100 GB to 200 GB (online `modify-volume` + `growpart` + `resize2fs`) to host Qwen-30B FP8 weights (~30 GB VRAM, ~58 GB disk). scratch.md updated. + +### Visualization + +`visualize_subtasks.py` renders both camera views (exterior + wrist) side-by-side with per-frame subtask text: +- **HTML** (default): self-contained page per episode, scrollable table with exterior/wrist thumbnails + subtask column. +- **Video** (`--video`): per-episode mp4 at 2 fps (matches cache rate), composite 640×180 frame with EXTERIOR/WRIST labels and a subtask banner. + +Used for inspecting plan progression without running the full Phase 2 action eval — much faster feedback loop during scaffold iteration. Bug caught through this: `visualize_subtasks` previously only rendered the exterior camera, hiding the gripper state that's visible only in the wrist view. + +### Status: Phase 2 not yet run + +The full Qwen-30B run on `.experiments_cache/droid_eval_ah15` (475 frames, structured critique, stride=5) is complete and saved to `subtasks_comet_qwen30b.json`. Phase 2 (action eval) and Phase 3 (metrics) against Seoul's pi0.5 action server are the next step — pending. + +## ForeAct release audit (2026-04-18) + +Before starting the ForeAct reconstruction on DROID, we read the paper (arxiv 2602.12322) and walked through the released code at `/Users/kkuan/openpi/foreact/` to pin down what we can actually reuse. Two load-bearing findings, recorded here because they scope everything downstream. + +### Finding 1 — "No architectural modification" ≠ "no training" + +The paper claims (§3.4) that ForeAct "requires no architectural modifications" to the VLA. Verified in code: pi0.5's image list is built dynamically at runtime by filtering the observation dict against `self.config.image_features` (`foreact/third-party/lerobot/src/lerobot/policies/pi05/modeling_pi05.py:1143-1150`). Adding a foresight image slot is just adding a new key to the dataset and the config — no new layers, no param changes, no architecture shim. That claim is literally true. + +But the public pi0.5 base checkpoint was pretrained with 2 images (`exterior`, `wrist`). Registering a 3rd key makes it an input but produces no useful behavior: the action head has no learned pathway from that feature slot to actions. ForeAct's recipe `foreact/third-party/lerobot/scripts/run_sub_task_100k.sh` fine-tunes from `lerobot/pi05_base` for 100k steps on `ForeAct_VLA_Dataset` — that fine-tune is what builds the pathway. "No architectural modification" is accurate; "plug-and-play at inference time" it is not. + +Implication for us: feeding foresight to our pi0.5 action server zero-shot is blocked at two independent layers. + +1. *Server-side*: `src/hosting/warmup.py:121-132` and the Rust QUIC sidecar hardcode a 2-image spec (`observation/exterior_image_1_left`, `observation/wrist_image_left`). A 3rd key is silently dropped before reaching the policy. +2. *Weights-side*: even if we bypassed serving and set `image_features` to include a foresight key, `_preprocess_images` would encode it but the action head has no learned attention/gating for it. Either silently ignored or actively harmful. + +So end-to-end action eval with foresight is out of scope for an inference-only reconstruction. Only fine-tuning pi0.5 on the augmented input (the paper's depth-C recipe) gets there. + +### Finding 2 — Only the foresight generator is released, not a fine-tuned VLA + +`mit-han-lab/foreact-pretrained` on HuggingFace contains exactly: +- 3 safetensors shards (10.2 GB total, ~5B params) +- `config.json` (941 B) +- `model.safetensors.index.json` (100 kB) +- No `vla/` or `pi0/` subfolder + +The 5B param count cleanly adds up to Sana-1600M (1.6B) + Gemma-2-2B-IT (2B) + DC-AE VAE — i.e. π_g only. The HF tag `visualforesight` and the `-pretrained` suffix (= cross-embodiment pretraining stage, *before* target-robot fine-tune) both confirm this. The `mit-han-lab` HF account has only one `foreact-*` repo; other pi0.5 checkpoints listed there (e.g. `vlash-pi05-libero-async5`) belong to different projects. + +The VLA training script `run_sub_task_100k.sh` starts from the *public* `lerobot/pi05_base` and produces a Galaxea-R1-Lite-specific fine-tune locally. That output is never uploaded. The `mit-han-lab/ForeActDataset` release contains the raw Galaxea episodes (subtask-segmented) but not a DROID-flavored preprocessing pipeline. + +Why it's not actually strange: the generator is robot-agnostic (pretrained on 10M cross-embodiment pairs across AgiBot / RoboMind / Galaxea / Bridge), useful to anyone with any robot. The fine-tuned VLA is Galaxea-R1-Lite-specific — different camera mount, different action space, different embodiment from DROID / LIBERO / anything else — so publishing it would have minimal downstream value. Standard pattern for robotics papers. + +Implication: even *with* training infra, reproducing ForeAct end-to-end on DROID would require running their fine-tune recipe ourselves on a DROID-flavored variant of `ForeAct_VLA_Dataset`, which they didn't release a preprocessing pipeline for (only the raw Galaxea episodes). + +### Scope of the reconstruction we're doing + +Given the above, the faithful-without-training reconstruction is: + +- **π_v planner** (their VLM subtask planner): fully faithful. Table 5 prompts verbatim, paper's `Qwen/Qwen3-VL-8B-Instruct` model. Only the harness is ours. +- **π_g generator** (their foresight image generator): faithful to the released `mit-han-lab/foreact-pretrained` checkpoint + `VisualForesightPipeline`. **Skip** the 5-epoch target-data fine-tune the paper always runs. DROID was deliberately excluded from π_g pretraining (§3.2) so this is genuinely zero-shot OOD — a setting the paper never evaluates. +- **VLA integration**: unreachable. Foresight images are outputs for human inspection only. No action eval. + +## ForeAct zero-shot reconstruction on DROID (2026-04-18) + +Faithful inference-only reconstruction of ForeAct's π_v (planner) + π_g (foresight generator) on our DROID cache. No fine-tuning — see the "ForeAct release audit" section above for why that scopes what's reachable. Code lives at `experiments/subtask_probe/droid_eval/foreact_eval/`. + +### Setup + +- Cache: `.experiments_cache/droid_eval_ah15/` (5 episodes, 475 frames @ ~1 Hz stride-15). +- Planner: paper's exact Table 5 two-turn prompts, `Qwen/Qwen3-VL-8B-Instruct` served by vLLM on US West 2 L40S (bf16, no quantization). This is the model the paper uses for both its VLM+π_0 baseline and ForeAct "Ours" in §4.3. +- Generator: `mit-han-lab/foreact-pretrained` checkpoint (5B params, 10.2GB) loaded in bf16 via the paper's `VisualForesightPipeline`. Paper's inference hparams: `guidance_scale=4.5`, `image_guidance_scale=1.5`, `num_inference_steps=8`. Runs in the foreact conda env on the same L40S. + +### Planner behavior — decomposition is the bottleneck + +Per-episode subtask counts from `subtasks_foreact_qwen8b.json`: + +| Episode | Instruction complexity | Unique subtasks | Transitions | Mean latency | +|---|---|---|---|---| +| ep_0000 | 1-step ("put cube in dish") | 6 | 28 | 0.73s | +| ep_0001 | 6-step (sink / cup / pan) | **1** | **0** | 0.97s | +| ep_0002 | 10-step (sort plushies) | **1** | **0** | 0.81s | +| ep_0003 | 2-step (cloths + markers) | 3 | 40 | 0.85s | +| ep_0004 | 2-step (bottle + blind) | 3 | 25 | 1.01s | + +For ep_0001 and ep_0002, the 8B planner emitted a single subtask for every single frame — the first sub-step of the instruction (ep_0001: "Take the straw out of the sink...", ep_0002: "Place the turnip plushie on the table"). It never advanced. For ep_0003 and ep_0004 it oscillated between echoing the full instruction and the first half. On the simplest task (ep_0000) it produced six near-synonyms of "pick up the cube" / "put the cube in the dish". + +The 8B model is clearly on the weaker end of the paper's VLM scaling analysis. Figure 13 reports Qwen3-VL-8B at 84.4% planning accuracy (σ=19.8) vs. Qwen2.5-VL-32B at 73.9% and Qwen2.5-VL-7B at 40.8%. Our DROID episodes pattern-match the long-horizon instructions where 7B failed and 32B was just acceptable. To get meaningful decomposition we'd likely need 32B+ — the paper's success numbers are on their own shorter-horizon Galaxea benchmark, not DROID-style free-form instructions. + +Secondary observation: the planner reported `previous_finished=True` on **0 of 475 frames**. We added this as an extra schema field for observability; the 8B model always returns `False` regardless of actual progress. Paper's prompt doesn't ask for this field, so the model has no training signal for it — fine, but the observability is lost. + +### Generator behavior — quality varies per episode, pretraining distribution-match matters + +The pretrained foresight generator produced 475 DROID-size PNGs at 0.48s mean latency on the L40S (claimed 0.33s on H100 — close enough for the architecture). + +No wholesale viewpoint hallucination — outputs don't snap to AgiBot/Galaxea scenes. But contrary to my first-pass "it's just an autoencoder" claim, the generator does produce *varying* predictions across frames. Quality and faithfulness to the subtask depend heavily on how close the DROID scene sits to the pretraining distribution: + +| Episode | Scene | Foresight quality | Notes | +|---|---|---|---| +| **ep_0001** | kitchen sink, overhead, dishes | plausible motion, **but hallucinates a phantom second arm** | Arm visibly reaches into the sink in a pose the current frame hasn't reached yet. But every foresight frame also shows a large dark blob on the right that's clearly a second end-effector — DROID is single-arm Franka. See explanation below. | +| ep_0003 | top-down lab, cloths + markers | moderate | Arm position shifts in roughly the right direction, some detail blur. | +| ep_0002 | dim side view, plushie sort | mixed | Arm motion visible but scene is dark and details smear; box sometimes drops out of the foresight. | +| ep_0000 | inverted lab shelf, cube + dish | **worst — heavy distortion** | Black blobs obscure the right half of many frames; occasional hallucinated extra cubes on the shelf. The ceiling-mounted / inverted camera orientation appears to be out-of-distribution. | +| ep_0004 | side view kitchen, bottle | near-identity | Foresight is nearly identical to the current frame; little motion predicted. | + +The pattern: the generator works best on scenes that visually resemble its pretraining data (kitchen/sink = ep_0001, similar to AgiBot-Beta). It degrades into artifacts on unusual camera orientations (ep_0000's inverted view) and collapses toward the identity map when the scene is too far from anything it was trained on (ep_0004). Subtask text has *some* effect on the motion trajectory but is easily dominated by the image-conditioning pathway in the weaker-distributional-match cases. + +**Embodiment bias — phantom second arm on ep_0001.** Every foresight frame in ep_0001 shows a large dark blob on the right side of the frame that's unmistakably a second bimanual end-effector, despite DROID being a single-arm Franka setup. This is a direct artifact of the pretraining composition (§3.2, Figure 4): AgiBot-World-Colosseo (947k subtasks, ~80% of pretraining volume) is bimanual (AgiBot-Beta), Galaxea Open-World is bimanual (Galaxea R1), and RoboMind includes bimanual ALOHA. Only Bridge (WidowX, single-arm) counters that prior, and it's a small slice. When the DROID scene pattern-matches an AgiBot-like sink/manipulation view, the generator's learned prior inserts the second arm that "should" be there in its training distribution. This is a clean illustration of why the paper always fine-tunes on target-embodiment data before reporting any number — pretraining alone bakes in the dominant embodiment prior. + +This mirrors the paper's §4.2 pretraining ablation but from the opposite side — they reported Fidelity=0.00 / Quality=0.00 OOD *without* pretraining; we see pretraining gets us to "mixed, episode-dependent" on DROID, but without the 5-epoch target-data fine-tune (`configs/finetune.yaml:24`) we're still far from consistent subtask-following. + +### Verdict: depth-C is a prerequisite for any real signal + +Both pieces ran without incident, but neither produces DROID signal that would improve pi0.5 even if we could route it into the action server: + +- **Planner**: the 8B choice from the paper is too small for DROID's long-horizon instructions. To reproduce the paper's planner quality on DROID we'd need at minimum Qwen3-VL-32B+. +- **Generator**: zero-shot quality is episode-dependent — decent on kitchen-sink-like scenes (ep_0001 looks genuinely promising), degenerate on inverted / unusual viewpoints (ep_0000, ep_0004). The paper's 5-epoch target fine-tune (`configs/finetune.yaml:24`) is load-bearing, not optional, for consistent subtask-following. + +So the depth-C path to a real replication has two training prerequisites before touching pi0.5 at all: +1. Fine-tune the foresight generator on DROID (or a DROID-flavored subtask-segmented dataset we'd need to construct). +2. Fine-tune pi0.5 via the paper's `run_sub_task_100k.sh` recipe to consume the third image slot. + +Each of those is a multi-day commitment. Punt unless the finding above is insufficient to kill this direction. + +### In-distribution sanity check on ForeActDataset / Galaxea R1 Lite (2026-04-19) + +To separate "the generator is broken" from "DROID is OOD for the generator", I ran the same pretrained checkpoint on 2 episodes from `mit-han-lab/ForeActDataset` (the paper's own Galaxea R1 Lite recordings, same robot family as one of the pretraining datasets). Same checkpoint, same inference hparams, same 1 Hz stride — only the input distribution changed. Adapter driver: `foreact_eval/generate_foresight_lerobot.py`. + +**Result: the generator clearly works here.** Across 12 foresight frames from episodes 0 ("Pick up the eggplant and place it into the plate") and 3 ("Pick up the corn and place it into the plate"): + +- **Subtask-conditioned motion**: Episode 0's foresight shows the eggplant being moved — no corn manipulation. Episode 3's foresight shows the yellow corn being picked up — no eggplant manipulation. The text conditioning actually steers the output, which was the missing signal on DROID. +- **Clean scene reconstruction**: no black-blob artifacts, no hallucinated extra vegetables, no viewpoint shift. All 5 veg items (leek, carrot, cucumber, corn, eggplant) + plate stay in their correct positions in frames where they shouldn't move. +- **Correct single-arm behavior**: the arm enters from the right at the correct angle for the Galaxea R1 Lite mounting. No phantom second arm like we saw on DROID ep_0001 — the embodiment prior matches the target scene, so it doesn't need to "pattern-complete" a missing arm. +- **Task progression**: across a single episode's strided frames, the arm visibly moves closer to the target object and the target object visibly shifts toward the plate. This is the subtask-end-state prediction the paper describes. + +Latency was 0.47-0.68s/frame on L40S (bf16), consistent with the DROID run. + +**Takeaway**: the pretrained checkpoint is doing its job. Everything we saw on DROID — near-identity autoencoding on some scenes, distortion/artifacts on inverted views, phantom bimanual arms on kitchen-sink scenes — is a distribution-mismatch problem, not a generator problem. With target-robot fine-tune (the paper's 5-epoch recipe), or even just running on robots in the pretraining pool, the generator produces genuinely useful foresight. Depth-C on DROID (fine-tune on DROID-flavored subtask data) would very likely recover this quality level. + +### Artifacts + +- `experiments/subtask_probe/droid_eval/foreact_eval/{planner,generate_subtasks,generate_foresight,generate_foresight_lerobot,visualize_foreact}.py` +- DROID zero-shot (OOD): + - `.experiments_cache/droid_eval_ah15/subtasks_foreact_qwen8b.json` (475 records) + - `.experiments_cache/droid_eval_ah15/foresight_foreact/{ep_0000..ep_0004}/frame_*.png` (475 PNGs @ 640×480) + - `.experiments_cache/droid_eval_ah15/foreact_html/*.html` (5 per-episode reports, exterior | wrist | foresight | subtask) + - `.experiments_cache/droid_eval_ah15/foreact_videos/*.mp4` (5 per-episode mp4s at 2 fps) +- ForeActDataset / Galaxea R1 Lite in-distribution: + - `.experiments_cache/droid_eval_ah15/foresight_picksveg/episode_{000000,000003}/frame_*.png` (12 PNGs) + `actual/` subdirs with source frames + - `.experiments_cache/droid_eval_ah15/foresight_picksveg/picksveg_report.html` (single side-by-side HTML) + +## Open Questions + +1. What is the exact prompt format PI used internally for subtask training? +2. Would real robot images produce longer, more specific subtask text? +3. Is the short output (3-4 tokens) an inherent limitation of the base checkpoint, or a prompt format / missing-images issue? +4. Does the ~100 gradient step fine-tuning need real robot data, or would LLM-generated subtask decompositions work? +5. How much does subtask conditioning improve action quality for long-horizon tasks (the paper reports it's significant)? +6. Can JIT compilation reduce JAX subtask latency from 14s to <1s? The prefix forward is fixed-shape and should JIT well, but the AR loop's growing KV cache is a challenge. +7. Is 32GB system RAM sufficient for dual-runtime JIT, or do we need g6e.2xlarge (64GB)? +8. For the hybrid prompt approach (no retraining), does injecting subtask text into the action format produce *better* actions with real images, or just *different* ones? +9. Does ASCII vocabulary masking eliminate Unicode garbage and preserve English subtask quality? (pending re-run) diff --git a/experiments/subtask_probe/__init__.py b/experiments/subtask_probe/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/experiments/subtask_probe/decode_jax.py b/experiments/subtask_probe/decode_jax.py new file mode 100644 index 0000000..07e6990 --- /dev/null +++ b/experiments/subtask_probe/decode_jax.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +"""JAX-based subtask generation probe for pi0.5. + +Uses the proven JAX code path (adapted from LisavilaLee/openpi_with_subtask +and BrunoFANG1's implementation referenced in Physical-Intelligence/openpi#701). + +The approach: + 1. Load the pi0.5 JAX model from the Orbax checkpoint + 2. Build a proper observation with images + prompt + 3. embed_prefix() -> forward through PaliGemma -> KV cache + 4. decode_to_logits (Embedder.decode = dot(h, embed_table.T)) + 5. Autoregressive token generation from last prefix position +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import jax +import jax.numpy as jnp +import numpy as np + +OPENPI_SRC = Path(__file__).resolve().parents[2] / "src" +sys.path.insert(0, str(OPENPI_SRC)) + +from openpi.models import model as _model # noqa: E402 +from openpi.models.pi0 import Pi0, make_attn_mask # noqa: E402 +from openpi.models.pi0_config import Pi0Config # noqa: E402 +from openpi.models.tokenizer import PaligemmaTokenizer # noqa: E402 + + +def _add_subtask_tokenizer_methods(tok: PaligemmaTokenizer) -> None: + """Monkey-patch the subtask tokenization methods onto the stock tokenizer.""" + import string + + def tokenize_high_level_prefix( + self: PaligemmaTokenizer, high_prompt: str + ) -> tuple[np.ndarray, np.ndarray]: + cleaned = high_prompt.lower().strip().replace("_", " ").replace("\n", " ") + if cleaned and cleaned[-1] in string.punctuation: + cleaned = cleaned[:-1] + prefix_str = f"Task: {cleaned}. Subtask: " + tokens = self._tokenizer.encode(prefix_str, add_bos=True) # ty: ignore[unresolved-attribute] + tokens_len = len(tokens) + if tokens_len < self._max_len: + pad_len = self._max_len - tokens_len + mask = [True] * tokens_len + [False] * pad_len + tokens = tokens + [0] * pad_len + else: + tokens = tokens[: self._max_len] + mask = [True] * self._max_len + return np.asarray(tokens, dtype=np.int32), np.asarray(mask, dtype=np.bool_) + + def detokenize(self: PaligemmaTokenizer, tokens: np.ndarray) -> str: + valid = [int(t) for t in tokens if t != 0 and t != 1] + return self._tokenizer.decode(valid) # ty: ignore[unresolved-attribute] + + # Bind methods + import types + + tok.tokenize_high_level_prefix = types.MethodType(tokenize_high_level_prefix, tok) # ty: ignore[unresolved-attribute] + tok.detokenize = types.MethodType(detokenize, tok) # ty: ignore[unresolved-attribute] + + +# --------------------------------------------------------------------------- +# Model loading +# --------------------------------------------------------------------------- + + +def load_pi05_jax(checkpoint_dir: str) -> Pi0: + """Load the pi0.5 JAX model from an Orbax checkpoint.""" + config = Pi0Config(pi05=True) + rng = jax.random.key(0) + model = config.create(rng) + + print(f"[load] Restoring params from {checkpoint_dir}/params ...") + params = _model.restore_params(f"{checkpoint_dir}/params", dtype=jnp.bfloat16) + import flax.nnx as nnx + + nnx.update(model, nnx.State(params)) + model.eval() + print("[load] Model loaded and in eval mode.") + return model + + +# --------------------------------------------------------------------------- +# Observation construction +# --------------------------------------------------------------------------- + + +def make_observation( + prompt: str, + tokenizer: PaligemmaTokenizer, + action_dim: int = 32, + use_random_images: bool = True, +) -> tuple[_model.Observation, int]: + """Build an observation for the probe. + + Uses the SUBTASK prompt format: "Task: {task}. Subtask: " (no state, no Action:). + This matches what the model expects for subtask generation mode. + + Returns (observation, num_real_tokens). + """ + # Tokenize with subtask prefix format (NOT the action format) + # "Task: {task}. Subtask: " -- no state, no Action: + tokens, mask = tokenizer.tokenize_high_level_prefix(prompt) # ty: ignore[unresolved-attribute] + num_real_tokens = int(mask.sum()) + state = np.zeros(action_dim, dtype=np.float32) + + # Images: random noise or zeros + def make_img() -> np.ndarray: + if use_random_images: + return np.random.default_rng(42).random((224, 224, 3)).astype(np.float32) * 2 - 1 + return np.zeros((224, 224, 3), dtype=np.float32) + + # Build observation (batch dim added, convert to JAX arrays) + obs = _model.Observation( + images={ + "base_0_rgb": jnp.array(make_img()[None]), + "left_wrist_0_rgb": jnp.array(make_img()[None]), + "right_wrist_0_rgb": jnp.array(make_img()[None]), + }, + image_masks={ + "base_0_rgb": jnp.array([True]), + "left_wrist_0_rgb": jnp.array([True]), + "right_wrist_0_rgb": jnp.array([True]), + }, + state=jnp.array(state[None]), + tokenized_prompt=jnp.array(tokens[None]), + tokenized_prompt_mask=jnp.array(mask[None]), + ) + + return obs, num_real_tokens + + +# --------------------------------------------------------------------------- +# Subtask generation (JAX, adapted from LisavilaLee's generate_subtask) +# --------------------------------------------------------------------------- + + +def generate_subtask_text( + model: Pi0, + observation: _model.Observation, + max_decoding_steps: int = 50, + temperature: float = 0.0, +) -> dict: + """Generate subtask text autoregressively from the pi0.5 model. + + This follows the proven JAX approach: + 1. embed_prefix -> PaliGemma forward -> KV cache + 2. Embedder.decode (dot with embedding table transpose) -> logits + 3. Greedy/sampled token generation in a loop + """ + observation = _model.preprocess_observation(None, observation, train=False) + + # Step 1: Embed prefix (images + language tokens) + prefix_tokens, prefix_mask, prefix_ar_mask = model.embed_prefix(observation) + B, prefix_S, _ = prefix_tokens.shape + + # Step 2: Forward through PaliGemma to get KV cache + prefix output + # No mask padding -- KV cache size matches prefix length. + prefix_attn_mask = make_attn_mask(prefix_mask, prefix_ar_mask) + positions = jnp.cumsum(prefix_mask, axis=1) - 1 # ty: ignore[invalid-argument-type] + + (prefix_out, _), kv_cache = model.PaliGemma.llm( + [prefix_tokens, None], + mask=prefix_attn_mask, + positions=positions, + adarms_cond=[None, None], + ) + + # Step 3: Find the last VALID token position (LisavilaLee's fix) + seq_indices = jnp.arange(prefix_S)[None, :] # [1, S] + last_pos = jnp.max(jnp.where(prefix_mask, seq_indices, -1), axis=1).astype(jnp.int32) # ty: ignore[no-matching-overload] + + last_hidden = prefix_out[jnp.arange(B), last_pos, :] # [B, D] + + # Step 4: Project to vocab logits via Embedder.decode + # Embedder.decode = dot(x, embedding_table.T) + embed_table = model.PaliGemma.llm.embedder["input_embedding"].value # ty: ignore[unresolved-attribute] + logits = jnp.dot(last_hidden, embed_table.T) # [B, vocab_size] + + # Collect initial top predictions + probs = jax.nn.softmax(logits, axis=-1) + top_k_probs, top_k_indices = jax.lax.top_k(probs[0], 10) + initial_predictions = list(zip(top_k_indices.tolist(), top_k_probs.tolist(), strict=True)) + + # Step 5: Autoregressive generation loop (eager, matches LisavilaLee's generate_subtask) + EOS_TOKEN = 1 + num_real = int(jnp.sum(prefix_mask, axis=-1)[0]) # ty: ignore[invalid-argument-type] + next_pos = jnp.array([num_real], dtype=jnp.int32) # [B] + generated_token_ids = [] + + current_logits = logits[ + None, :, : + ] # reshape to [B, 1, V] to match their pattern... actually [B, V] + current_cache = kv_cache + + for step_idx in range(max_decoding_steps): + # Greedy decode + if temperature > 0: + token_id = int( + jax.random.categorical(jax.random.key(step_idx), current_logits[0] / temperature) + ) + else: + token_id = int(jnp.argmax(current_logits[0])) + + generated_token_ids.append(token_id) + if token_id == EOS_TOKEN: + break + + # Embed the token + token_jax = jnp.array([[token_id]], dtype=jnp.int32) + token_embedding = model.PaliGemma.llm(token_jax, method="embed") # [B, 1, D] + + # Attention mask: [B, 1, prefix_S + gen_count] + # New token attends to all prefix tokens + all previously generated tokens + itself. + gen_count = step_idx + 1 + gen_mask = jnp.ones((B, gen_count), dtype=jnp.bool_) + full_mask = jnp.concatenate([prefix_mask, gen_mask], axis=1) # ty: ignore[invalid-argument-type] + attn_mask = full_mask[:, None, :] # [B, 1, prefix_S + gen_count] + + new_positions = next_pos[:, None] # [B, 1] + + # Forward with KV cache + (new_out, _), current_cache = model.PaliGemma.llm( + [token_embedding, None], + mask=attn_mask, + positions=new_positions, + kv_cache=current_cache, + ) + + # Project to logits via embedding table transpose + new_hidden = new_out[:, -1, :] # [B, D] + current_logits = jnp.dot(new_hidden, embed_table.T) # [B, vocab_size] + next_pos = next_pos + 1 + + return { + "output_tokens": generated_token_ids, + "num_steps": len(generated_token_ids), + "initial_predictions": initial_predictions, + "last_valid_position": int(last_pos[0]), + "prefix_length": prefix_S, + } + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def main() -> None: + import argparse + + parser = argparse.ArgumentParser(description="JAX subtask generation probe for pi0.5") + parser.add_argument( + "--checkpoint_dir", + type=str, + default=str(Path.home() / ".cache/openpi/openpi-assets/checkpoints/pi05_base"), + ) + parser.add_argument("--random_images", action="store_true", default=True) + parser.add_argument("--zero_images", action="store_true") + args = parser.parse_args() + + use_random = not args.zero_images + + tokenizer = PaligemmaTokenizer(max_len=200) + _add_subtask_tokenizer_methods(tokenizer) + print("Tokenizer loaded (with subtask methods)") + + model = load_pi05_jax(args.checkpoint_dir) + + test_prompts = [ + "pick up the red cup and place it on the shelf", + "fold the towel neatly", + "open the drawer and put the block inside", + "stack the blue block on top of the red block", + "wipe the table with the sponge", + ] + + for prompt in test_prompts: + print(f"\n{'=' * 80}") + print(f' Prompt: "{prompt}"') + print(f"{'=' * 80}") + + obs, num_real = make_observation(prompt, tokenizer, use_random_images=use_random) + print(f" Real tokens: {num_real}/200, images: {'random' if use_random else 'zeros'}") + + result = generate_subtask_text(model, obs, max_decoding_steps=50, temperature=0.0) + + # Decode tokens + token_ids = result["output_tokens"] + # Remove EOS if present + if 1 in token_ids: + token_ids = token_ids[: token_ids.index(1)] + + generated_text = tokenizer._tokenizer.decode(token_ids) # ty: ignore[unresolved-attribute] + + print( + f" Prefix length: {result['prefix_length']}, last valid pos: {result['last_valid_position']}" + ) + print(f" Steps generated: {result['num_steps']}") + + # Initial predictions + print(" Top-10 next-token predictions from last prefix position:") + for token_id, prob in result["initial_predictions"]: + token_str = tokenizer._tokenizer.decode([token_id]) # ty: ignore[unresolved-attribute] + print(f' [{token_id:6d}] "{token_str}" (prob={prob:.4f})') + + print(f' Generated text: "{generated_text}"') + print(f" Raw tokens: {token_ids[:30]}") + + print(f"\n{'=' * 80}") + print(" DONE") + print(f"{'=' * 80}") + + +if __name__ == "__main__": + main() diff --git a/experiments/subtask_probe/droid_eval/README.md b/experiments/subtask_probe/droid_eval/README.md new file mode 100644 index 0000000..bc24871 --- /dev/null +++ b/experiments/subtask_probe/droid_eval/README.md @@ -0,0 +1,154 @@ +# DROID Evaluation: Planner+Action vs Action-Only + +## Goal + +Validate whether injecting JAX-generated subtask text into the pi0.5 action prompt produces **better** actions (closer to ground truth) compared to action-only inference. Previous experiments with zero images proved the mechanism works (subtask text changes actions) but couldn't assess quality. + +## Setup + +**Models:** +- **Subtask generator (JAX):** `pi05_base` checkpoint — generates subtask text via AR decoding +- **Action generator (PyTorch):** `swatery/pi05_droid_base` on HuggingFace — DROID-finetuned pi0.5 + +**Data:** DROID v1.0.1 episodes streamed from GCS (`gs://gresearch/robotics/droid/1.0.1`). Each episode provides real robot images, proprioceptive state, language instructions, and ground truth actions. + +## Pipeline + +``` +Phase 0: extract_droid_samples.py + Stream DROID episodes from GCS → cache frames + ground truth actions to .npz + Two modes: first-K (default) or top-K longest (--scan_episodes N) + +Phase 1: generate_subtasks.py (pi0.5 server) or generate_subtasks_gemini.py (Gemini) + Emit subtask text for each cached frame → JSON + +Phase 2: run_action_eval.py + Load PyTorch DROID checkpoint → run 3 prompt conditions per frame: + A. Baseline: "Task: X, State: S;\nAction: " + B. Hybrid A: "Task: X. Subtask: Y, State: S;\nAction: " + C. Hybrid B: "Task: X (Y), State: S;\nAction: " + +Phase 3: compute_metrics.py + Compare predicted actions to ground truth → L2 distance, cosine sim, per-dim MAE +``` + +## Prerequisites + +```bash +# On the Seoul L40S instance: + +# 1. Install RLDS dependencies (in the openpi project root) +cd ~/openpi +uv sync --group rlds + +# 2. Download DROID norm stats +gsutil cp -r gs://openpi-assets/checkpoints/pi05_droid/assets/droid/ \ + ~/.cache/openpi/openpi-assets/checkpoints/pi05_droid/assets/droid/ + +# 3. Download the PyTorch DROID checkpoint +hf download swatery/pi05_droid_base + +# 4. Ensure JAX base checkpoint is available +uv run python -c "from openpi.shared import download; download.maybe_download('gs://openpi-assets/checkpoints/pi05_base')" +``` + +## Running + +```bash +cd ~/openpi/hosting + +# Phase 0 (quick): Extract the first 10 successful episodes (short demos, ~30–60 min). +uv run python -m experiments.subtask_probe.droid_eval.extract_droid_samples \ + --num_episodes 10 \ + --output_dir ./.experiments_cache/droid_eval + +# Phase 0 (long-horizon eval): Scan 5k DROID episodes and keep the 5 longest +# multi-step ones (>=60s, keyword-matched). This is the setup used to measure +# whether subtask conditioning helps on genuinely long tasks. Streams from GCS, +# ~10 min on a cloud box. +uv run python -m experiments.subtask_probe.droid_eval.extract_droid_samples \ + --num_episodes 5 --scan_episodes 5000 \ + --min_duration_s 60 --require_multi_step \ + --output_dir ./.experiments_cache/droid_eval_2min + +# Phase 1: Generate subtasks (~14s per frame with JAX) +uv run python experiments/subtask_probe/droid_eval/generate_subtasks.py \ + --samples_dir ./.experiments_cache/droid_eval \ + --output ./.experiments_cache/droid_eval/subtasks.json + +# Phase 1 (alt): Generate subtasks via Gemini Robotics-ER instead of pi0.5. +# Requires GEMINI_API_KEY in the environment (or .env). Emits the same JSON +# schema so Phase 2 / 3 consume it unchanged, and pairs cleanly with +# compare_subtask_outputs.py for pi0.5-vs-Gemini diffs. +uv run python experiments/subtask_probe/droid_eval/generate_subtasks_gemini.py \ + --samples_dir ./.experiments_cache/droid_eval \ + --output ./.experiments_cache/droid_eval/subtasks_gemini.json + +# Phase 1 (alt 2): Comet-style hierarchical subtask generation. +# +# Runs a stateful plan -> critique -> subtask loop per episode, ported from +# openpi-comet/src/openpi/shared/client.py. Each cached frame issues 2 VLM +# calls (critique + subtask), so expect ~2x the wall clock and API spend of +# the stateless Gemini run. Output JSON schema is identical — drop-in for +# Phase 2 / 3. + +# Backend A: Gemini Robotics-ER 1.6 Preview (requires GEMINI_API_KEY). +uv run python -m experiments.subtask_probe.droid_eval.comet_style.run \ + --samples_dir ./.experiments_cache/droid_eval \ + --output ./.experiments_cache/droid_eval/subtasks_comet_gemini.json \ + --backend gemini + +# Backend B: OpenAI-compatible VLM (e.g. vLLM hosting Qwen3-VL-30B). +# First, on a GPU host with >=48 GB VRAM, serve the model: +# uv pip install vllm +# vllm serve Qwen/Qwen3-VL-30B-A3B-Instruct \ +# --port 8000 --max-model-len 32768 --limit-mm-per-prompt image=64 +# Then from the local machine (tunnel the port if the server is remote): +uv run python -m experiments.subtask_probe.droid_eval.comet_style.run \ + --samples_dir ./.experiments_cache/droid_eval \ + --output ./.experiments_cache/droid_eval/subtasks_comet_qwen.json \ + --backend openai_compat \ + --base_url http://localhost:8000/v1 \ + --model Qwen/Qwen3-VL-30B-A3B-Instruct + +# Phase 2: Run action evaluation (~280ms per inference × 3 conditions) +uv run python experiments/subtask_probe/droid_eval/run_action_eval.py \ + --samples_dir ./.experiments_cache/droid_eval \ + --subtasks ./.experiments_cache/droid_eval/subtasks.json \ + --output_dir ./.experiments_cache/droid_eval/predictions + +# Phase 3: Compute metrics +uv run python experiments/subtask_probe/droid_eval/compute_metrics.py \ + --samples_dir ./.experiments_cache/droid_eval \ + --predictions_dir ./.experiments_cache/droid_eval/predictions \ + --output ./.experiments_cache/droid_eval/results.json +``` + +## Metrics + +| Metric | What it measures | +|--------|-----------------| +| L2 distance to ground truth | Primary quality signal — are predicted actions closer to what the robot actually did? | +| Per-dimension MAE | Which joints benefit most from subtask conditioning | +| Cosine similarity | Directional alignment independent of magnitude | +| Gripper accuracy | Binary open/closed correctness (threshold 0.5) | + +Results are aggregated by: +- Multi-step vs single-step tasks +- Episode progress (early / middle / late) +- Overall + +## Interpretation + +- **Hybrid < Baseline L2** on multi-step tasks → subtask conditioning helps +- Improvement **only on multi-step**, not single-step → genuine hierarchical decomposition +- **Hybrid A ≠ Hybrid B** → prompt format matters for how the model parses injected text +- No improvement → hybrid prompt injection doesn't work without fine-tuning + +## Key Implementation Details + +- **Normalization:** Raw DROID joint positions must be z-score normalized before tokenizing into the action prompt. Norm stats from `gs://openpi-assets/checkpoints/pi05_droid/assets/droid/norm_stats.json`. +- **Action horizon:** DROID checkpoint uses `action_horizon=15` (not the base checkpoint's 50). +- **Same noise seed:** All 3 conditions use identical initial noise per frame for fair comparison. +- **Image format:** JAX expects HWC float32 [-1,1]; PyTorch expects CHW float32 [-1,1]. +- **DroidOutputs:** Only first 8 dims of 32D model output are meaningful (7 joints + 1 gripper). diff --git a/experiments/subtask_probe/droid_eval/__init__.py b/experiments/subtask_probe/droid_eval/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/experiments/subtask_probe/droid_eval/comet_style/__init__.py b/experiments/subtask_probe/droid_eval/comet_style/__init__.py new file mode 100644 index 0000000..aa4ab8f --- /dev/null +++ b/experiments/subtask_probe/droid_eval/comet_style/__init__.py @@ -0,0 +1,9 @@ +"""Comet-style hierarchical subtask generation for DROID frames. + +Ports the plan -> critique -> subtask loop from +openpi-comet/src/openpi/shared/client.py into a backend-agnostic scaffold with +two concrete reasoner backends: Gemini Robotics-ER and any OpenAI-compatible +VLM server (e.g. a local vLLM hosting Qwen3-VL). + +This is evaluation-only code; the live hosting stack is untouched. +""" diff --git a/experiments/subtask_probe/droid_eval/comet_style/_gemini_utils.py b/experiments/subtask_probe/droid_eval/comet_style/_gemini_utils.py new file mode 100644 index 0000000..765cf40 --- /dev/null +++ b/experiments/subtask_probe/droid_eval/comet_style/_gemini_utils.py @@ -0,0 +1,118 @@ +"""Shared helpers for Gemini-backed subtask generators. + +Split out of generate_subtasks_gemini.py so both the stateless generator and +the Comet-style hierarchical reasoner use the same PNG encoding and 429 +retry-with-backoff logic. +""" + +from __future__ import annotations + +import io +import logging +import re +import time +from collections.abc import Callable +from typing import TypeVar + +import numpy as np +from PIL import Image + +logger = logging.getLogger(__name__) + +_RETRY_DELAY_PATTERN = re.compile(r"'retryDelay':\s*'(\d+(?:\.\d+)?)s'") + +T = TypeVar("T") + + +def encode_png(image: np.ndarray) -> bytes: + """PNG-encode an HxWx3 uint8 RGB image.""" + buffer = io.BytesIO() + Image.fromarray(np.asarray(image, dtype=np.uint8)).save(buffer, format="PNG") + return buffer.getvalue() + + +def parse_retry_delay_seconds(exc: Exception) -> float | None: + """Extract the server's suggested retry delay (seconds) from a 429 error string.""" + match = _RETRY_DELAY_PATTERN.search(str(exc)) + if match is None: + return None + return float(match.group(1)) + + +def is_rate_limit_error(exc: Exception) -> bool: + message = str(exc) + return "429" in message or "RESOURCE_EXHAUSTED" in message + + +# Message fragments that indicate a *transient* network problem worth retrying +# (vs. a deterministic API error like 400 or schema validation). Matched against +# str(exc) so we're robust to the specific exception classes google-genai uses +# across versions. +_TRANSIENT_NETWORK_FRAGMENTS = ( + "server disconnected", + "remote end closed connection", + "connection reset", + "connection aborted", + "read timed out", + "readtimeout", + "connecttimeout", + "503", # service unavailable + "502", # bad gateway + "504", # gateway timeout + "500", # internal server error +) + + +def is_transient_network_error(exc: Exception) -> bool: + message = str(exc).lower() + return any(fragment in message for fragment in _TRANSIENT_NETWORK_FRAGMENTS) + + +def call_with_retry( + call: Callable[[], T], + *, + max_retries: int, +) -> T: + """Call a Gemini API function with retry on 429s and transient network errors. + + * 429 / RESOURCE_EXHAUSTED -> sleep for the server-suggested ``retryDelay`` + (or a capped exponential fallback) and try again. + * Transient network failures (server disconnect, 5xx, read timeout) -> + exponential backoff and try again, because these previously caused a + 50+ minute hang when they surfaced at the wrong point in the Gemini + client's internal retry logic. + * Other exceptions propagate immediately. + """ + last_exc: Exception | None = None + for attempt in range(max_retries + 1): + try: + return call() + except Exception as exc: + rate_limited = is_rate_limit_error(exc) + transient = is_transient_network_error(exc) + if (not rate_limited and not transient) or attempt == max_retries: + raise + last_exc = exc + if rate_limited: + # Server sends retryDelay like '3s'; add jitter so a stampede + # of workers doesn't all wake up at the same instant and trip + # the quota again. + base_delay = parse_retry_delay_seconds(exc) or min(2**attempt, 30.0) + sleep_s = base_delay + (0.2 * attempt) + logger.info( + "Rate-limited (attempt %d/%d); sleeping %.1fs before retry", + attempt + 1, + max_retries, + sleep_s, + ) + else: + sleep_s = min(2**attempt, 10.0) + (0.2 * attempt) + logger.warning( + "Transient network error (attempt %d/%d): %s; sleeping %.1fs", + attempt + 1, + max_retries, + exc, + sleep_s, + ) + time.sleep(sleep_s) + raise RuntimeError("retry loop exited without returning") from last_exc diff --git a/experiments/subtask_probe/droid_eval/comet_style/gemini_reasoner.py b/experiments/subtask_probe/droid_eval/comet_style/gemini_reasoner.py new file mode 100644 index 0000000..1f60c3e --- /dev/null +++ b/experiments/subtask_probe/droid_eval/comet_style/gemini_reasoner.py @@ -0,0 +1,85 @@ +"""Gemini Robotics-ER backed reasoner for Comet-style hierarchical subtask generation.""" + +from __future__ import annotations + +import logging +from typing import Any + +import numpy as np +from google import genai +from google.genai import types + +from ._gemini_utils import call_with_retry, encode_png +from .reasoner_base import BaseReasoner + +logger = logging.getLogger(__name__) + +DEFAULT_MODEL = "gemini-robotics-er-1.6-preview" + + +class GeminiReasoner(BaseReasoner): + """Plan/critique/subtask loop backed by Gemini Robotics-ER 1.6 Preview. + + Accepts any Gemini model via ``model=`` — e.g. ``gemini-3.1-pro-preview`` + if you want a general-purpose reasoning VLM instead of the robotics-tuned + default. + """ + + def __init__( + self, + model: str = DEFAULT_MODEL, + thinking_budget: int = 0, + max_retries: int = 10, + request_timeout_s: float = 120.0, + history_maxlen: int = 640, + sampled_images_max: int = 64, + history_stride: int = 5, + ) -> None: + super().__init__( + history_maxlen=history_maxlen, + sampled_images_max=sampled_images_max, + history_stride=history_stride, + ) + # timeout is in milliseconds per google-genai's HttpOptions. A 2-minute + # cap is well above normal ~5-10s latency but short enough that a hung + # socket surfaces fast instead of stalling the whole run. + self._client = genai.Client( + http_options=types.HttpOptions(timeout=int(request_timeout_s * 1000)) + ) + self._model = model + self._thinking_budget = thinking_budget + self._max_retries = max_retries + + def _chat( + self, + user_prompt: str, + images: list[np.ndarray], + response_schema: dict[str, Any] | None = None, + ) -> str: + contents: list = [] + for image in images: + contents.append(types.Part.from_bytes(data=encode_png(image), mime_type="image/png")) + contents.append(user_prompt) + + config_kwargs: dict[str, Any] = { + "temperature": 1.0, + "thinking_config": types.ThinkingConfig(thinking_budget=self._thinking_budget), + } + if response_schema is not None: + # The google-genai SDK accepts a dict JSON schema directly and + # converts it to a types.Schema internally. Pairing with + # response_mime_type="application/json" enforces the structure. + config_kwargs["response_mime_type"] = "application/json" + config_kwargs["response_schema"] = response_schema + + config = types.GenerateContentConfig(**config_kwargs) + + def _call() -> str: + response = self._client.models.generate_content( + model=self._model, + contents=contents, + config=config, + ) + return (response.text or "").strip() + + return call_with_retry(_call, max_retries=self._max_retries) diff --git a/experiments/subtask_probe/droid_eval/comet_style/openai_compat_reasoner.py b/experiments/subtask_probe/droid_eval/comet_style/openai_compat_reasoner.py new file mode 100644 index 0000000..8192a27 --- /dev/null +++ b/experiments/subtask_probe/droid_eval/comet_style/openai_compat_reasoner.py @@ -0,0 +1,95 @@ +"""OpenAI-compatible VLM backend for Comet-style hierarchical subtask generation. + +Targets a self-hosted vLLM server (or any server that speaks the OpenAI +chat-completions protocol with image inputs). Default model name matches the +only VLM named in the openpi-comet repo, ``Qwen3-VL-30B-A3B-Instruct`` +(``openpi-comet/src/openpi/shared/client.py:169``), but any multimodal chat +model the server hosts will work. +""" + +from __future__ import annotations + +import base64 +import logging +from typing import Any, cast + +import numpy as np +from openai import OpenAI +from openai.types.chat import ChatCompletionMessageParam + +from ._gemini_utils import encode_png +from .reasoner_base import BaseReasoner + +logger = logging.getLogger(__name__) + +DEFAULT_BASE_URL = "http://localhost:8000/v1" +DEFAULT_MODEL = "Qwen/Qwen3-VL-30B-A3B-Instruct" + + +def _encode_data_url(image: np.ndarray) -> str: + """PNG-encode an image as a ``data:`` URL suitable for OpenAI image inputs.""" + png_bytes = encode_png(image) + return f"data:image/png;base64,{base64.b64encode(png_bytes).decode('utf-8')}" + + +class OpenAICompatReasoner(BaseReasoner): + def __init__( + self, + base_url: str = DEFAULT_BASE_URL, + model: str = DEFAULT_MODEL, + api_key: str = "none", + temperature: float = 1.0, + timeout_s: float = 600.0, + history_maxlen: int = 640, + sampled_images_max: int = 64, + history_stride: int = 5, + ) -> None: + super().__init__( + history_maxlen=history_maxlen, + sampled_images_max=sampled_images_max, + history_stride=history_stride, + ) + self._client = OpenAI(base_url=base_url, api_key=api_key, timeout=timeout_s) + self._model = model + self._temperature = temperature + + def _chat( + self, + user_prompt: str, + images: list[np.ndarray], + response_schema: dict[str, Any] | None = None, + ) -> str: + content: list[dict[str, Any]] = [{"type": "text", "text": user_prompt}] + for image in images: + content.append( + { + "type": "image_url", + "image_url": {"url": _encode_data_url(image)}, + } + ) + # The chat-completions SDK types are strict TypedDicts that don't + # accept our general-purpose content list; cast is safer than + # maintaining parallel TypedDict literals for every image. + messages = cast(list[ChatCompletionMessageParam], [{"role": "user", "content": content}]) + + create_kwargs: dict[str, Any] = { + "model": self._model, + "messages": messages, + "temperature": self._temperature, + } + if response_schema is not None: + # vLLM exposes OpenAI-spec structured outputs backed by xgrammar. + # The server fills in decoding constraints from the JSON schema so + # the response is guaranteed to parse. + create_kwargs["response_format"] = { + "type": "json_schema", + "json_schema": { + "name": "response", + "schema": response_schema, + "strict": True, + }, + } + + response = self._client.chat.completions.create(**create_kwargs) + message = response.choices[0].message.content or "" + return message.strip() diff --git a/experiments/subtask_probe/droid_eval/comet_style/reasoner_base.py b/experiments/subtask_probe/droid_eval/comet_style/reasoner_base.py new file mode 100644 index 0000000..90b4fe2 --- /dev/null +++ b/experiments/subtask_probe/droid_eval/comet_style/reasoner_base.py @@ -0,0 +1,470 @@ +"""Backend-agnostic scaffold for Comet-style hierarchical subtask generation. + +Ports ``strip_think_tags``, ``generate_stylized_plan``, ``sample_images`` and +the three-step plan -> critique -> subtask control flow verbatim from +``openpi-comet/src/openpi/shared/client.py``. The prompt strings are copied +byte-for-byte from that file so we're testing Comet's prompting, not our +rewording. + +Concrete backends subclass ``BaseReasoner`` and implement a single ``_chat`` +hook that sends a VLM request with a user prompt plus a list of images and +returns the response text. +""" + +from __future__ import annotations + +import abc +import collections +import json +import logging +import re +from typing import Any, Literal + +import numpy as np + +logger = logging.getLogger(__name__) + + +# Comet defaults: deque(maxlen=64*10) for image history, sample at most 64 images per call +# (client.py:178, 72-79). +DEFAULT_HISTORY_MAXLEN = 64 * 10 +DEFAULT_SAMPLED_IMAGES_MAX = 64 + +# Status markers for plan steps, matching Comet's stylized plan output: +# [o] done, [-] in_progress, [x] not_started +PlanStepStatus = Literal["done", "in_progress", "not_started"] +_STATUS_VALUES: tuple[PlanStepStatus, ...] = ("done", "in_progress", "not_started") +_STATUS_MARKERS: dict[PlanStepStatus, str] = { + "done": "[o]", + "in_progress": "[-]", + "not_started": "[x]", +} + +# JSON schemas enforced via the backend's native structured-output mechanism +# (Gemini response_schema / vLLM xgrammar json_schema). Both backends translate +# these dicts to their native representation. +# +# The schemas are designed to produce short outputs compatible with the pi0.5 +# action prompt budget: plans are 2-10 short step strings, subtasks are a +# single short imperative phrase (~120 chars / ~20 words). +PLAN_SCHEMA: dict[str, Any] = { + "type": "array", + "items": {"type": "string"}, + "minItems": 2, + "maxItems": 10, +} + +SUBTASK_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "subtask": { + "type": "string", + "maxLength": 120, + }, + }, + "required": ["subtask"], +} + +# plan_critique returns a parallel list of statuses — one per step of the +# original plan, in the same order. Using an enum makes structural equality +# meaningful (no prose drift) so we only reset ``subtask_history`` when the +# plan state actually changes, not when wording varies. +CRITIQUE_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "statuses": { + "type": "array", + "items": {"type": "string", "enum": list(_STATUS_VALUES)}, + }, + }, + "required": ["statuses"], +} + + +def strip_think_tags(text: str) -> str: + """Remove ``...`` content or any leading ```` preamble. + + Mirrors ``openpi-comet/src/openpi/shared/client.py:82``. + """ + lower = text.lower() + if "" in lower and "" in lower: + cleaned_text = re.sub(r".*?", "", text, flags=re.DOTALL | re.IGNORECASE) + elif "" in lower: + cleaned_text = re.sub(r"^.*?", "", text, flags=re.DOTALL | re.IGNORECASE) + else: + cleaned_text = text + return re.sub(r"\n\s*\n", "\n", cleaned_text.strip()) + + +def parse_plan_list(text: str) -> list[str] | None: + """Parse a VLM response that should be a JSON list of plan-step strings. + + Returns the parsed list on success, or ``None`` when the response cannot + be coerced into a non-empty list of strings (bad JSON, wrong shape, + non-string items, empty list). Callers are expected to fall back to a + single-step plan in that case. + + Handles the two common messy outputs from reasoning VLMs: ```` + tags around the response and markdown code fences (```json ... ```). + Comet uses ``json_repair`` for further tolerance; we avoid the dep and + surface parse failures as ``None`` instead. + """ + cleaned = strip_think_tags(text).strip() + if cleaned.startswith("```"): + cleaned = re.sub(r"^```[a-zA-Z]*\n?", "", cleaned) + cleaned = re.sub(r"\n?```\s*$", "", cleaned) + try: + parsed = json.loads(cleaned) + except json.JSONDecodeError: + return None + if not isinstance(parsed, list) or not parsed: + return None + result: list[str] = [] + for item in parsed: + if not isinstance(item, str): + return None + result.append(item) + return result + + +def render_plan_status(plans: list[str], statuses: list[PlanStepStatus]) -> str: + """Render a plan + per-step status list into Comet's stylized string format. + + Example output:: + + [o] pick up the cube + [-] move to the dish + [x] release the cube + + Mirrors the shape of ``openpi-comet/src/openpi/shared/client.py:118`` but + is driven by discrete enum statuses rather than an index + completion + flag, which maps cleanly onto our structured-output critique responses. + """ + if len(plans) != len(statuses): + raise ValueError( + f"plans/statuses length mismatch: {len(plans)} steps vs {len(statuses)} statuses" + ) + return "\n".join( + f"{_STATUS_MARKERS[status]} {step}" for step, status in zip(plans, statuses, strict=True) + ) + + +def initial_statuses(num_steps: int) -> list[PlanStepStatus]: + """Starting state: first step in_progress, the rest not_started.""" + if num_steps <= 0: + raise ValueError("initial_statuses requires at least one step") + statuses: list[PlanStepStatus] = ["in_progress"] + statuses.extend(["not_started"] * (num_steps - 1)) + return statuses + + +def sample_images( + image_list: list[np.ndarray], + max_len: int = 64, + stride: int = 5, +) -> list[np.ndarray]: + """Sample every ``stride``-th image in reverse from the most recent, up to ``max_len``. + + Mirrors ``openpi-comet/src/openpi/shared/client.py:72``, which hardcoded + stride=5 because their history buffer was populated at the 30 Hz sim rate + and they wanted ~6 Hz of temporal density per VLM call. On our DROID + cache the buffer is populated at the *cached* rate (1 Hz with + ``--frame_subsample=15``), so stride=5 skips over almost everything. + Rule of thumb: ``stride ≈ cache_hz / desired_sample_hz`` (with floor 1). + For our 1 Hz cache, stride=1 means the VLM sees the last ``max_len`` + consecutive seconds of history. + """ + if stride <= 0: + raise ValueError(f"stride must be positive, got {stride}") + sampled: list[np.ndarray] = [] + for i in range(len(image_list) - 1, -1, -stride): + sampled.append(image_list[i]) + if len(sampled) >= max_len: + break + sampled.reverse() + return sampled + + +def all_steps_done(statuses: list[PlanStepStatus] | None) -> bool: + """Return True when every step is marked ``done``. + + DROID cached frames can continue well past the point where the robot has + finished the instructed task. Once the plan is fully complete, the VLM + has nothing useful to say and its degenerate outputs ("finished", "none") + pollute the action prompt. Callers short-circuit on this condition. + """ + return bool(statuses) and all(s == "done" for s in statuses) + + +class BaseReasoner(abc.ABC): + """Stateful hierarchical reasoner shared by all backends. + + State that carries across frames within an episode: + * ``history_multi_modals`` — ring buffer of past images + * ``subtask_history`` — ordered list of subtask strings produced so far + * ``plan_status`` — the current stylized plan string with status markers, + or ``None`` when no plan has been generated yet (pre-first-call or + post-reset) + + Call ``reset()`` at the start of each new episode. + + Design note — two-call vs merged ``plan_critique`` + ``generate_subtask``: + Every replan fires two VLM calls back-to-back (critique + subtask select). + These could be merged into one call with a schema like + ``{"statuses": [...], "subtask": "..."}``, which would halve cost + latency. + We keep them separate because Comet's original flow depends on the + sequential dependency — the subtask prompt is built from the *updated* + ``plan_status`` *and* a possibly-reset ``subtask_history`` (triggered when + the statuses change). Merging loses the reset gate and forces the model to + maintain cross-field consistency in one generation, which is fine for + strong reasoning models but risks inconsistent outputs on non-reasoning + models (e.g. Gemini Robotics-ER at ``thinking_budget=0``). Revisit this + if API cost becomes the bottleneck and the model can handle it. + """ + + def __init__( + self, + history_maxlen: int = DEFAULT_HISTORY_MAXLEN, + sampled_images_max: int = DEFAULT_SAMPLED_IMAGES_MAX, + history_stride: int = 5, + ) -> None: + self.history_multi_modals: collections.deque[np.ndarray] = collections.deque( + maxlen=history_maxlen + ) + self.sampled_images_max = sampled_images_max + # Stride used by _sampled_history when picking frames from the + # history deque. Default 5 matches Comet's original hardcoded value + # and empirically gives the best plan stability on our 1 Hz cache + # because the wider temporal span (~40 s of history with max=8) + # provides the temporal-contrast signal the reasoner needs to + # detect plan progression. stride=1 looks tempting ("consecutive + # frames!") but flips plan interpretation on every tiny frame + # change on slow tasks. + self.history_stride = history_stride + self.subtask_history: list[str] = [] + # Canonical plan state: the immutable step texts from the one-shot + # generate_plan call and a parallel list of per-step statuses that + # plan_critique updates. plan_status (the rendered string) is derived. + self.plans: list[str] | None = None + self.plan_statuses: list[PlanStepStatus] | None = None + + def reset(self) -> None: + self.history_multi_modals.clear() + self.subtask_history = [] + self.plans = None + self.plan_statuses = None + + @property + def plan_status(self) -> str | None: + """Rendered Comet-style plan string, or None before generate_plan runs.""" + if self.plans is None or self.plan_statuses is None: + return None + return render_plan_status(self.plans, self.plan_statuses) + + @abc.abstractmethod + def _chat( + self, + user_prompt: str, + images: list[np.ndarray], + response_schema: dict[str, Any] | None = None, + ) -> str: + """Send a VLM request and return the raw response string. + + When ``response_schema`` is provided the backend must enforce it via + its native structured-output mechanism (Gemini ``response_schema`` / + vLLM ``json_schema``); otherwise the backend should free-form generate. + + Implementations should NOT strip ```` tags — the base class does + that where appropriate. + """ + + def _sampled_history(self) -> list[np.ndarray]: + return sample_images( + list(self.history_multi_modals), + max_len=self.sampled_images_max, + stride=self.history_stride, + ) + + def generate_plan(self, task: str, initial_image: np.ndarray) -> str: + """One-shot plan generation from the initial observation of the episode. + + Prompt adapted from ``openpi-comet/src/openpi/shared/client.py:274``, + with an explicit JSON-array constraint enforced via ``PLAN_SCHEMA`` so + off-the-shelf VLMs emit the structured list Comet's scaffold expects. + + Stores the parsed plan as ``self.plans`` and initializes + ``self.plan_statuses`` with the first step in_progress, others + not_started. Returns the rendered plan_status string. + """ + user_prompt = ( + f"Given the task '{task}', break it down into several concrete high-level steps. " + "Respond with a JSON array of 2-10 short imperative step strings, nothing else." + ) + self.history_multi_modals.append(initial_image) + response = self._chat(user_prompt, [initial_image], response_schema=PLAN_SCHEMA) + plans = parse_plan_list(response) + if plans is None: + logger.warning( + "Plan response could not be parsed as a non-empty JSON list of strings; " + "falling back to single-step plan. Raw response: %r", + response, + ) + plans = [task] + self.plans = plans + self.plan_statuses = initial_statuses(len(plans)) + rendered = self.plan_status + assert rendered is not None + return rendered + + def plan_critique(self, task: str) -> list[PlanStepStatus]: + """Ask the VLM to update per-step statuses given the image history. + + Returns a parallel list of ``PlanStepStatus`` values, one per step of + ``self.plans`` in the same order. Enforced via ``CRITIQUE_SCHEMA`` so + the backend returns discrete enum values rather than prose — this is + load-bearing for the reset-on-change logic in ``generate_subtask``, + which previously thrashed on tiny wording variations in free-form + critique output. + """ + if self.plans is None or self.plan_statuses is None: + raise RuntimeError("plan_critique called before generate_plan — no plan to critique") + + last_subtask = self.subtask_history[-1] if self.subtask_history else "None" + numbered_plan = "\n".join(f" {i + 1}. {step}" for i, step in enumerate(self.plans)) + current_state = "\n".join( + f" {i + 1}. {status}" for i, status in enumerate(self.plan_statuses) + ) + user_prompt = ( + f"You are given the task of '{task}'. The plan has these steps in order:\n" + f"{numbered_plan}\n" + f"\nTheir current statuses are:\n" + f"{current_state}\n" + f"\nThe last high-level objective given to the robot was '{last_subtask}'. " + "Looking at the images, update each step's status to one of " + f"{list(_STATUS_VALUES)}. Respond with a JSON object " + '{"statuses": [...]} containing exactly ' + f"{len(self.plans)} status values in the same order as the steps." + ) + response = self._chat(user_prompt, self._sampled_history(), response_schema=CRITIQUE_SCHEMA) + new_statuses = _extract_statuses_field(response, expected_len=len(self.plans)) + if new_statuses is None: + logger.warning( + "plan_critique response was not a valid statuses list of length %d; " + "keeping previous statuses. Raw response: %r", + len(self.plans), + response, + ) + return list(self.plan_statuses) + return new_statuses + + def generate_subtask(self, task: str, images: list[np.ndarray]) -> str: + """Produce the next high-level subtask given the current observation. + + On the first call of an episode this also bootstraps the plan. + Mirrors the control flow of + ``openpi-comet/src/openpi/shared/client.py:294`` but avoids double- + pushing the current image into the history deque and uses structural + status comparison (not prose equality) to decide when to reset + ``subtask_history``. + + Prompt adapted from ``openpi-comet/src/openpi/shared/client.py:305``; + the subtask string is enforced via ``SUBTASK_SCHEMA`` to keep the + output short enough for the pi0.5 action prompt budget. + """ + if not images: + raise ValueError("generate_subtask requires at least one image") + + if self.plans is None: + # First frame of the episode — bootstrap the plan. generate_plan + # already pushes initial_image into the history deque. + self.generate_plan(task, images[0]) + self.subtask_history = [] + for extra in images[1:]: + self.history_multi_modals.append(extra) + else: + for img in images: + self.history_multi_modals.append(img) + # Once the plan is fully complete, subsequent critique/subtask + # calls produce degenerate output ("finished", "none") because + # there is nothing left to plan. Skip the VLM calls entirely and + # reuse the last real subtask so the action policy still gets a + # meaningful prompt. + if all_steps_done(self.plan_statuses): + last_subtask = self.subtask_history[-1] if self.subtask_history else task + self.subtask_history.append(last_subtask) + return last_subtask + if self.subtask_history: + updated_statuses = self.plan_critique(task) + if updated_statuses != self.plan_statuses: + self.subtask_history = [] + self.plan_statuses = updated_statuses + + # Unreachable in practice: generate_plan always sets plans/statuses, + # and we took the "first frame" branch above when plans was None. + assert self.plans is not None and self.plan_statuses is not None + + rendered_plan_status = render_plan_status(self.plans, self.plan_statuses) + last_subtask = self.subtask_history[-1] if self.subtask_history else "None" + user_prompt = ( + f"You are given the task of '{task}'. The status of the plans are:\n" + f" {rendered_plan_status}\n" + f" Note that [-] indicates in progress. [o] indicates completed. [x] indicates not started.\n" + f" The last high-level objective given to the robot was '{last_subtask}'." + f"Based on your analysis, what should be the next high-level objective the robot should achieve? " + 'Respond with a JSON object {"subtask": "..."} where the value is ' + "a 3-6 word lowercase imperative phrase." + ) + response = self._chat(user_prompt, self._sampled_history(), response_schema=SUBTASK_SCHEMA) + subtask = _extract_subtask_field(response) + self.subtask_history.append(subtask) + return subtask + + +def _extract_subtask_field(response: str) -> str: + """Pull the ``subtask`` field from a JSON object response. + + Falls back to the cleaned raw text if parsing fails or the field is + missing so we never lose the data the model actually returned. + """ + cleaned = strip_think_tags(response).strip() + if cleaned.startswith("```"): + cleaned = re.sub(r"^```[a-zA-Z]*\n?", "", cleaned) + cleaned = re.sub(r"\n?```\s*$", "", cleaned) + try: + obj = json.loads(cleaned) + except json.JSONDecodeError: + logger.warning("Subtask response was not valid JSON; using raw text. Got: %r", cleaned) + return cleaned + if isinstance(obj, dict) and isinstance(obj.get("subtask"), str): + return obj["subtask"].strip() + logger.warning("Subtask JSON missing 'subtask' string field; using raw text. Got: %r", obj) + return cleaned + + +def _extract_statuses_field(response: str, expected_len: int) -> list[PlanStepStatus] | None: + """Pull the ``statuses`` array from a JSON critique response. + + Returns a list of exactly ``expected_len`` validated ``PlanStepStatus`` + values, or ``None`` when parsing fails, the array is missing, lengths + don't match, or any element isn't a known status. Callers should keep + the prior plan_statuses on ``None`` rather than reset them. + """ + cleaned = strip_think_tags(response).strip() + if cleaned.startswith("```"): + cleaned = re.sub(r"^```[a-zA-Z]*\n?", "", cleaned) + cleaned = re.sub(r"\n?```\s*$", "", cleaned) + try: + obj = json.loads(cleaned) + except json.JSONDecodeError: + return None + if not isinstance(obj, dict): + return None + statuses = obj.get("statuses") + if not isinstance(statuses, list) or len(statuses) != expected_len: + return None + validated: list[PlanStepStatus] = [] + for item in statuses: + if item not in _STATUS_VALUES: + return None + validated.append(item) + return validated diff --git a/experiments/subtask_probe/droid_eval/comet_style/run.py b/experiments/subtask_probe/droid_eval/comet_style/run.py new file mode 100644 index 0000000..6ac9ed4 --- /dev/null +++ b/experiments/subtask_probe/droid_eval/comet_style/run.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +"""Phase 1 (alt 2): Comet-style hierarchical subtask generation for DROID frames. + +Runs a stateful plan -> critique -> subtask loop per episode, ported from +``openpi-comet/src/openpi/shared/client.py``. Supports two backends: + + * ``--backend gemini`` — Gemini Robotics-ER 1.6 Preview (default). + * ``--backend openai_compat`` — any OpenAI-compatible chat-completions + server (e.g. a local vLLM hosting + Qwen3-VL-30B-A3B-Instruct). + +Output JSON schema matches ``generate_subtasks_gemini.py`` so ``run_action_eval``, +``compute_metrics`` and ``visualize_results`` consume it unchanged. + +Usage: + # Gemini backend (requires GEMINI_API_KEY) + uv run python -m experiments.subtask_probe.droid_eval.comet_style.run \\ + --samples_dir ./.experiments_cache/droid_eval_2min \\ + --output ./.experiments_cache/droid_eval_2min/subtasks_comet_gemini.json \\ + --backend gemini + + # OpenAI-compatible backend (vLLM hosting Qwen3-VL-30B) + uv run python -m experiments.subtask_probe.droid_eval.comet_style.run \\ + --samples_dir ./.experiments_cache/droid_eval_2min \\ + --output ./.experiments_cache/droid_eval_2min/subtasks_comet_qwen.json \\ + --backend openai_compat \\ + --base_url http://localhost:8000/v1 \\ + --model Qwen/Qwen3-VL-30B-A3B-Instruct +""" + +from __future__ import annotations + +import argparse +import json +import logging +import time +from pathlib import Path +from typing import Any, Literal, assert_never, cast, get_args + +import numpy as np +from dotenv import load_dotenv + +from experiments.subtask_probe.droid_eval.utils import load_manifest + +from .reasoner_base import BaseReasoner + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + +Backend = Literal["gemini", "openai_compat"] +BACKEND_CHOICES: tuple[Backend, ...] = get_args(Backend) + + +def _parse_backend(raw: str) -> Backend: + """Narrow the argparse string into the ``Backend`` literal union. + + argparse's ``choices=`` already rejects bad values at runtime; this + function exists to carry that proof into the type system. + """ + if raw in BACKEND_CHOICES: + return cast(Backend, raw) + raise ValueError(f"Unknown backend: {raw!r} (expected one of {BACKEND_CHOICES})") + + +def _build_reasoner( + backend: Backend, + args: argparse.Namespace, +) -> tuple[BaseReasoner, str]: + """Instantiate the requested backend and return (reasoner, backend_label). + + ``backend_label`` gets written into the output JSON's ``backend`` field so + downstream tooling can tell runs apart. + """ + match backend: + case "gemini": + from .gemini_reasoner import DEFAULT_MODEL as GEMINI_DEFAULT + from .gemini_reasoner import GeminiReasoner + + model = args.model or GEMINI_DEFAULT + reasoner: BaseReasoner = GeminiReasoner( + model=model, + thinking_budget=args.thinking_budget, + max_retries=args.max_retries, + history_maxlen=args.history_maxlen, + sampled_images_max=args.sampled_images_max, + history_stride=args.history_stride, + ) + return reasoner, model + case "openai_compat": + from .openai_compat_reasoner import DEFAULT_BASE_URL, OpenAICompatReasoner + from .openai_compat_reasoner import DEFAULT_MODEL as OAI_DEFAULT + + base_url = args.base_url or DEFAULT_BASE_URL + model = args.model or OAI_DEFAULT + reasoner = OpenAICompatReasoner( + base_url=base_url, + model=model, + api_key=args.api_key, + history_maxlen=args.history_maxlen, + sampled_images_max=args.sampled_images_max, + history_stride=args.history_stride, + ) + return reasoner, f"{model}@{base_url}" + case _ as unreachable: + assert_never(unreachable) + + +def _process_episode( + reasoner: BaseReasoner, + samples_dir: Path, + episode: dict[str, Any], + replan_every: int, +) -> list[dict[str, Any]]: + """Run the reasoner across all frames of one episode. + + Calls ``reasoner.reset()`` at the start. On replan frames the reasoner + issues 2 VLM calls (plan/critique + subtask); non-replan frames reuse + the last subtask text and make no VLM calls. + """ + reasoner.reset() + records: list[dict[str, Any]] = [] + episode_id = episode["episode_id"] + instruction = episode["instruction"] + last_subtask = "" + + for step_idx, frame_info in enumerate(episode["frames"]): + frame_idx = frame_info["frame_idx"] + frame_path = samples_dir / frame_info["file"] + frame_data = np.load(frame_path) + exterior_image = np.asarray(frame_data["exterior_image"], dtype=np.uint8) + + is_replan = step_idx % replan_every == 0 + elapsed = 0.0 + if is_replan: + start = time.time() + try: + last_subtask = reasoner.generate_subtask(instruction, [exterior_image]) + except Exception as exc: + logger.warning( + "Reasoner call failed for %s frame %d: %s", + episode_id, + frame_idx, + exc, + ) + last_subtask = "" + elapsed = time.time() - start + + records.append( + { + "episode_id": episode_id, + "frame_idx": frame_idx, + "instruction": instruction, + "subtask_text": last_subtask, + "generation_time_s": round(elapsed, 2), + "server_subtask_ms": round(elapsed * 1000, 1), + } + ) + + if step_idx % 5 == 0 or step_idx == len(episode["frames"]) - 1: + logger.info( + "[%s] frame %d/%d: %r (%.1fs, replan=%s)", + episode_id, + step_idx + 1, + len(episode["frames"]), + last_subtask, + elapsed, + is_replan, + ) + + logger.info( + "[%s] done — final plan_status:\n%s", + episode_id, + reasoner.plan_status or "", + ) + return records + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Comet-style hierarchical subtask generation for DROID frames" + ) + parser.add_argument("--samples_dir", type=str, required=True) + parser.add_argument("--output", type=str, required=True) + parser.add_argument( + "--backend", + choices=list(BACKEND_CHOICES), + default="gemini", + ) + parser.add_argument( + "--model", + type=str, + default=None, + help="Model ID. Defaults: gemini-robotics-er-1.6-preview (gemini) / " + "Qwen/Qwen3-VL-30B-A3B-Instruct (openai_compat).", + ) + parser.add_argument( + "--base_url", + type=str, + default=None, + help="OpenAI-compatible server URL (openai_compat backend only). " + "Default: http://localhost:8000/v1", + ) + parser.add_argument( + "--api_key", + type=str, + default="none", + help="API key for openai_compat backend (local vLLM ignores this)", + ) + parser.add_argument( + "--thinking_budget", + type=int, + default=0, + help="Gemini thinking budget (gemini backend only; 0 disables thinking)", + ) + parser.add_argument( + "--max_retries", + type=int, + default=10, + help="Per-call retry budget for 429 (gemini backend only)", + ) + parser.add_argument( + "--replan_every", + type=int, + default=1, + help=( + "Issue a new plan+critique+subtask every N cached frames; " + "intermediate frames reuse the previous subtask text. Default 1." + ), + ) + parser.add_argument( + "--history_maxlen", + type=int, + default=640, + help="Per-episode image history deque size. Default matches Comet's 64*10.", + ) + parser.add_argument( + "--sampled_images_max", + type=int, + default=64, + help="Max images sampled from history per VLM call.", + ) + parser.add_argument( + "--history_stride", + type=int, + default=5, + help=( + "Stride used when sampling history for each VLM call. Default " + "5 matches Comet's original hardcoded value and empirically " + "gives the best plan-stability on our 1 Hz cache because the " + "wider temporal span provides the contrast signal the reasoner " + "needs to detect progression. stride=1 over-interprets " + "frame-to-frame motion on slow tasks — see FINDINGS.md." + ), + ) + parser.add_argument( + "--max_episodes", + type=int, + default=None, + help="Only process the first N episodes from the manifest (for triage runs).", + ) + return parser.parse_args() + + +def main() -> None: + load_dotenv() + args = _parse_args() + + samples_dir = Path(args.samples_dir) + manifest = load_manifest(samples_dir) + if args.max_episodes is not None: + manifest = manifest[: args.max_episodes] + logger.info("Loaded manifest: %d episodes", len(manifest)) + + backend = _parse_backend(args.backend) + reasoner, backend_label = _build_reasoner(backend, args) + logger.info( + "Backend=%s model=%s replan_every=%d history_maxlen=%d sampled_images_max=%d", + backend, + backend_label, + args.replan_every, + args.history_maxlen, + args.sampled_images_max, + ) + + all_records: list[dict[str, Any]] = [] + for episode in manifest: + logger.info( + "Starting episode %s (%d frames): %r", + episode["episode_id"], + len(episode["frames"]), + episode["instruction"], + ) + all_records.extend( + _process_episode(reasoner, samples_dir, episode, replan_every=args.replan_every) + ) + + output_path = Path(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("w") as f: + json.dump( + { + "prompt_format": "comet_style_hierarchical", + "backend": backend_label, + "results": all_records, + }, + f, + indent=2, + ) + + logger.info("Saved %d records to %s (backend=%s)", len(all_records), output_path, backend_label) + + gen_times = [r["generation_time_s"] for r in all_records if r["generation_time_s"] > 0] + if gen_times: + logger.info( + "Latency: mean=%.2fs, min=%.2fs, max=%.2fs (replan calls only)", + float(np.mean(gen_times)), + float(np.min(gen_times)), + float(np.max(gen_times)), + ) + unique_subtasks = {r["subtask_text"] for r in all_records if r["subtask_text"]} + logger.info("Unique subtask texts: %d out of %d frames", len(unique_subtasks), len(all_records)) + failed = sum(1 for r in all_records if not r["subtask_text"]) + if failed: + logger.warning("Empty subtasks: %d / %d frames", failed, len(all_records)) + + +if __name__ == "__main__": + main() diff --git a/experiments/subtask_probe/droid_eval/compute_metrics.py b/experiments/subtask_probe/droid_eval/compute_metrics.py new file mode 100644 index 0000000..5536561 --- /dev/null +++ b/experiments/subtask_probe/droid_eval/compute_metrics.py @@ -0,0 +1,372 @@ +#!/usr/bin/env python3 +"""Phase 3: Compute metrics comparing action conditions against ground truth. + +Loads ground truth action chunks and predicted actions from all 3 conditions, +computes L2 distance, cosine similarity, per-dimension MAE, and gripper accuracy. + +Usage: + uv run python experiments/subtask_probe/droid_eval/compute_metrics.py \ + --samples_dir ./.experiments_cache/droid_eval \ + --predictions_dir ./.experiments_cache/droid_eval/predictions \ + --output ./.experiments_cache/droid_eval/results.json +""" + +from __future__ import annotations + +import argparse +import json +import logging +from pathlib import Path +from typing import Any, TypedDict + +import numpy as np + +from .constants import CONDITION_NAMES, GRIPPER_THRESHOLD, JOINT_NAMES +from .utils import load_manifest + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + + +class ConditionMetrics(TypedDict): + l2_distance: float + cosine_similarity: float + per_dim_mae: list[float] + gripper_accuracy: float + per_step_l2: list[float] + + +def compute_frame_metrics( + ground_truth: np.ndarray, + predictions: dict[str, np.ndarray], +) -> dict[str, ConditionMetrics]: + """Compute per-condition metrics for a single frame. + + Args: + ground_truth: [action_horizon, 8] ground truth action chunk + predictions: dict mapping condition name → [action_horizon, 8] predicted actions + """ + results = {} + + for condition_name, pred in predictions.items(): + # Ensure shapes match + min_horizon = min(ground_truth.shape[0], pred.shape[0]) + gt = ground_truth[:min_horizon] + pr = pred[:min_horizon] + + # L2 distance (over entire action chunk) + l2_distance = float(np.linalg.norm(gt - pr)) + + # Cosine similarity (flatten both) + gt_flat = gt.flatten() + pr_flat = pr.flatten() + cos_sim = float( + np.dot(gt_flat, pr_flat) / (np.linalg.norm(gt_flat) * np.linalg.norm(pr_flat) + 1e-10) + ) + + # Per-dimension MAE + per_dim_mae = np.mean(np.abs(gt - pr), axis=0).tolist() # [8] + + # Gripper accuracy (binary: open/closed) + gt_gripper = (gt[:, -1] > GRIPPER_THRESHOLD).astype(int) + pred_gripper = (pr[:, -1] > GRIPPER_THRESHOLD).astype(int) + gripper_accuracy = float(np.mean(gt_gripper == pred_gripper)) + + # Per-timestep L2 (for trajectory analysis) + per_step_l2 = np.linalg.norm(gt - pr, axis=-1).tolist() # [min_horizon] + + results[condition_name] = { + "l2_distance": l2_distance, + "cosine_similarity": cos_sim, + "per_dim_mae": per_dim_mae, + "gripper_accuracy": gripper_accuracy, + "per_step_l2": per_step_l2, + } + + return results + + +def aggregate_metrics( + all_frame_metrics: list[dict[str, ConditionMetrics]], + task_types: list[str], + episode_progress: list[float], +) -> dict[str, Any]: + """Aggregate per-frame metrics into summary statistics.""" + results = {} + + for condition in CONDITION_NAMES: + l2_distances = [fm[condition]["l2_distance"] for fm in all_frame_metrics] + cos_sims = [fm[condition]["cosine_similarity"] for fm in all_frame_metrics] + gripper_accs = [fm[condition]["gripper_accuracy"] for fm in all_frame_metrics] + per_dim_maes = np.array([fm[condition]["per_dim_mae"] for fm in all_frame_metrics]) + + results[condition] = { + "overall": { + "l2_distance": { + "mean": float(np.mean(l2_distances)), + "std": float(np.std(l2_distances)), + }, + "cosine_similarity": { + "mean": float(np.mean(cos_sims)), + "std": float(np.std(cos_sims)), + }, + "gripper_accuracy": { + "mean": float(np.mean(gripper_accs)), + "std": float(np.std(gripper_accs)), + }, + "per_dim_mae": { + name: float(per_dim_maes[:, i].mean()) + for i, name in enumerate(JOINT_NAMES[: per_dim_maes.shape[1]]) + }, + "n_frames": len(l2_distances), + }, + } + + # By task type + for task_type in ["multi_step", "single_step"]: + mask = [t == task_type for t in task_types] + if not any(mask): + continue + + type_l2 = [d for d, m in zip(l2_distances, mask, strict=True) if m] + type_cos = [d for d, m in zip(cos_sims, mask, strict=True) if m] + type_gripper = [d for d, m in zip(gripper_accs, mask, strict=True) if m] + + results[condition][f"task_type_{task_type}"] = { + "l2_distance": {"mean": float(np.mean(type_l2)), "std": float(np.std(type_l2))}, + "cosine_similarity": { + "mean": float(np.mean(type_cos)), + "std": float(np.std(type_cos)), + }, + "gripper_accuracy": { + "mean": float(np.mean(type_gripper)), + "std": float(np.std(type_gripper)), + }, + "n_frames": len(type_l2), + } + + # By episode progress (early/middle/late) + for label, low, high in [ + ("early", 0.0, 0.33), + ("middle", 0.33, 0.67), + ("late", 0.67, 1.01), + ]: + mask = [low <= p < high for p in episode_progress] + if not any(mask): + continue + + prog_l2 = [d for d, m in zip(l2_distances, mask, strict=True) if m] + prog_cos = [d for d, m in zip(cos_sims, mask, strict=True) if m] + + results[condition][f"progress_{label}"] = { + "l2_distance": {"mean": float(np.mean(prog_l2)), "std": float(np.std(prog_l2))}, + "cosine_similarity": { + "mean": float(np.mean(prog_cos)), + "std": float(np.std(prog_cos)), + }, + "n_frames": len(prog_l2), + } + + # Pairwise comparisons (paired differences) + pairwise = {} + for condition_a, condition_b in [ + ("baseline", "subtask"), + ]: + l2_a = np.array([fm[condition_a]["l2_distance"] for fm in all_frame_metrics]) + l2_b = np.array([fm[condition_b]["l2_distance"] for fm in all_frame_metrics]) + diff = l2_a - l2_b # positive = condition_b is closer to ground truth + + pairwise[f"{condition_a}_vs_{condition_b}"] = { + "l2_diff_mean": float(np.mean(diff)), + "l2_diff_std": float(np.std(diff)), + "pct_b_better": float(np.mean(diff > 0) * 100), + "pct_a_better": float(np.mean(diff < 0) * 100), + } + + # Wilcoxon signed-rank test + try: + from scipy.stats import wilcoxon + + stat, p_value = wilcoxon(l2_a, l2_b, alternative="two-sided") + pairwise[f"{condition_a}_vs_{condition_b}"]["wilcoxon_p"] = float(p_value) + pairwise[f"{condition_a}_vs_{condition_b}"]["wilcoxon_stat"] = float(stat) + except ImportError: + logger.warning("scipy not available, skipping Wilcoxon test") + except ValueError as e: + logger.warning("Wilcoxon test failed: %s", e) + + results["pairwise"] = pairwise + return results + + +def print_summary(results: dict[str, Any]) -> None: + """Print a human-readable summary table.""" + print("\n" + "=" * 80) + print(" DROID EVALUATION RESULTS") + print("=" * 80) + + # Overall metrics table + print(f"\n {'Condition':<15} {'L2 Dist':>12} {'Cos Sim':>12} {'Grip Acc':>12} {'N':>6}") + print(f" {'-' * 57}") + + for condition in CONDITION_NAMES: + overall = results[condition]["overall"] + l2 = overall["l2_distance"] + cos = overall["cosine_similarity"] + grip = overall["gripper_accuracy"] + n = overall["n_frames"] + print( + f" {condition:<15} {l2['mean']:>8.4f}+-{l2['std']:<4.4f}" + f" {cos['mean']:>8.4f}+-{cos['std']:<4.4f}" + f" {grip['mean']:>8.4f}+-{grip['std']:<4.4f}" + f" {n:>6}" + ) + + # Per-dimension MAE + print("\n Per-dimension MAE:") + print(f" {'Condition':<15}", end="") + for name in JOINT_NAMES: + print(f" {name:>8}", end="") + print() + print(f" {'-' * (15 + 8 * len(JOINT_NAMES))}") + + for condition in CONDITION_NAMES: + per_dim = results[condition]["overall"]["per_dim_mae"] + print(f" {condition:<15}", end="") + for name in JOINT_NAMES: + if name in per_dim: + print(f" {per_dim[name]:>8.4f}", end="") + print() + + # Task type breakdown + for task_type in ["multi_step", "single_step"]: + key = f"task_type_{task_type}" + if key not in results["baseline"]: + continue + + print(f"\n {task_type.replace('_', ' ').title()} Tasks:") + print(f" {'Condition':<15} {'L2 Dist':>12} {'Cos Sim':>12} {'N':>6}") + print(f" {'-' * 45}") + + for condition in CONDITION_NAMES: + if key not in results[condition]: + continue + data = results[condition][key] + l2 = data["l2_distance"] + cos = data["cosine_similarity"] + n = data["n_frames"] + print( + f" {condition:<15} {l2['mean']:>8.4f}+-{l2['std']:<4.4f}" + f" {cos['mean']:>8.4f}+-{cos['std']:<4.4f}" + f" {n:>6}" + ) + + # Pairwise comparisons + print("\n Pairwise Comparisons:") + print(f" {'Comparison':<30} {'L2 Diff':>10} {'% B Better':>12} {'Wilcoxon p':>12}") + print(f" {'-' * 64}") + + for comparison, data in results["pairwise"].items(): + p_str = f"{data['wilcoxon_p']:.4f}" if "wilcoxon_p" in data else "N/A" + print( + f" {comparison:<30} {data['l2_diff_mean']:>10.4f}" + f" {data['pct_b_better']:>11.1f}%" + f" {p_str:>12}" + ) + + print("\n" + "=" * 80) + + # Interpretation + pairwise = results["pairwise"] + comparison = pairwise.get("baseline_vs_subtask", {}) + + print("\n Interpretation:") + if comparison.get("l2_diff_mean", 0) > 0: + print(" - Subtask produces actions CLOSER to ground truth than baseline (L2 diff > 0)") + else: + print(" - Subtask produces actions FARTHER from ground truth than baseline") + + p = comparison.get("wilcoxon_p", 1.0) + if p < 0.05: + print(f" - Difference is statistically significant (p={p:.4f})") + else: + print(f" - Difference is NOT statistically significant (p={p:.4f})") + + print() + + +def main() -> None: + parser = argparse.ArgumentParser(description="Compute eval metrics") + parser.add_argument("--samples_dir", type=str, required=True) + parser.add_argument("--predictions_dir", type=str, required=True) + parser.add_argument("--output", type=str, required=True) + args = parser.parse_args() + + samples_dir = Path(args.samples_dir) + predictions_dir = Path(args.predictions_dir) + + # Load manifests + manifest = load_manifest(samples_dir) + + with (predictions_dir / "prediction_manifest.json").open() as f: + pred_manifest = json.load(f) + + # Build episode metadata index + episode_metadata = {} + for episode in manifest: + episode_metadata[episode["episode_id"]] = episode + + # Process all frames + all_frame_metrics = [] + task_types = [] + episode_progress_values = [] + + for pred_entry in pred_manifest: + episode_id = pred_entry["episode_id"] + frame_idx = pred_entry["frame_idx"] + episode = episode_metadata[episode_id] + + # Load ground truth + frame_file = samples_dir / next( + f["file"] for f in episode["frames"] if f["frame_idx"] == frame_idx + ) + frame_data = np.load(frame_file) + ground_truth = frame_data["ground_truth_actions"] # [15, 8] + + # Load predictions + pred_file = predictions_dir / pred_entry["prediction_file"] + pred_data = np.load(pred_file) + + predictions = { + "baseline": pred_data["baseline"], + "subtask": pred_data["subtask"], + } + + # Compute metrics + frame_metrics = compute_frame_metrics(ground_truth, predictions) + all_frame_metrics.append(frame_metrics) + task_types.append(episode["task_type"]) + + # Episode progress: where in the trajectory is this frame? + progress = frame_idx / max(episode["traj_len"] - 1, 1) + episode_progress_values.append(progress) + + logger.info("Computed metrics for %d frames", len(all_frame_metrics)) + + # Aggregate + results = aggregate_metrics(all_frame_metrics, task_types, episode_progress_values) + + # Save results + output_path = Path(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("w") as f: + json.dump(results, f, indent=2) + + logger.info("Results saved to %s", output_path) + + # Print summary + print_summary(results) + + +if __name__ == "__main__": + main() diff --git a/experiments/subtask_probe/droid_eval/constants.py b/experiments/subtask_probe/droid_eval/constants.py new file mode 100644 index 0000000..5eec722 --- /dev/null +++ b/experiments/subtask_probe/droid_eval/constants.py @@ -0,0 +1,32 @@ +"""Shared constants for the DROID subtask evaluation pipeline.""" + +from typing import Literal + +# Inference mode — finite set of valid values for the "mode" field in observation dicts. +# "subtask_only": generate subtask text only (no action generation) +# "action_only": generate actions only (skip subtask generation) +InferenceMode = Literal["subtask_only", "action_only"] + +# DROID action dimensions +ACTION_HORIZON = 15 # Number of future action steps predicted per frame +DROID_ACTION_DIM = 8 # 7 joints + 1 gripper +MODEL_ACTION_DIM = 32 # pi0.5 internal latent action dimension + +# Evaluation conditions +CONDITION_NAMES = ["baseline", "subtask"] + +# Joint names for per-dimension metrics and visualization +JOINT_NAMES = ["j1", "j2", "j3", "j4", "j5", "j6", "j7", "gripper"] + +# Gripper state threshold (values above = closed, below = open) +GRIPPER_THRESHOLD = 0.5 + +# Visualization colors per condition +CONDITION_COLORS = { + "baseline": "#4a90d9", + "subtask": "#d94a4a", + "ground_truth": "#888888", +} + +# Default server ports +DEFAULT_QUIC_PORT = 5555 diff --git a/experiments/subtask_probe/droid_eval/extract_droid_samples.py b/experiments/subtask_probe/droid_eval/extract_droid_samples.py new file mode 100644 index 0000000..5b14d8f --- /dev/null +++ b/experiments/subtask_probe/droid_eval/extract_droid_samples.py @@ -0,0 +1,403 @@ +#!/usr/bin/env python3 +"""Phase 0: Extract DROID samples from RLDS for evaluation. + +Streams episodes from gs://gresearch/robotics/droid/1.0.1, extracts frames +with images, proprioceptive state, language instructions, and ground truth +action chunks. Caches to local .npz files. + +Two selection modes: + +* **First-K** (default): take the first ``--num_episodes`` matches in stream + order. Fast and useful when the dataset is already well-shaped. + +* **Top-K longest** (``--scan_episodes N``): scan the first N episodes in the + stream, buffer qualifiers in an in-memory min-heap, save the + ``--num_episodes`` longest. Use for long-horizon evals where the raw stream + is dominated by short demos. Works well paired with ``--require_multi_step``. + +Usage: + # First-K (legacy behavior): + uv run python -m experiments.subtask_probe.droid_eval.extract_droid_samples \\ + --num_episodes 10 \\ + --output_dir ./.experiments_cache/droid_eval + + # Top-K longest with multi-step filter (long-horizon eval): + uv run python -m experiments.subtask_probe.droid_eval.extract_droid_samples \\ + --num_episodes 5 --scan_episodes 5000 \\ + --min_duration_s 60 --require_multi_step \\ + --output_dir ./.experiments_cache/droid_eval_2min +""" + +from __future__ import annotations + +import argparse +import heapq +import json +import logging +import re +from pathlib import Path +from typing import Any + +import numpy as np +import tqdm + +from .constants import ACTION_HORIZON +from .utils import decode_droid_image + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + +# DROID is recorded at 15 Hz — used to convert --min_duration_s to a step count. +DROID_FPS = 15 + +# Multi-step task keywords — tasks containing these are likely long-horizon +MULTI_STEP_KEYWORDS = re.compile( + r"\b(and then|then|after|pick.{1,20}place|put.{1,20}in|open.{1,20}put|grab.{1,20}move)\b", + re.IGNORECASE, +) + + +def is_multi_step_task(instruction: str) -> bool: + """Heuristic: does the instruction describe a multi-step task?""" + return bool(MULTI_STEP_KEYWORDS.search(instruction)) + + +def _decode_str(value: bytes | str) -> str: + return value.decode("utf-8") if isinstance(value, bytes) else value + + +def _extract_traj_fields(traj: dict[str, Any]) -> dict[str, Any] | None: + """Pull metadata + arrays from a raw numpy traj. + + Returns None if the episode is unusable (empty instruction, too short). + """ + file_path = _decode_str(traj["traj_metadata"]["episode_metadata"]["file_path"][0]) + instruction = _decode_str(traj["language_instruction"][0]).strip() + if not instruction or instruction.lower() in ("nan", "none"): + logger.debug("Skipping episode with empty instruction: %s", file_path) + return None + + actions_joint = traj["action_dict"]["joint_position"] # [T, 7] + actions_gripper = traj["action_dict"]["gripper_position"] # [T, 1] + actions = np.concatenate([actions_joint, actions_gripper], axis=-1) # [T, 8] + + traj_len = len(actions) + if traj_len < ACTION_HORIZON: + logger.debug("Skipping short episode (%d frames): %s", traj_len, file_path) + return None + + return { + "file_path": file_path, + "instruction": instruction, + "traj_len": int(traj_len), + "actions": actions, + "exterior_images": traj["observation"]["exterior_image_1_left"], + "wrist_images": traj["observation"]["wrist_image_left"], + "joint_positions": traj["observation"]["joint_position"], + "gripper_positions": traj["observation"]["gripper_position"], + } + + +def _passes_filters( + fields: dict[str, Any], min_duration_s: float, require_multi_step: bool +) -> bool: + min_traj_len = int(min_duration_s * DROID_FPS) + if fields["traj_len"] < min_traj_len: + return False + return not (require_multi_step and not is_multi_step_task(fields["instruction"])) + + +def _save_episode( + fields: dict[str, Any], + episode_id: str, + output_dir: Path, + frame_subsample: int, +) -> dict[str, Any]: + """Decode images + save npz frames for one episode. Returns the manifest entry.""" + episode_dir = output_dir / episode_id + episode_dir.mkdir(exist_ok=True) + + traj_len = fields["traj_len"] + actions = fields["actions"] + frame_indices = list(range(0, traj_len, frame_subsample)) + + frame_records = [] + for frame_idx in frame_indices: + # Decode images from JPEG bytes (kept at native DROID res; server pads to 224x224). + exterior_img = decode_droid_image(fields["exterior_images"][frame_idx]) + wrist_img = decode_droid_image(fields["wrist_images"][frame_idx]) + + # Ground truth action chunk: next ACTION_HORIZON actions, pad tail with last. + action_chunk_indices = np.minimum( + np.arange(frame_idx, frame_idx + ACTION_HORIZON), traj_len - 1 + ) + ground_truth_action_chunk = actions[action_chunk_indices] # [15, 8] + + state = np.concatenate( + [fields["joint_positions"][frame_idx], fields["gripper_positions"][frame_idx]] + ) # [8] + + frame_file = episode_dir / f"frame_{frame_idx:05d}.npz" + np.savez_compressed( + frame_file, + exterior_image=exterior_img, + wrist_image=wrist_img, + state=state, + ground_truth_actions=ground_truth_action_chunk, + frame_idx=frame_idx, + ) + + frame_records.append( + {"frame_idx": int(frame_idx), "file": str(frame_file.relative_to(output_dir))} + ) + + return { + "episode_id": episode_id, + "instruction": fields["instruction"], + "task_type": "multi_step" if is_multi_step_task(fields["instruction"]) else "single_step", + "file_path": fields["file_path"], + "traj_len": traj_len, + "num_frames": len(frame_records), + "frames": frame_records, + } + + +def _write_manifest(manifest: list[dict[str, Any]], output_dir: Path) -> None: + manifest_path = output_dir / "manifest.json" + with manifest_path.open("w") as f: + json.dump(manifest, f, indent=2) + + multi_step_count = sum(1 for ep in manifest if ep["task_type"] == "multi_step") + single_step_count = sum(1 for ep in manifest if ep["task_type"] == "single_step") + total_frames = sum(ep["num_frames"] for ep in manifest) + logger.info("Extraction complete:") + logger.info( + " Episodes: %d (%d multi-step, %d single-step)", + len(manifest), + multi_step_count, + single_step_count, + ) + logger.info(" Total frames: %d", total_frames) + logger.info(" Manifest: %s", manifest_path) + + +def _extract_first_k( + dataset: Any, + num_episodes: int, + output_dir: Path, + frame_subsample: int, + min_duration_s: float, + require_multi_step: bool, +) -> None: + """Stream-scan mode: save the first ``num_episodes`` qualifying episodes.""" + output_dir.mkdir(parents=True, exist_ok=True) + manifest: list[dict[str, Any]] = [] + + for traj in tqdm.tqdm(dataset.as_numpy_iterator(), desc="Extracting", total=num_episodes): + if len(manifest) >= num_episodes: + break + fields = _extract_traj_fields(traj) + if fields is None or not _passes_filters(fields, min_duration_s, require_multi_step): + continue + entry = _save_episode(fields, f"ep_{len(manifest):04d}", output_dir, frame_subsample) + manifest.append(entry) + logger.info( + "Episode %s (%d steps / %.1fs, %s): %r", + entry["episode_id"], + entry["traj_len"], + entry["traj_len"] / DROID_FPS, + entry["task_type"], + entry["instruction"][:80], + ) + + _write_manifest(manifest, output_dir) + + +def _extract_top_k( + dataset: Any, + num_episodes: int, + scan_episodes: int, + output_dir: Path, + frame_subsample: int, + min_duration_s: float, + require_multi_step: bool, +) -> None: + """Top-K longest mode: scan ``scan_episodes`` and keep the longest ``num_episodes`` matches. + + Uses a size-``num_episodes`` min-heap keyed on traj_len so memory stays bounded + (roughly ``num_episodes`` x episode_size in RAM). Episode size is ~20-40 MB for + a 1-2 minute DROID episode, so keeping 5 in memory is ~150 MB — fine. + """ + output_dir.mkdir(parents=True, exist_ok=True) + + # heap: (traj_len, insertion_index, fields_dict). insertion_index is a tiebreaker + # so heapq never tries to compare raw dicts when traj_len matches. + heap: list[tuple[int, int, dict[str, Any]]] = [] + scanned = 0 + qualifying = 0 + + for i, traj in enumerate( + tqdm.tqdm(dataset.as_numpy_iterator(), desc="Scanning", total=scan_episodes) + ): + if i >= scan_episodes: + break + scanned = i + 1 + fields = _extract_traj_fields(traj) + if fields is None or not _passes_filters(fields, min_duration_s, require_multi_step): + continue + qualifying += 1 + key = (fields["traj_len"], i, fields) + if len(heap) < num_episodes: + heapq.heappush(heap, key) + elif fields["traj_len"] > heap[0][0]: + heapq.heapreplace(heap, key) + + logger.info( + "Scan complete: %d episodes read, %d passed filters, keeping top %d by duration", + scanned, + qualifying, + len(heap), + ) + + # Sort longest-first so ep_0000 is the longest — easier to eyeball the manifest. + selected = sorted(heap, key=lambda entry: -entry[0]) + + manifest: list[dict[str, Any]] = [] + for episode_count, (_, _, fields) in enumerate(selected): + entry = _save_episode(fields, f"ep_{episode_count:04d}", output_dir, frame_subsample) + manifest.append(entry) + logger.info( + "Saved %s (%d steps / %.1fs, %s): %r", + entry["episode_id"], + entry["traj_len"], + entry["traj_len"] / DROID_FPS, + entry["task_type"], + entry["instruction"][:80], + ) + + _write_manifest(manifest, output_dir) + + +def extract_episodes( + data_dir: str, + num_episodes: int, + output_dir: Path, + frame_subsample: int = 10, + min_duration_s: float = 0.0, + require_multi_step: bool = False, + scan_episodes: int | None = None, +) -> None: + """Stream DROID episodes from GCS and cache selected frames.""" + # Lazy imports — tensorflow is heavy and optional + import dlimp as dl # ty: ignore[unresolved-import] + import tensorflow as tf # ty: ignore[unresolved-import] + import tensorflow_datasets as tfds # ty: ignore[unresolved-import] + + # Prevent TF from grabbing GPU + tf.config.set_visible_devices([], "GPU") + + logger.info("Building DROID RLDS dataset from %s ...", data_dir) + builder = tfds.builder("droid", data_dir=data_dir, version="1.0.1") + dataset = dl.DLataset.from_rlds(builder, split="train", shuffle=False, num_parallel_reads=4) + + # Filter for successful episodes only + dataset = dataset.filter( + lambda traj: tf.strings.regex_full_match( + traj["traj_metadata"]["episode_metadata"]["file_path"][0], + ".*success.*", + ) + ) + + if scan_episodes is None: + _extract_first_k( + dataset=dataset, + num_episodes=num_episodes, + output_dir=output_dir, + frame_subsample=frame_subsample, + min_duration_s=min_duration_s, + require_multi_step=require_multi_step, + ) + else: + _extract_top_k( + dataset=dataset, + num_episodes=num_episodes, + scan_episodes=scan_episodes, + output_dir=output_dir, + frame_subsample=frame_subsample, + min_duration_s=min_duration_s, + require_multi_step=require_multi_step, + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Extract DROID samples for evaluation") + parser.add_argument( + "--data_dir", + type=str, + default="gs://gresearch/robotics", + help=( + "GCS path that contains the `droid/` TFDS tree. The public DROID v1.0.1 " + "lives at gs://gresearch/robotics/droid/1.0.1, so the right data_dir is " + "`gs://gresearch/robotics`." + ), + ) + parser.add_argument( + "--num_episodes", + type=int, + default=10, + help="Number of episodes to save (start small to validate pipeline)", + ) + parser.add_argument( + "--output_dir", + type=str, + default="./.experiments_cache/droid_eval", + help="Local directory to cache extracted frames", + ) + parser.add_argument( + "--frame_subsample", + type=int, + default=10, + help="Take every Nth frame from each episode", + ) + parser.add_argument( + "--min_duration_s", + type=float, + default=0.0, + help=( + "Minimum episode duration in seconds. DROID runs at 15 Hz, so " + "e.g. 60 keeps only episodes >= 1:00. Default 0 keeps all." + ), + ) + parser.add_argument( + "--require_multi_step", + action="store_true", + help=( + "Keep only episodes whose language instruction matches the multi-step " + "keyword heuristic (and-then, pick…place, put…in, etc.)." + ), + ) + parser.add_argument( + "--scan_episodes", + type=int, + default=None, + help=( + "If set, enable top-K longest mode: scan this many stream episodes, buffer " + "qualifying ones in a min-heap, save the --num_episodes longest. If omitted, " + "use legacy first-K streaming." + ), + ) + args = parser.parse_args() + + extract_episodes( + data_dir=args.data_dir, + num_episodes=args.num_episodes, + output_dir=Path(args.output_dir), + frame_subsample=args.frame_subsample, + min_duration_s=args.min_duration_s, + require_multi_step=args.require_multi_step, + scan_episodes=args.scan_episodes, + ) + + +if __name__ == "__main__": + main() diff --git a/experiments/subtask_probe/droid_eval/foreact_eval/README.md b/experiments/subtask_probe/droid_eval/foreact_eval/README.md new file mode 100644 index 0000000..24e7730 --- /dev/null +++ b/experiments/subtask_probe/droid_eval/foreact_eval/README.md @@ -0,0 +1,80 @@ +# ForeAct reconstruction (inference-only) + +Faithful-as-possible reconstruction of [ForeAct (arxiv 2602.12322)](https://arxiv.org/abs/2602.12322) +on our DROID subtask-probe pipeline. **No training.** See the "ForeAct release +audit" section in `../../FINDINGS.md` for the scope-defining findings — short +version: the paper's released checkpoint is the foresight generator only; the +fine-tuned VLA is not released; and our pi0.5 action server has a fixed +2-image interface so we can't feed foresight to actions zero-shot. + +This module covers two of the paper's three components: + +| Component | File | Status | +|---|---|---| +| π_v — VLM planner (Table 5 prompts) | `planner.py` + `generate_subtasks.py` | faithful | +| π_g — foresight image generator | `generate_foresight.py` | faithful to released checkpoint, runs on remote GPU | +| VLA with augmented visual input | — | **unreachable without fine-tuning** | + +## Phase 1: Planner subtasks + +The planner uses the exact Table 5 prompts (initial + follow-up). Per-episode +state is literally just `previous_subtask: str | None`. We enforce a JSON +schema on the VLM output (`{"subtask": str, "previous_finished": bool}`) — +the paper only says "concise and deterministic"; the schema is our addition +for reliability and observability. + +```bash +# Local vLLM on US West 2 serving the paper's model (Qwen3-VL-8B-Instruct): +uv run python -m experiments.subtask_probe.droid_eval.foreact_eval.generate_subtasks \ + --samples_dir ./.experiments_cache/droid_eval_ah15 \ + --output ./.experiments_cache/droid_eval_ah15/subtasks_foreact_qwen8b.json \ + --backend openai_compat \ + --base_url http://localhost:8000/v1 \ + --model Qwen/Qwen3-VL-8B-Instruct +``` + +Backends: `openai_compat` (paper's setup), `gemini` (optional, for comparison +with our prior Comet-Gemini run). + +## Phase 2: Foresight image generator + +Runs the released `mit-han-lab/foreact-pretrained` checkpoint on our DROID +cache frames + the Phase 1 subtasks. **Must run on a GPU box** inside the +foreact conda env — the `diffusers`/`transformers`/`deepspeed` pins conflict +with our hosting project's deps. + +On the remote box (US West 2 L40S): +```bash +# After stopping the Qwen 8B vLLM to free VRAM: +conda activate foreact +huggingface-cli download mit-han-lab/foreact-pretrained --local-dir ~/foreact_ckpt + +python generate_foresight.py \ + --samples_dir ~/.experiments_cache/droid_eval_ah15 \ + --subtasks ~/.experiments_cache/droid_eval_ah15/subtasks_foreact_qwen8b.json \ + --output_dir ~/.experiments_cache/droid_eval_ah15/foresight_foreact \ + --checkpoint ~/foreact_ckpt +``` + +Then rsync the PNGs back to local: +```bash +rsync -av us-west-2-l40s:~/.experiments_cache/droid_eval_ah15/foresight_foreact/ \ + ./.experiments_cache/droid_eval_ah15/foresight_foreact/ +``` + +Paper's recommended inference hparams (from `foreact/app_cli.py`): +`guidance_scale=4.5`, `image_guidance_scale=1.5`, `num_inference_steps=8`. + +## Phase 3: Visualization + +HTML + mp4 with the third "predicted foresight" image column alongside +exterior / wrist / subtask: + +```bash +uv run python -m experiments.subtask_probe.droid_eval.foreact_eval.visualize_foreact \ + --samples_dir ./.experiments_cache/droid_eval_ah15 \ + --subtasks ./.experiments_cache/droid_eval_ah15/subtasks_foreact_qwen8b.json \ + --foresight_dir ./.experiments_cache/droid_eval_ah15/foresight_foreact \ + --output_dir ./.experiments_cache/droid_eval_ah15/foreact_report \ + [--video --fps 2] +``` diff --git a/experiments/subtask_probe/droid_eval/foreact_eval/__init__.py b/experiments/subtask_probe/droid_eval/foreact_eval/__init__.py new file mode 100644 index 0000000..c14396a --- /dev/null +++ b/experiments/subtask_probe/droid_eval/foreact_eval/__init__.py @@ -0,0 +1,6 @@ +"""Faithful ForeAct reconstruction on DROID (inference-only, no training). + +See ``README.md`` for scope and the ``FINDINGS.md`` ForeAct release-audit +section for why this module only covers π_v (planner) + π_g (foresight +generator) and not the end-to-end VLA integration the paper reports. +""" diff --git a/experiments/subtask_probe/droid_eval/foreact_eval/generate_foresight.py b/experiments/subtask_probe/droid_eval/foreact_eval/generate_foresight.py new file mode 100644 index 0000000..490080e --- /dev/null +++ b/experiments/subtask_probe/droid_eval/foreact_eval/generate_foresight.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +"""Run the ForeAct foresight image generator across DROID cache frames. + +IMPORTANT: this driver is designed to run **inside the foreact conda env on a +remote GPU box**, not inside our hosting project's Python env. It imports +``pipeline.VisualForesightPipeline`` and ``utils.trainer_utils`` from the +``/Users/kkuan/openpi/foreact/`` repo — those modules live in the foreact +env, not ours, which is why our linter ignores the imports. + +Typical workflow: + +1. On the remote box, clone the foreact repo and run its ``environment_setup.sh``. +2. ``huggingface-cli download mit-han-lab/foreact-pretrained --local-dir ~/foreact_ckpt``. +3. Copy our DROID cache + subtasks JSON to the box. +4. ``scp`` this script into the foreact repo directory. +5. ``conda activate foreact && python generate_foresight.py ...``. + +Uses the paper's recommended inference hparams (``foreact/app_cli.py``): +``guidance_scale=4.5``, ``image_guidance_scale=1.5``, ``num_inference_steps=8``. +""" + +from __future__ import annotations + +import argparse +import json +import logging +import random +import time +from pathlib import Path +from typing import Any + +import numpy as np +import torch +from PIL import Image + +# These imports resolve inside the foreact conda env, not ours. This script +# is meant to be run on the remote GPU box after ``conda activate foreact``. +from pipeline import VisualForesightPipeline # ty: ignore[unresolved-import] +from utils.trainer_utils import find_newest_checkpoint # ty: ignore[unresolved-import] + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + + +def _load_manifest(samples_dir: Path) -> list[dict[str, Any]]: + with (samples_dir / "manifest.json").open() as f: + return json.load(f) + + +def _load_subtask_records(path: Path) -> list[dict[str, Any]]: + with path.open() as f: + payload = json.load(f) + if isinstance(payload, dict) and "results" in payload: + return payload["results"] + if isinstance(payload, list): + return payload + raise ValueError(f"Unrecognized subtask JSON shape in {path}") + + +def _index_subtasks(records: list[dict[str, Any]]) -> dict[tuple[str, int], str]: + return {(r["episode_id"], r["frame_idx"]): r["subtask_text"] for r in records} + + +def _process_frame( + pipeline: VisualForesightPipeline, + exterior_image: np.ndarray, + subtask_text: str, + *, + guidance_scale: float, + image_guidance_scale: float, + num_inference_steps: int, + seed: int, +) -> tuple[Image.Image, float]: + """Generate one foresight image and return (image, elapsed_seconds).""" + pil_in = Image.fromarray(exterior_image).convert("RGB") + generator = torch.Generator().manual_seed(seed) + start = time.time() + out = pipeline( + caption=subtask_text, + image=pil_in, + guidance_scale=guidance_scale, + image_guidance_scale=image_guidance_scale, + num_inference_steps=num_inference_steps, + num_images_per_prompt=1, + generator=generator, + ).images + elapsed = time.time() - start + if not out: + raise RuntimeError("VisualForesightPipeline returned no images") + return out[0], elapsed + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="ForeAct foresight generator over DROID cache frames" + ) + parser.add_argument("--samples_dir", type=str, required=True) + parser.add_argument( + "--subtasks", + type=str, + required=True, + help="Path to a subtasks_*.json (e.g. from foreact_eval.generate_subtasks).", + ) + parser.add_argument( + "--output_dir", + type=str, + required=True, + help="Where to write foresight PNGs and foresight_manifest.json.", + ) + parser.add_argument( + "--checkpoint", + type=str, + required=True, + help="Local path to the foreact-pretrained checkpoint directory.", + ) + parser.add_argument("--guidance_scale", type=float, default=4.5) + parser.add_argument("--image_guidance_scale", type=float, default=1.5) + parser.add_argument("--num_inference_steps", type=int, default=8) + parser.add_argument( + "--max_episodes", + type=int, + default=None, + help="Only process the first N episodes (for smoke tests).", + ) + parser.add_argument("--seed", type=int, default=42) + return parser.parse_args() + + +def main() -> None: + args = _parse_args() + + samples_dir = Path(args.samples_dir) + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + manifest = _load_manifest(samples_dir) + if args.max_episodes is not None: + manifest = manifest[: args.max_episodes] + logger.info("Loaded manifest: %d episodes", len(manifest)) + + records = _load_subtask_records(Path(args.subtasks)) + subtask_index = _index_subtasks(records) + logger.info("Loaded %d subtask records", len(records)) + + logger.info("Loading VisualForesightPipeline from %s ...", args.checkpoint) + pipeline = VisualForesightPipeline.from_pretrained( + find_newest_checkpoint(args.checkpoint), + ignore_mismatched_sizes=True, + _gradient_checkpointing=False, + torch_dtype=torch.bfloat16, + ) + pipeline = pipeline.to(device="cuda", dtype=torch.bfloat16) + logger.info("Pipeline loaded.") + + random.seed(args.seed) + np.random.seed(args.seed) + torch.manual_seed(args.seed) + torch.cuda.manual_seed_all(args.seed) + + generation_records: list[dict[str, Any]] = [] + for episode in manifest: + episode_id = episode["episode_id"] + episode_dir = output_dir / episode_id + episode_dir.mkdir(parents=True, exist_ok=True) + logger.info("Starting episode %s (%d frames)", episode_id, len(episode["frames"])) + + for step_idx, frame_info in enumerate(episode["frames"]): + frame_idx = frame_info["frame_idx"] + subtask_text = subtask_index.get((episode_id, frame_idx), "") + if not subtask_text: + logger.warning("No subtask for %s frame %d; skipping", episode_id, frame_idx) + continue + + frame_path = samples_dir / frame_info["file"] + frame_data = np.load(frame_path) + exterior_image = np.asarray(frame_data["exterior_image"], dtype=np.uint8) + + # Per-frame seed so the generation is reproducible but each frame + # has independent noise. + frame_seed = (args.seed, episode_id, frame_idx).__hash__() & 0x7FFFFFFF + try: + image, elapsed = _process_frame( + pipeline, + exterior_image, + subtask_text, + guidance_scale=args.guidance_scale, + image_guidance_scale=args.image_guidance_scale, + num_inference_steps=args.num_inference_steps, + seed=frame_seed, + ) + except Exception as exc: + logger.warning( + "Foresight generation failed for %s frame %d: %s", + episode_id, + frame_idx, + exc, + ) + continue + + out_path = episode_dir / f"frame_{frame_idx:05d}.png" + image.save(out_path) + generation_records.append( + { + "episode_id": episode_id, + "frame_idx": frame_idx, + "subtask_text": subtask_text, + "output": str(out_path.relative_to(output_dir)), + "generation_time_s": round(elapsed, 3), + "seed": frame_seed, + } + ) + + if step_idx % 5 == 0 or step_idx == len(episode["frames"]) - 1: + logger.info( + "[%s] frame %d/%d: %r (%.2fs)", + episode_id, + step_idx + 1, + len(episode["frames"]), + subtask_text, + elapsed, + ) + + manifest_path = output_dir / "foresight_manifest.json" + with manifest_path.open("w") as f: + json.dump( + { + "hparams": { + "guidance_scale": args.guidance_scale, + "image_guidance_scale": args.image_guidance_scale, + "num_inference_steps": args.num_inference_steps, + "seed": args.seed, + "checkpoint": args.checkpoint, + }, + "records": generation_records, + }, + f, + indent=2, + ) + logger.info( + "Wrote %d foresight images + manifest to %s", + len(generation_records), + output_dir, + ) + + latencies = [r["generation_time_s"] for r in generation_records] + if latencies: + logger.info( + "Foresight latency: mean=%.2fs min=%.2fs max=%.2fs (n=%d)", + float(np.mean(latencies)), + float(np.min(latencies)), + float(np.max(latencies)), + len(latencies), + ) + + +if __name__ == "__main__": + main() diff --git a/experiments/subtask_probe/droid_eval/foreact_eval/generate_foresight_lerobot.py b/experiments/subtask_probe/droid_eval/foreact_eval/generate_foresight_lerobot.py new file mode 100644 index 0000000..ca16cdf --- /dev/null +++ b/experiments/subtask_probe/droid_eval/foreact_eval/generate_foresight_lerobot.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +"""Run the ForeAct foresight generator across a LeRobot-format dataset episode. + +Same generator, same hparams as ``generate_foresight.py``, but reads input +frames from LeRobot-v2.1 shards (mp4 videos + parquet) instead of our DROID +.npz cache. Used to test the pretrained generator on a dataset it was +*pretrained on* (Galaxea R1 Lite, `mit-han-lab/ForeActDataset`). + +This script runs inside the foreact conda env on the remote GPU box: + + ssh us-west-2 "cd ~/foreact && source .venv/bin/activate && \\ + python generate_foresight_lerobot.py \\ + --dataset_root ~/foreact_dataset/20251102_Pick_Veg \\ + --camera_key observation.images.head_left_rgb \\ + --output_dir ~/foresight_foreact_picksveg \\ + --checkpoint ~/foreact_ckpt \\ + --episode_indices 0,1,2,3,4 \\ + --stride 15" +""" + +from __future__ import annotations + +import argparse +import json +import logging +import random +import time +from pathlib import Path +from typing import Any + +import av +import numpy as np +import torch +from PIL import Image + +# Resolved inside the foreact conda env on the remote box. +from pipeline import VisualForesightPipeline # ty: ignore[unresolved-import] +from utils.trainer_utils import find_newest_checkpoint # ty: ignore[unresolved-import] + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + + +def _load_episode_index(dataset_root: Path) -> dict[int, dict[str, Any]]: + """Parse meta/episodes.jsonl into {episode_index: {tasks: [...], length: int}}.""" + by_idx: dict[int, dict[str, Any]] = {} + with (dataset_root / "meta" / "episodes.jsonl").open() as f: + for line in f: + row = json.loads(line) + by_idx[row["episode_index"]] = row + return by_idx + + +def _decode_mp4_frames(video_path: Path, stride: int) -> list[np.ndarray]: + """Return every `stride`-th frame from an mp4 as HxWx3 uint8 RGB arrays. + + LeRobot stores each camera as a single mp4 per episode; we decode all + frames sequentially and pick out the strided ones. For Galaxea R1 Lite at + 15 fps, stride=15 gives ~1 Hz — matches the paper's pretraining sampling + cadence (§3.2: "sample condition frames at 1-second intervals"). + """ + frames: list[np.ndarray] = [] + with av.open(str(video_path)) as container: + stream = container.streams.video[0] + stream.thread_type = "AUTO" + for idx, frame in enumerate(container.decode(stream)): + if idx % stride == 0: + frames.append(frame.to_ndarray(format="rgb24")) + return frames + + +def _process_frame( + pipeline: VisualForesightPipeline, + exterior_image: np.ndarray, + subtask_text: str, + *, + guidance_scale: float, + image_guidance_scale: float, + num_inference_steps: int, + seed: int, +) -> tuple[Image.Image, float]: + pil_in = Image.fromarray(exterior_image).convert("RGB") + generator = torch.Generator().manual_seed(seed) + start = time.time() + out = pipeline( + caption=subtask_text, + image=pil_in, + guidance_scale=guidance_scale, + image_guidance_scale=image_guidance_scale, + num_inference_steps=num_inference_steps, + num_images_per_prompt=1, + generator=generator, + ).images + elapsed = time.time() - start + if not out: + raise RuntimeError("VisualForesightPipeline returned no images") + return out[0], elapsed + + +def _parse_episode_indices(raw: str) -> list[int]: + return [int(x.strip()) for x in raw.split(",") if x.strip()] + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="ForeAct foresight over a LeRobot dataset") + parser.add_argument( + "--dataset_root", + type=str, + required=True, + help="Path to a LeRobot-v2.1 dataset dir containing meta/, data/, videos/.", + ) + parser.add_argument( + "--camera_key", + type=str, + default="observation.images.head_left_rgb", + help="Camera feature name. Default matches foreact/configs/finetune.yaml.", + ) + parser.add_argument( + "--episode_indices", + type=str, + default="0", + help="Comma-separated episode indices to process (e.g. '0,1,2,3,4').", + ) + parser.add_argument( + "--stride", + type=int, + default=15, + help="Decode every Nth frame (15 Hz dataset / stride=15 = ~1 Hz).", + ) + parser.add_argument("--output_dir", type=str, required=True) + parser.add_argument("--checkpoint", type=str, required=True) + parser.add_argument("--guidance_scale", type=float, default=4.5) + parser.add_argument("--image_guidance_scale", type=float, default=1.5) + parser.add_argument("--num_inference_steps", type=int, default=8) + parser.add_argument("--seed", type=int, default=42) + return parser.parse_args() + + +def main() -> None: + args = _parse_args() + dataset_root = Path(args.dataset_root) + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + episode_meta = _load_episode_index(dataset_root) + episode_indices = _parse_episode_indices(args.episode_indices) + for idx in episode_indices: + if idx not in episode_meta: + raise SystemExit(f"episode {idx} not in meta/episodes.jsonl") + logger.info( + "Dataset root=%s, processing %d episodes: %s", + dataset_root, + len(episode_indices), + episode_indices, + ) + + logger.info("Loading VisualForesightPipeline from %s ...", args.checkpoint) + pipeline = VisualForesightPipeline.from_pretrained( + find_newest_checkpoint(args.checkpoint), + ignore_mismatched_sizes=True, + _gradient_checkpointing=False, + torch_dtype=torch.bfloat16, + ) + pipeline = pipeline.to(device="cuda", dtype=torch.bfloat16) + logger.info("Pipeline loaded.") + + random.seed(args.seed) + np.random.seed(args.seed) + torch.manual_seed(args.seed) + torch.cuda.manual_seed_all(args.seed) + + generation_records: list[dict[str, Any]] = [] + for ep_idx in episode_indices: + meta = episode_meta[ep_idx] + tasks: list[str] = meta.get("tasks") or [] + # Skip episodes whose label is the "Finish." filler used as a between- + # task reset signal (task_index=1 in tasks.jsonl). + if not tasks or tasks[0].strip().lower().rstrip(".") == "finish": + logger.info("Skipping episode %d (task=%r)", ep_idx, tasks) + continue + subtask_text = tasks[0] + video_path = ( + dataset_root / "videos" / "chunk-000" / args.camera_key / f"episode_{ep_idx:06d}.mp4" + ) + if not video_path.exists(): + logger.warning("missing video: %s", video_path) + continue + + logger.info("Decoding %s ...", video_path) + frames = _decode_mp4_frames(video_path, stride=args.stride) + logger.info( + "Episode %d: %d strided frames (length=%d) — subtask=%r", + ep_idx, + len(frames), + meta.get("length"), + subtask_text, + ) + + episode_dir = output_dir / f"episode_{ep_idx:06d}" + episode_dir.mkdir(parents=True, exist_ok=True) + + # Also save the actual source frames so the downstream HTML / eyeball + # comparison doesn't need to re-decode the mp4 on the local box. + src_dir = episode_dir / "actual" + src_dir.mkdir(parents=True, exist_ok=True) + + for step_idx, frame_rgb in enumerate(frames): + frame_idx = step_idx * args.stride + Image.fromarray(frame_rgb).save(src_dir / f"frame_{frame_idx:05d}.png") + seed = (args.seed, ep_idx, frame_idx).__hash__() & 0x7FFFFFFF + try: + image, elapsed = _process_frame( + pipeline, + frame_rgb, + subtask_text, + guidance_scale=args.guidance_scale, + image_guidance_scale=args.image_guidance_scale, + num_inference_steps=args.num_inference_steps, + seed=seed, + ) + except Exception as exc: + logger.warning("episode %d frame %d failed: %s", ep_idx, frame_idx, exc) + continue + + out_path = episode_dir / f"frame_{frame_idx:05d}.png" + image.save(out_path) + generation_records.append( + { + "episode_index": ep_idx, + "frame_idx": frame_idx, + "subtask_text": subtask_text, + "output": str(out_path.relative_to(output_dir)), + "generation_time_s": round(elapsed, 3), + "seed": seed, + } + ) + + if step_idx % 3 == 0 or step_idx == len(frames) - 1: + logger.info( + "[ep%d] step %d/%d (frame %d): %.2fs", + ep_idx, + step_idx + 1, + len(frames), + frame_idx, + elapsed, + ) + + manifest_path = output_dir / "foresight_manifest.json" + with manifest_path.open("w") as f: + json.dump( + { + "dataset_root": str(dataset_root), + "camera_key": args.camera_key, + "stride": args.stride, + "hparams": { + "guidance_scale": args.guidance_scale, + "image_guidance_scale": args.image_guidance_scale, + "num_inference_steps": args.num_inference_steps, + "seed": args.seed, + "checkpoint": args.checkpoint, + }, + "records": generation_records, + }, + f, + indent=2, + ) + logger.info("Wrote %d foresight images to %s", len(generation_records), output_dir) + + latencies = [r["generation_time_s"] for r in generation_records] + if latencies: + logger.info( + "Foresight latency: mean=%.2fs min=%.2fs max=%.2fs (n=%d)", + float(np.mean(latencies)), + float(np.min(latencies)), + float(np.max(latencies)), + len(latencies), + ) + + +if __name__ == "__main__": + main() diff --git a/experiments/subtask_probe/droid_eval/foreact_eval/generate_subtasks.py b/experiments/subtask_probe/droid_eval/foreact_eval/generate_subtasks.py new file mode 100644 index 0000000..5cea030 --- /dev/null +++ b/experiments/subtask_probe/droid_eval/foreact_eval/generate_subtasks.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +"""Run the ForeAct VLM planner across DROID cache frames. + +Produces ``subtasks_foreact_*.json`` with the same per-frame schema other +subtask generators in this project emit, so ``visualize_foreact.py`` and any +future tooling consume it uniformly. + +Usage (OpenAI-compatible backend, paper's Qwen3-VL-8B-Instruct on local vLLM):: + + uv run python -m experiments.subtask_probe.droid_eval.foreact_eval.generate_subtasks \\ + --samples_dir ./.experiments_cache/droid_eval_ah15 \\ + --output ./.experiments_cache/droid_eval_ah15/subtasks_foreact_qwen8b.json \\ + --backend openai_compat \\ + --base_url http://localhost:8000/v1 \\ + --model Qwen/Qwen3-VL-8B-Instruct +""" + +from __future__ import annotations + +import argparse +import json +import logging +import time +from pathlib import Path +from typing import Any, Literal, assert_never, cast, get_args + +import numpy as np +from dotenv import load_dotenv + +from experiments.subtask_probe.droid_eval.foreact_eval.planner import ( + DEFAULT_BASE_URL, + DEFAULT_GEMINI_MODEL, + DEFAULT_OPENAI_MODEL, + BasePlanner, + GeminiPlanner, + OpenAICompatPlanner, +) +from experiments.subtask_probe.droid_eval.utils import load_manifest + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + + +Backend = Literal["openai_compat", "gemini"] +BACKEND_CHOICES: tuple[Backend, ...] = get_args(Backend) + + +def _parse_backend(raw: str) -> Backend: + if raw in BACKEND_CHOICES: + return cast(Backend, raw) + raise ValueError(f"Unknown backend: {raw!r} (expected one of {BACKEND_CHOICES})") + + +def _build_planner(backend: Backend, args: argparse.Namespace) -> tuple[BasePlanner, str]: + """Return (planner, backend_label) where backend_label goes into the output JSON.""" + match backend: + case "openai_compat": + base_url = args.base_url or DEFAULT_BASE_URL + model = args.model or DEFAULT_OPENAI_MODEL + planner: BasePlanner = OpenAICompatPlanner( + base_url=base_url, + model=model, + api_key=args.api_key, + ) + return planner, f"{model}@{base_url}" + case "gemini": + model = args.model or DEFAULT_GEMINI_MODEL + planner = GeminiPlanner( + model=model, + thinking_budget=args.thinking_budget, + max_retries=args.max_retries, + ) + return planner, model + case _ as unreachable: + assert_never(unreachable) + + +def _process_episode( + planner: BasePlanner, + samples_dir: Path, + episode: dict[str, Any], + replan_every: int, +) -> list[dict[str, Any]]: + """Run the planner across one episode, reset()-ing state at the start. + + On replan frames, one VLM call is issued. On non-replan frames, the last + subtask is reused without a VLM call. Non-replan latency is recorded as 0s + so the output JSON clearly distinguishes replan frames from reused frames. + """ + planner.reset() + records: list[dict[str, Any]] = [] + episode_id = episode["episode_id"] + instruction = episode["instruction"] + last_subtask = "" + last_previous_finished = False + last_prompt_phase = "" + + for step_idx, frame_info in enumerate(episode["frames"]): + frame_idx = frame_info["frame_idx"] + frame_path = samples_dir / frame_info["file"] + frame_data = np.load(frame_path) + exterior_image = np.asarray(frame_data["exterior_image"], dtype=np.uint8) + + is_replan = step_idx % replan_every == 0 + elapsed = 0.0 + if is_replan: + start = time.time() + try: + result = planner.generate_subtask(instruction, exterior_image) + last_subtask = result["subtask"] + last_previous_finished = result["previous_finished"] + last_prompt_phase = result["prompt_phase"] + except Exception as exc: + logger.warning( + "Planner call failed for %s frame %d: %s", + episode_id, + frame_idx, + exc, + ) + last_subtask = "" + last_previous_finished = False + last_prompt_phase = "error" + elapsed = time.time() - start + + records.append( + { + "episode_id": episode_id, + "frame_idx": frame_idx, + "instruction": instruction, + "subtask_text": last_subtask, + "generation_time_s": round(elapsed, 2), + "server_subtask_ms": round(elapsed * 1000, 1), + "previous_finished": last_previous_finished, + "prompt_phase": last_prompt_phase, + "is_replan": is_replan, + } + ) + + if step_idx % 5 == 0 or step_idx == len(episode["frames"]) - 1 or is_replan: + logger.info( + "[%s] frame %d/%d phase=%s finished=%s: %r (%.1fs)", + episode_id, + step_idx + 1, + len(episode["frames"]), + last_prompt_phase or "reuse", + last_previous_finished, + last_subtask, + elapsed, + ) + + return records + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="ForeAct VLM planner over DROID cache frames") + parser.add_argument("--samples_dir", type=str, required=True) + parser.add_argument("--output", type=str, required=True) + parser.add_argument( + "--backend", + choices=list(BACKEND_CHOICES), + default="openai_compat", + help="VLM backend. Default matches the paper's Qwen3-VL-8B via local vLLM.", + ) + parser.add_argument( + "--model", + type=str, + default=None, + help="Model ID. Defaults: Qwen/Qwen3-VL-8B-Instruct (openai_compat) / " + "gemini-robotics-er-1.6-preview (gemini).", + ) + parser.add_argument( + "--base_url", + type=str, + default=None, + help="OpenAI-compatible server URL. Default: http://localhost:8000/v1", + ) + parser.add_argument( + "--api_key", + type=str, + default="none", + help="API key for openai_compat backend (local vLLM ignores this).", + ) + parser.add_argument( + "--thinking_budget", + type=int, + default=0, + help="Gemini thinking budget (gemini backend only; 0 disables).", + ) + parser.add_argument( + "--max_retries", + type=int, + default=10, + help="Per-call retry budget for 429 / transient network errors (gemini backend).", + ) + parser.add_argument( + "--replan_every", + type=int, + default=1, + help=( + "Issue a new planner call every N cached frames; intermediate frames " + "reuse the previous subtask text. Default 1 — the paper implies " + "per-observation cadence." + ), + ) + parser.add_argument( + "--max_episodes", + type=int, + default=None, + help="Only process the first N episodes (for smoke tests).", + ) + return parser.parse_args() + + +def main() -> None: + load_dotenv() + args = _parse_args() + + samples_dir = Path(args.samples_dir) + manifest = load_manifest(samples_dir) + if args.max_episodes is not None: + manifest = manifest[: args.max_episodes] + logger.info("Loaded manifest: %d episodes", len(manifest)) + + backend = _parse_backend(args.backend) + planner, backend_label = _build_planner(backend, args) + logger.info( + "Backend=%s label=%s replan_every=%d", + backend, + backend_label, + args.replan_every, + ) + + all_records: list[dict[str, Any]] = [] + for episode in manifest: + logger.info( + "Starting episode %s (%d frames): %r", + episode["episode_id"], + len(episode["frames"]), + episode["instruction"], + ) + all_records.extend( + _process_episode(planner, samples_dir, episode, replan_every=args.replan_every) + ) + + output_path = Path(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("w") as f: + json.dump( + { + "prompt_format": "foreact_two_turn", + "backend": backend_label, + "results": all_records, + }, + f, + indent=2, + ) + logger.info("Saved %d records to %s (backend=%s)", len(all_records), output_path, backend_label) + + gen_times = [r["generation_time_s"] for r in all_records if r["generation_time_s"] > 0] + if gen_times: + logger.info( + "Planner latency: mean=%.2fs min=%.2fs max=%.2fs (replan calls only, n=%d)", + float(np.mean(gen_times)), + float(np.min(gen_times)), + float(np.max(gen_times)), + len(gen_times), + ) + unique_subtasks = {r["subtask_text"] for r in all_records if r["subtask_text"]} + logger.info("Unique subtask texts: %d / %d frames", len(unique_subtasks), len(all_records)) + empty = sum(1 for r in all_records if not r["subtask_text"]) + if empty: + logger.warning("Empty subtasks: %d / %d frames", empty, len(all_records)) + advances = sum(1 for r in all_records if r["previous_finished"]) + logger.info("Planner reported previous_finished=True on %d frames", advances) + + +if __name__ == "__main__": + main() diff --git a/experiments/subtask_probe/droid_eval/foreact_eval/planner.py b/experiments/subtask_probe/droid_eval/foreact_eval/planner.py new file mode 100644 index 0000000..8b4c5a3 --- /dev/null +++ b/experiments/subtask_probe/droid_eval/foreact_eval/planner.py @@ -0,0 +1,259 @@ +"""ForeAct VLM planner with the paper's Table 5 two-turn prompt. + +Ported verbatim from Appendix A.5 of the ForeAct paper (arxiv 2602.12322). +The prompt strings are part of the method — do not reword them. + +Per-episode state is minimal: just ``previous_subtask: str | None``. The +"reason-execute-monitor" cycle relies on the VLM re-deriving the plan +latently each turn; there is no explicit plan list or status marker. + +We additionally enforce a JSON schema on the response. The paper only says +"concise and deterministic", but our Comet experience showed free-form output +adds 500ms-3s of latency and causes semantic drift. The ``subtask`` field is +what the paper actually uses; the ``previous_finished`` bool is ours, added +purely for observability (so we can log when the planner advances). +""" + +from __future__ import annotations + +import base64 +import json +import logging +from abc import ABC, abstractmethod +from typing import Any, Literal, cast + +import numpy as np +from openai import OpenAI +from openai.types.chat import ChatCompletionMessageParam + +from experiments.subtask_probe.droid_eval.comet_style._gemini_utils import ( + call_with_retry, + encode_png, +) + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Prompts (Table 5, Appendix A.5 of foreact.pdf). Only ``{task}`` substitutes. +# --------------------------------------------------------------------------- + +INITIAL_PROMPT_TEMPLATE = """\ +You are a robot controller. Please plan to finish the task in several steps. \ +And give instruction for each step in a concise way. +The task is to "{task}". + +RULES: +\u2022 During the job, I will continuously give you an observation image of the current state. +\u2022 Based on the observation, please judge if the last instruction has been finished. + - If yes, give me the instruction for the next step. + - If no, repeat the instruction of the ongoing subtask. +\u2022 You're not required to describe the observation. Only output the instruction for each subtask. + +Now, you are only required to output instruction for the first step.""" + + +FOLLOW_UP_PROMPT_TEMPLATE = """\ +Pay attention to the latest observation. Firstly, judge if the last instruction \ +has been finished. Secondly, if yes, give me the instruction for the next step; \ +if no, repeat the instruction of the ongoing subtask. +Your answer should be concise and deterministic. +Remember, your Overall Task is "{task}".""" + + +SUBTASK_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "subtask": {"type": "string"}, + "previous_finished": {"type": "boolean"}, + }, + "required": ["subtask", "previous_finished"], + "additionalProperties": False, +} + + +PromptPhase = Literal["initial", "follow_up"] +ImagePosition = Literal["start", "end"] + + +# --------------------------------------------------------------------------- +# Base planner with the per-episode state + dispatch logic +# --------------------------------------------------------------------------- + + +class BasePlanner(ABC): + """Stateful per-episode ForeAct planner; subclasses implement the VLM call.""" + + def __init__(self) -> None: + self.previous_subtask: str | None = None + + def reset(self) -> None: + self.previous_subtask = None + + def generate_subtask(self, task: str, current_image: np.ndarray) -> dict[str, Any]: + """Run one planner turn and update ``previous_subtask`` in place. + + Returns ``{"subtask": str, "previous_finished": bool, "prompt_phase": str}``. + On parse failure returns empty subtask_text + previous_finished=False so + the outer loop can keep marching without crashing the episode. + """ + if self.previous_subtask is None: + prompt = INITIAL_PROMPT_TEMPLATE.format(task=task) + # Table 5: "VISUAL INPUT: [Initial Observation Image]" appears at + # the END of the initial prompt block. + image_position: ImagePosition = "end" + prompt_phase: PromptPhase = "initial" + else: + prompt = FOLLOW_UP_PROMPT_TEMPLATE.format(task=task) + # Table 5: "VISUAL INPUT: [Current Observation Image]" appears at + # the START of the follow-up prompt block. + image_position = "start" + prompt_phase = "follow_up" + + raw = self._chat( + prompt=prompt, + image=current_image, + image_position=image_position, + response_schema=SUBTASK_SCHEMA, + ) + parsed = _parse_response(raw) + if parsed["subtask"]: + self.previous_subtask = parsed["subtask"] + return {**parsed, "prompt_phase": prompt_phase} + + @abstractmethod + def _chat( + self, + prompt: str, + image: np.ndarray, + image_position: ImagePosition, + response_schema: dict[str, Any], + ) -> str: + """Return the raw JSON string emitted by the VLM.""" + + +def _parse_response(raw: str) -> dict[str, Any]: + try: + payload = json.loads(raw) + except json.JSONDecodeError: + logger.warning("Planner response was not valid JSON: %r", raw[:200]) + return {"subtask": "", "previous_finished": False} + subtask = str(payload.get("subtask") or "").strip() + previous_finished = bool(payload.get("previous_finished", False)) + return {"subtask": subtask, "previous_finished": previous_finished} + + +# --------------------------------------------------------------------------- +# Backend A — OpenAI-compatible (targets local vLLM hosting Qwen3-VL-8B-Instruct, +# which is the exact model the paper uses for both VLM+\u03c0_0 and ForeAct \u00a74.3) +# --------------------------------------------------------------------------- + + +DEFAULT_BASE_URL = "http://localhost:8000/v1" +DEFAULT_OPENAI_MODEL = "Qwen/Qwen3-VL-8B-Instruct" + + +class OpenAICompatPlanner(BasePlanner): + def __init__( + self, + base_url: str = DEFAULT_BASE_URL, + model: str = DEFAULT_OPENAI_MODEL, + api_key: str = "none", + temperature: float = 1.0, + timeout_s: float = 600.0, + ) -> None: + super().__init__() + self._client = OpenAI(base_url=base_url, api_key=api_key, timeout=timeout_s) + self._model = model + self._temperature = temperature + + def _chat( + self, + prompt: str, + image: np.ndarray, + image_position: ImagePosition, + response_schema: dict[str, Any], + ) -> str: + png_bytes = encode_png(image) + data_url = f"data:image/png;base64,{base64.b64encode(png_bytes).decode('utf-8')}" + image_part: dict[str, Any] = {"type": "image_url", "image_url": {"url": data_url}} + text_part: dict[str, Any] = {"type": "text", "text": prompt} + content = [text_part, image_part] if image_position == "end" else [image_part, text_part] + # The OpenAI SDK's TypedDict is stricter than the general-purpose dict + # we build; cast once here rather than maintain parallel literals. + messages = cast(list[ChatCompletionMessageParam], [{"role": "user", "content": content}]) + response = self._client.chat.completions.create( + model=self._model, + messages=messages, + temperature=self._temperature, + response_format={ + "type": "json_schema", + "json_schema": { + "name": "foreact_subtask", + "schema": response_schema, + "strict": True, + }, + }, + ) + return (response.choices[0].message.content or "").strip() + + +# --------------------------------------------------------------------------- +# Backend B — Gemini (optional; the paper doesn't use it, but we have keys set +# up from the Comet work and it's a cheap apples-to-apples vs. our prior runs) +# --------------------------------------------------------------------------- + + +DEFAULT_GEMINI_MODEL = "gemini-robotics-er-1.6-preview" + + +class GeminiPlanner(BasePlanner): + def __init__( + self, + model: str = DEFAULT_GEMINI_MODEL, + thinking_budget: int = 0, + max_retries: int = 10, + request_timeout_s: float = 120.0, + ) -> None: + super().__init__() + # google-genai is an optional dependency for this backend only — + # import lazily so OpenAI-only users don't need it installed. + from google import genai + from google.genai import types + + self._types = types + self._client = genai.Client( + http_options=types.HttpOptions(timeout=int(request_timeout_s * 1000)) + ) + self._model = model + self._thinking_budget = thinking_budget + self._max_retries = max_retries + + def _chat( + self, + prompt: str, + image: np.ndarray, + image_position: ImagePosition, + response_schema: dict[str, Any], + ) -> str: + types = self._types + image_part = types.Part.from_bytes(data=encode_png(image), mime_type="image/png") + contents: list[Any] = ( + [prompt, image_part] if image_position == "end" else [image_part, prompt] + ) + config = types.GenerateContentConfig( + temperature=1.0, + thinking_config=types.ThinkingConfig(thinking_budget=self._thinking_budget), + response_mime_type="application/json", + response_schema=response_schema, + ) + + def _call() -> str: + response = self._client.models.generate_content( + model=self._model, + contents=contents, + config=config, + ) + return (response.text or "").strip() + + return call_with_retry(_call, max_retries=self._max_retries) diff --git a/experiments/subtask_probe/droid_eval/foreact_eval/visualize_foreact.py b/experiments/subtask_probe/droid_eval/foreact_eval/visualize_foreact.py new file mode 100644 index 0000000..a47e7ba --- /dev/null +++ b/experiments/subtask_probe/droid_eval/foreact_eval/visualize_foreact.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""Render exterior | wrist | predicted foresight | subtask per DROID frame. + +Extends ``visualize_subtasks.py`` with a third image column that pulls the +foresight PNG generated by ``generate_foresight.py``. If a foresight image is +missing for a given frame (e.g. the generator skipped it), a grey placeholder +with the text "(no foresight)" is shown so the row still aligns. + +Usage:: + + uv run python -m experiments.subtask_probe.droid_eval.foreact_eval.visualize_foreact \\ + --samples_dir ./.experiments_cache/droid_eval_ah15 \\ + --subtasks ./.experiments_cache/droid_eval_ah15/subtasks_foreact_qwen8b.json \\ + --foresight_dir ./.experiments_cache/droid_eval_ah15/foresight_foreact \\ + --output_dir ./.experiments_cache/droid_eval_ah15/foreact_report \\ + [--video --fps 2] +""" + +from __future__ import annotations + +import argparse +import logging +from pathlib import Path + +import imageio.v3 as iio3 +import numpy as np +from PIL import Image + +from experiments.subtask_probe.droid_eval.utils import load_manifest, load_subtask_index +from experiments.subtask_probe.droid_eval.visualize_subtasks import ( + _composite_side_by_side, + _draw_caption, + _encode_png_base64, + _load_frame_images, +) + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + + +def _load_foresight_image( + foresight_dir: Path, episode_id: str, frame_idx: int, target_h: int, target_w: int +) -> np.ndarray: + """Load the foresight PNG for a given frame, resizing to (target_h, target_w). + + Returns a grey placeholder if the PNG is missing so the HTML/video grid + stays aligned even when the generator skipped frames. + """ + path = foresight_dir / episode_id / f"frame_{frame_idx:05d}.png" + if not path.exists(): + return np.full((target_h, target_w, 3), 40, dtype=np.uint8) + with Image.open(path) as img: + resized = img.convert("RGB").resize((target_w, target_h), Image.Resampling.LANCZOS) + return np.asarray(resized, dtype=np.uint8) + + +def _write_html( + out_path: Path, + episode_id: str, + instruction: str, + items: list[tuple[int, str, dict[str, np.ndarray]]], +) -> None: + """Emit a self-contained HTML with exterior | wrist | foresight | subtask.""" + rows = [] + for frame_idx, subtask, views in items: + ext_b64 = _encode_png_base64(views["exterior"]) + wrist_b64 = _encode_png_base64(views["wrist"]) + fore_b64 = _encode_png_base64(views["foresight"]) + rows.append( + f"" + f"{frame_idx}" + f"
exterior
" + f"" + f"
wrist
" + f"" + f"
predicted foresight
" + f"" + f"{subtask or '(empty)'}" + f"" + ) + html = ( + "" + f"ForeAct \u2014 {episode_id}" + "" + f"

{episode_id}

" + f"

Instruction: {instruction} · {len(items)} frames

" + "" + "" + "" + "" + "".join(rows) + "
frameexteriorwristpredicted foresightsubtask
" + "" + ) + out_path.write_text(html) + + +def _write_mp4( + out_path: Path, + frames_with_captions: list[np.ndarray], + fps: int, +) -> None: + iio3.imwrite( + out_path, + np.stack(frames_with_captions), + fps=fps, + codec="libx264", + macro_block_size=1, + ) + + +def _process_episode( + samples_dir: Path, + foresight_dir: Path, + episode: dict, + subtask_index: dict[tuple[str, int], str], + output_dir: Path, + video: bool, + fps: int, +) -> None: + episode_id = episode["episode_id"] + instruction = episode["instruction"] + + items: list[tuple[int, str, dict[str, np.ndarray]]] = [] + captioned: list[np.ndarray] = [] + + for frame_info in episode["frames"]: + frame_idx = frame_info["frame_idx"] + views = _load_frame_images(samples_dir / frame_info["file"]) + ext_h, ext_w = views["exterior"].shape[:2] + foresight = _load_foresight_image(foresight_dir, episode_id, frame_idx, ext_h, ext_w) + views["foresight"] = foresight + subtask = subtask_index.get((episode_id, frame_idx), "") + items.append((frame_idx, subtask, views)) + if video: + composite = _composite_side_by_side(views) + ext_w = views["exterior"].shape[1] + wrist_w = views["wrist"].shape[1] + separator = 4 + captioned.append( + _draw_caption( + composite, + caption=subtask, + footer=f"{episode_id} frame {frame_idx} | task: {instruction}", + camera_labels=[ + (4, "EXTERIOR"), + (ext_w + separator + 4, "WRIST"), + (ext_w + wrist_w + 2 * separator + 4, "FORESIGHT"), + ], + ) + ) + + if video: + mp4_path = output_dir / f"{episode_id}.mp4" + _write_mp4(mp4_path, captioned, fps=fps) + logger.info("%s -> %s (%d frames, %d fps)", episode_id, mp4_path, len(captioned), fps) + else: + html_path = output_dir / f"{episode_id}.html" + _write_html(html_path, episode_id, instruction, items) + logger.info("%s -> %s (%d frames)", episode_id, html_path, len(items)) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Render ForeAct foresight + subtask grid") + parser.add_argument("--samples_dir", type=str, required=True) + parser.add_argument("--subtasks", type=str, required=True) + parser.add_argument( + "--foresight_dir", + type=str, + required=True, + help="Directory containing foresight/{episode_id}/frame_{idx:05d}.png files.", + ) + parser.add_argument("--output_dir", type=str, required=True) + parser.add_argument("--video", action="store_true") + parser.add_argument("--fps", type=int, default=2) + args = parser.parse_args() + + samples_dir = Path(args.samples_dir) + foresight_dir = Path(args.foresight_dir) + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + manifest = load_manifest(samples_dir) + subtask_index = load_subtask_index(Path(args.subtasks)) + logger.info( + "Loaded %d episodes, %d subtask records, foresight_dir=%s", + len(manifest), + len(subtask_index), + foresight_dir, + ) + + for episode in manifest: + _process_episode( + samples_dir=samples_dir, + foresight_dir=foresight_dir, + episode=episode, + subtask_index=subtask_index, + output_dir=output_dir, + video=args.video, + fps=args.fps, + ) + + logger.info("Done. Output: %s", output_dir) + + +if __name__ == "__main__": + main() diff --git a/experiments/subtask_probe/droid_eval/generate_subtasks.py b/experiments/subtask_probe/droid_eval/generate_subtasks.py new file mode 100644 index 0000000..d154354 --- /dev/null +++ b/experiments/subtask_probe/droid_eval/generate_subtasks.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +"""Phase 1: Generate subtask text for DROID frames via deployed server. + +Sends each cached DROID frame to the deployed server with mode="subtask_only" +to get subtask text, then caches the results for Phase 2. + +Optionally configures the subtask prompt format on the server via the admin +HTTP endpoint before generation, so the same script can drive prompt-format +A/B tests against a single deployment. + +Usage: + # Use whichever subtask prompt format the server is currently configured with: + uv run python experiments/subtask_probe/droid_eval/generate_subtasks.py \ + --samples_dir ./.experiments_cache/droid_eval \ + --output ./.experiments_cache/droid_eval/subtasks.json \ + --server 43.200.36.250 + + # Override the subtask prompt format on the server before running: + uv run python experiments/subtask_probe/droid_eval/generate_subtasks.py \ + --samples_dir ./.experiments_cache/droid_eval \ + --prompt_format '{task}' \ + --output ./.experiments_cache/droid_eval/subtasks_raw.json \ + --server 43.200.36.250 +""" + +from __future__ import annotations + +import argparse +import json +import logging +import time +from pathlib import Path +from typing import Any + +import httpx +import numpy as np + +from hosting.admin_server import DEFAULT_ADMIN_PORT +from hosting.flash_transport_policy import FlashTransportPolicy + +from .constants import DEFAULT_QUIC_PORT +from .utils import build_subtask_observation, build_warmup_observation, load_manifest + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + + +def _admin_request( + server: str, + admin_port: int, + method: str, + body: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Send a request to the server's admin HTTP endpoint and return the parsed JSON response.""" + url = f"http://{server}:{admin_port}/config" + try: + response = httpx.request(method, url, json=body, timeout=10.0) + response.raise_for_status() + except httpx.HTTPStatusError as exc: + raise RuntimeError( + f"Admin endpoint returned {exc.response.status_code} for {method} {url}: " + f"{exc.response.text}" + ) from exc + except httpx.HTTPError as exc: + raise RuntimeError(f"Could not reach admin endpoint at {url}: {exc}") from exc + return response.json() + + +def _set_server_prompt_format(server: str, admin_port: int, prompt_format: str) -> str: + """PATCH the server's subtask prompt format and verify it was applied.""" + payload = _admin_request( + server, admin_port, method="PATCH", body={"generation_prompt_format": prompt_format} + ) + actual = payload.get("generation_prompt_format") + if actual != prompt_format: + raise RuntimeError( + f"Admin endpoint did not apply prompt format. Requested {prompt_format!r}, " + f"server reports {actual!r}." + ) + return prompt_format + + +def _get_server_prompt_format(server: str, admin_port: int) -> str: + """Fetch the server's currently-configured subtask prompt format.""" + payload = _admin_request(server, admin_port, method="GET") + prompt_format = payload.get("generation_prompt_format") + if not isinstance(prompt_format, str): + raise RuntimeError( + f"Admin endpoint returned no generation_prompt_format field: {payload!r}" + ) + return prompt_format + + +def main() -> None: + parser = argparse.ArgumentParser(description="Generate subtasks for DROID frames via server") + parser.add_argument( + "--samples_dir", + type=str, + required=True, + help="Directory with extracted DROID samples (from extract_droid_samples.py)", + ) + parser.add_argument( + "--output", + type=str, + required=True, + help="Output JSON file for subtask cache", + ) + parser.add_argument( + "--server", + type=str, + required=True, + help="Server address (e.g., 43.200.36.250)", + ) + parser.add_argument("--port", type=int, default=DEFAULT_QUIC_PORT, help="Server QUIC port") + parser.add_argument( + "--admin_port", + type=int, + default=DEFAULT_ADMIN_PORT, + help="Admin HTTP port for runtime config (used to set/read --prompt_format)", + ) + parser.add_argument( + "--prompt_format", + type=str, + default=None, + help=( + "Subtask prompt format to install on the server before generation, " + "e.g. 'Task: {task}. Subtask: ' or '{task}'. Must contain the literal " + "'{{task}}' placeholder. If omitted, the server's current format is used." + ), + ) + args = parser.parse_args() + + samples_dir = Path(args.samples_dir) + manifest = load_manifest(samples_dir) + logger.info("Loaded manifest: %d episodes", len(manifest)) + + # Set or read the active subtask prompt format on the server before any inference. + # The runtime config is read on every generate() call server-side, so this takes + # effect on the next request. + if args.prompt_format is not None: + active_prompt_format = _set_server_prompt_format( + args.server, args.admin_port, args.prompt_format + ) + logger.info("Set server subtask prompt format to %r", active_prompt_format) + else: + active_prompt_format = _get_server_prompt_format(args.server, args.admin_port) + logger.info("Using server's current subtask prompt format: %r", active_prompt_format) + + # Connect to server via QUIC + policy = FlashTransportPolicy(args.server, port=args.port) + logger.info("Connected to server at %s:%d via QUIC", args.server, args.port) + + # Warm up connection + policy.infer(build_warmup_observation(mode="subtask_only")) + logger.info("Server warmup complete") + + # Process all frames + subtask_results = [] + total_frames = sum(ep["num_frames"] for ep in manifest) + processed = 0 + + for episode in manifest: + episode_id = episode["episode_id"] + instruction = episode["instruction"] + + for frame_info in episode["frames"]: + frame_file = samples_dir / frame_info["file"] + frame_data = np.load(frame_file) + + # Send raw uint8 images — the server's _normalize_image() handles + # conversion to float32 [-1, 1] and resize_with_pad to 224x224. + obs = build_subtask_observation( + exterior_image=frame_data["exterior_image"], + wrist_image=frame_data["wrist_image"], + prompt=instruction, + ) + + start_time = time.time() + result = policy.infer(obs) + elapsed = time.time() - start_time + + subtask_info = result.get("subtask", {}) + subtask_text = subtask_info.get("text", "") or result.get("subtask_text", "") + subtask_ms = subtask_info.get("ms", elapsed * 1000) + + subtask_results.append( + { + "episode_id": episode_id, + "frame_idx": frame_info["frame_idx"], + "instruction": instruction, + "subtask_text": subtask_text, + "generation_time_s": round(elapsed, 2), + "server_subtask_ms": round(subtask_ms, 1), + } + ) + + processed += 1 + if processed % 5 == 0 or processed == total_frames: + logger.info( + "[%d/%d] %s frame %d: '%s' (%.1fs)", + processed, + total_frames, + episode_id, + frame_info["frame_idx"], + subtask_text, + elapsed, + ) + + # Save results — wrap in a self-describing dict so consumers know which prompt + # format produced these subtasks. load_subtask_records() handles both this shape + # and the legacy bare-list shape for backward compatibility. + output_path = Path(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("w") as f: + json.dump( + {"prompt_format": active_prompt_format, "results": subtask_results}, + f, + indent=2, + ) + + logger.info( + "Subtask generation complete: %d results saved to %s (prompt_format=%r)", + len(subtask_results), + output_path, + active_prompt_format, + ) + + # Summary stats + gen_times = [r["generation_time_s"] for r in subtask_results] + logger.info( + "Latency: mean=%.2fs, min=%.2fs, max=%.2fs", + np.mean(gen_times), + np.min(gen_times), + np.max(gen_times), + ) + unique_subtasks = {r["subtask_text"] for r in subtask_results} + logger.info( + "Unique subtask texts: %d out of %d frames", len(unique_subtasks), len(subtask_results) + ) + + +if __name__ == "__main__": + main() diff --git a/experiments/subtask_probe/droid_eval/generate_subtasks_gemini.py b/experiments/subtask_probe/droid_eval/generate_subtasks_gemini.py new file mode 100644 index 0000000..a9a695e --- /dev/null +++ b/experiments/subtask_probe/droid_eval/generate_subtasks_gemini.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +"""Phase 1 (alt): Generate subtask text for DROID frames via Gemini Robotics-ER. + +Drop-in alternative to generate_subtasks.py that swaps the deployed pi0.5 server +for Google's Gemini Robotics-ER 1.6 Preview model, called directly via the +google-genai SDK. The on-disk JSON schema is identical so compare_subtask_outputs.py +and run_action_eval.py work unchanged. + +Requires GEMINI_API_KEY in the environment (or in a .env file loaded via +python-dotenv, which is already a project dep). + +Usage: + uv run python experiments/subtask_probe/droid_eval/generate_subtasks_gemini.py \\ + --samples_dir ./.experiments_cache/droid_eval \\ + --output ./.experiments_cache/droid_eval/subtasks_gemini.json + + # Override the prompt template: + uv run python experiments/subtask_probe/droid_eval/generate_subtasks_gemini.py \\ + --samples_dir ./.experiments_cache/droid_eval \\ + --prompt_format 'Task: {task}. What is the robot doing right now? Reply in 4 words.' \\ + --output ./.experiments_cache/droid_eval/subtasks_gemini_terse.json +""" + +from __future__ import annotations + +import argparse +import json +import logging +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Any + +import numpy as np +from dotenv import load_dotenv +from google import genai +from google.genai import types + +from .comet_style._gemini_utils import call_with_retry, encode_png +from .utils import load_manifest + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + +DEFAULT_MODEL = "gemini-robotics-er-1.6-preview" + +DEFAULT_PROMPT_FORMAT = ( + 'You are observing a robot performing: "{task}". Looking at the exterior and ' + "wrist camera views of the current moment, describe the immediate next subtask " + "the robot should do in 3 to 6 words, as a lowercase imperative phrase with no " + "trailing period. Respond with only that phrase." +) + + +def _generate_one( + client: genai.Client, + model: str, + prompt_text: str, + exterior_png: bytes, + wrist_png: bytes, + thinking_budget: int, + max_retries: int, +) -> tuple[str, float]: + """Call Gemini with retry-on-429. Returns (subtask_text, elapsed_seconds).""" + start_time = time.time() + + def _call() -> str: + response = client.models.generate_content( + model=model, + contents=[ + "Exterior camera view:", + types.Part.from_bytes(data=exterior_png, mime_type="image/png"), + "Wrist camera view:", + types.Part.from_bytes(data=wrist_png, mime_type="image/png"), + prompt_text, + ], + config=types.GenerateContentConfig( + temperature=1.0, + thinking_config=types.ThinkingConfig(thinking_budget=thinking_budget), + ), + ) + return (response.text or "").strip() + + text = call_with_retry(_call, max_retries=max_retries) + elapsed = time.time() - start_time + return text, elapsed + + +def _validate_prompt_format(prompt_format: str) -> None: + """Reject prompt formats that do not contain the required {task} placeholder. + + Mirrors the check in admin_server.py so CLI errors surface early. + """ + if "{task}" not in prompt_format: + raise ValueError( + f"--prompt_format must contain the literal '{{task}}' placeholder, got: " + f"{prompt_format!r}" + ) + + +def main() -> None: + load_dotenv() + + parser = argparse.ArgumentParser( + description="Generate subtasks for DROID frames via Gemini Robotics-ER" + ) + parser.add_argument( + "--samples_dir", + type=str, + required=True, + help="Directory with extracted DROID samples (from extract_droid_samples.py)", + ) + parser.add_argument( + "--output", + type=str, + required=True, + help="Output JSON file for subtask cache", + ) + parser.add_argument( + "--model", + type=str, + default=DEFAULT_MODEL, + help=f"Gemini model ID (default: {DEFAULT_MODEL})", + ) + parser.add_argument( + "--prompt_format", + type=str, + default=DEFAULT_PROMPT_FORMAT, + help=( + "Prompt template sent to Gemini. Must contain the literal '{task}' " + "placeholder, which is replaced with the episode's language instruction." + ), + ) + parser.add_argument( + "--thinking_budget", + type=int, + default=0, + help="Gemini thinking budget (0 disables thinking, higher values allow more reasoning tokens)", + ) + parser.add_argument( + "--max_workers", + type=int, + default=1, + help=( + "Max concurrent Gemini API requests. Default 1 keeps you under the " + "free-tier 5 RPM cap; raise it on a paid tier for throughput." + ), + ) + parser.add_argument( + "--max_retries", + type=int, + default=10, + help="Per-frame retry budget for 429 (RESOURCE_EXHAUSTED) responses", + ) + args = parser.parse_args() + + _validate_prompt_format(args.prompt_format) + + samples_dir = Path(args.samples_dir) + manifest = load_manifest(samples_dir) + logger.info("Loaded manifest: %d episodes", len(manifest)) + + client = genai.Client() + + # Build the flat task list up-front so results can be collected in manifest order + # regardless of which worker finishes first. + tasks: list[dict[str, Any]] = [] + for episode in manifest: + episode_id = episode["episode_id"] + instruction = episode["instruction"] + for frame_info in episode["frames"]: + tasks.append( + { + "episode_id": episode_id, + "instruction": instruction, + "frame_idx": frame_info["frame_idx"], + "frame_path": samples_dir / frame_info["file"], + } + ) + total_frames = len(tasks) + logger.info( + "Dispatching %d frames to %s (max_workers=%d)", total_frames, args.model, args.max_workers + ) + + progress_lock = threading.Lock() + progress = {"done": 0} + + def process(task: dict[str, Any]) -> dict[str, Any]: + frame_data = np.load(task["frame_path"]) + prompt_text = args.prompt_format.format(task=task["instruction"]) + try: + exterior_png = encode_png(frame_data["exterior_image"]) + wrist_png = encode_png(frame_data["wrist_image"]) + subtask_text, elapsed = _generate_one( + client=client, + model=args.model, + prompt_text=prompt_text, + exterior_png=exterior_png, + wrist_png=wrist_png, + thinking_budget=args.thinking_budget, + max_retries=args.max_retries, + ) + except Exception as exc: + logger.warning( + "Gemini call failed for %s frame %d: %s", + task["episode_id"], + task["frame_idx"], + exc, + ) + subtask_text = "" + elapsed = 0.0 + + with progress_lock: + progress["done"] += 1 + done = progress["done"] + if done % 5 == 0 or done == total_frames: + logger.info( + "[%d/%d] %s frame %d: %r (%.1fs)", + done, + total_frames, + task["episode_id"], + task["frame_idx"], + subtask_text, + elapsed, + ) + + return { + "episode_id": task["episode_id"], + "frame_idx": task["frame_idx"], + "instruction": task["instruction"], + "subtask_text": subtask_text, + "generation_time_s": round(elapsed, 2), + "server_subtask_ms": round(elapsed * 1000, 1), + } + + if args.max_workers <= 1: + subtask_results = [process(task) for task in tasks] + else: + with ThreadPoolExecutor(max_workers=args.max_workers) as executor: + # executor.map preserves input order, which matches the manifest. + subtask_results = list(executor.map(process, tasks)) + + output_path = Path(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("w") as f: + json.dump( + { + "prompt_format": args.prompt_format, + "backend": args.model, + "results": subtask_results, + }, + f, + indent=2, + ) + + logger.info( + "Subtask generation complete: %d results saved to %s (backend=%s)", + len(subtask_results), + output_path, + args.model, + ) + + # Summary stats — mirrors generate_subtasks.py so output looks familiar. + gen_times = [r["generation_time_s"] for r in subtask_results if r["generation_time_s"] > 0] + if gen_times: + logger.info( + "Latency: mean=%.2fs, min=%.2fs, max=%.2fs", + float(np.mean(gen_times)), + float(np.min(gen_times)), + float(np.max(gen_times)), + ) + unique_subtasks = {r["subtask_text"] for r in subtask_results if r["subtask_text"]} + logger.info( + "Unique subtask texts: %d out of %d frames", len(unique_subtasks), len(subtask_results) + ) + failed = sum(1 for r in subtask_results if not r["subtask_text"]) + if failed: + logger.warning("Failed frames: %d / %d", failed, len(subtask_results)) + + +if __name__ == "__main__": + main() diff --git a/experiments/subtask_probe/droid_eval/run_action_eval.py b/experiments/subtask_probe/droid_eval/run_action_eval.py new file mode 100644 index 0000000..0517634 --- /dev/null +++ b/experiments/subtask_probe/droid_eval/run_action_eval.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Phase 2: Run action generation under 2 prompt conditions via deployed server. + +Sends each cached DROID frame to the deployed server with mode="action_only" +under two prompt conditions: + 1. Baseline: original task instruction only + 2. Subtask: "{instruction}. Subtask: {subtask}" (with generated subtask) + +Requires the server to be running with pi05_droid config and the DROID +checkpoint, since the action policy's transforms and normalization must +match the DROID embodiment. + +Usage: + uv run python experiments/subtask_probe/droid_eval/run_action_eval.py \ + --samples_dir ./.experiments_cache/droid_eval \ + --subtasks ./.experiments_cache/droid_eval/subtasks.json \ + --output_dir ./.experiments_cache/droid_eval/predictions \ + --server 43.200.36.250 +""" + +from __future__ import annotations + +import argparse +import json +import logging +from pathlib import Path + +import numpy as np + +from hosting.flash_transport_policy import FlashTransportPolicy + +from .constants import DEFAULT_QUIC_PORT, DROID_ACTION_DIM +from .utils import ( + build_action_observation, + build_warmup_observation, + generate_frame_noise, + load_manifest, + load_subtask_index, +) + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run action eval with 2 prompt conditions") + parser.add_argument("--samples_dir", type=str, required=True) + parser.add_argument("--subtasks", type=str, required=True) + parser.add_argument("--output_dir", type=str, required=True) + parser.add_argument( + "--server", + type=str, + required=True, + help="Server address (e.g., 43.200.36.250)", + ) + parser.add_argument("--port", type=int, default=DEFAULT_QUIC_PORT, help="Server QUIC port") + args = parser.parse_args() + + samples_dir = Path(args.samples_dir) + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + manifest = load_manifest(samples_dir) + subtask_index = load_subtask_index(Path(args.subtasks)) + + # Connect to server + policy = FlashTransportPolicy(args.server, port=args.port) + logger.info("Connected to server at %s:%d via QUIC", args.server, args.port) + + policy.infer(build_warmup_observation(mode="action_only")) + logger.info("Server warmup complete") + + # Process all frames + all_predictions = [] + total_frames = sum(ep["num_frames"] for ep in manifest) + processed = 0 + + for episode in manifest: + episode_id = episode["episode_id"] + instruction = episode["instruction"] + + for frame_info in episode["frames"]: + frame_idx = frame_info["frame_idx"] + frame_file = samples_dir / frame_info["file"] + frame_data = np.load(frame_file) + + exterior_image = frame_data["exterior_image"] + wrist_image = frame_data["wrist_image"] + raw_state = frame_data["state"] + + subtask_text = subtask_index.get((episode_id, frame_idx), "") + if not subtask_text: + logger.warning( + "No subtask found for %s frame %d, using empty string", episode_id, frame_idx + ) + + # Same noise for all conditions so the only difference is the prompt + frame_noise = generate_frame_noise(episode_id, frame_idx) + + # Run all conditions + conditions = { + "baseline": instruction, + "subtask": f"{instruction}. Subtask: {subtask_text}", + } + frame_predictions = {} + for condition_name, prompt in conditions.items(): + obs = build_action_observation( + exterior_image, wrist_image, raw_state, prompt, noise=frame_noise + ) + result = policy.infer(obs) + frame_predictions[condition_name] = np.array(result["actions"])[ + :, :DROID_ACTION_DIM + ] + + # Save predictions + pred_file = output_dir / f"{episode_id}_frame_{frame_idx:05d}.npz" + np.savez_compressed(pred_file, **frame_predictions) + + all_predictions.append( + { + "episode_id": episode_id, + "frame_idx": frame_idx, + "instruction": instruction, + "subtask_text": subtask_text, + "prediction_file": str(pred_file.relative_to(output_dir)), + } + ) + + processed += 1 + if processed % 5 == 0 or processed == total_frames: + logger.info( + "[%d/%d] Processed %s frame %d", processed, total_frames, episode_id, frame_idx + ) + + # Save prediction manifest + pred_manifest_path = output_dir / "prediction_manifest.json" + with pred_manifest_path.open("w") as f: + json.dump(all_predictions, f, indent=2) + + logger.info( + "Action evaluation complete: %d predictions saved to %s", len(all_predictions), output_dir + ) + + +if __name__ == "__main__": + main() diff --git a/experiments/subtask_probe/droid_eval/utils.py b/experiments/subtask_probe/droid_eval/utils.py new file mode 100644 index 0000000..13c3cec --- /dev/null +++ b/experiments/subtask_probe/droid_eval/utils.py @@ -0,0 +1,151 @@ +"""Shared utilities for the DROID subtask evaluation pipeline.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import numpy as np + +from .constants import DROID_ACTION_DIM, MODEL_ACTION_DIM, InferenceMode + + +def load_manifest(samples_dir: Path) -> list[dict[str, Any]]: + """Load the episode manifest from a samples directory.""" + with (samples_dir / "manifest.json").open() as f: + return json.load(f) + + +def load_subtask_records(path: Path) -> list[dict[str, Any]]: + """Load the raw list of subtask records from a subtasks JSON file. + + Accepts two on-disk shapes: + * Legacy: a bare JSON list of records. + * Current: ``{"prompt_format": "...", "results": [...]}`` — written by + the prompt-format-aware ``generate_subtasks.py``. + """ + with path.open() as f: + payload = json.load(f) + if isinstance(payload, dict) and "results" in payload: + return payload["results"] + if isinstance(payload, list): + return payload + raise ValueError( + f"Unrecognized subtask JSON shape in {path}: expected list or dict with 'results' key" + ) + + +def load_subtask_index(path: Path) -> dict[tuple[str, int], str]: + """Load subtask results and index by (episode_id, frame_idx) -> subtask_text.""" + return { + (entry["episode_id"], entry["frame_idx"]): entry["subtask_text"] + for entry in load_subtask_records(path) + } + + +def load_subtask_entries(path: Path) -> dict[tuple[str, int], dict[str, Any]]: + """Load subtask results and index by (episode_id, frame_idx) -> full entry dict.""" + return { + (entry["episode_id"], entry["frame_idx"]): entry for entry in load_subtask_records(path) + } + + +def build_subtask_observation( + exterior_image: np.ndarray, + wrist_image: np.ndarray, + prompt: str, +) -> dict[str, Any]: + """Build an observation dict for subtask generation (mode="subtask_only"). + + Images are sent as raw uint8 — the server's _normalize_image() handles + conversion to float32 [-1, 1] and the camera name mapping. + """ + return { + "images": { + "base_0_rgb": exterior_image, + "left_wrist_0_rgb": wrist_image, + }, + "state": np.zeros(14, dtype=np.float32), + "prompt": prompt, + "mode": "subtask_only", + } + + +def build_action_observation( + exterior_image: np.ndarray, + wrist_image: np.ndarray, + state: np.ndarray, + prompt: str, + noise: np.ndarray | None = None, +) -> dict[str, Any]: + """Build an observation dict for action generation (mode="action_only"). + + The server's pi05_droid policy transforms handle normalization, + tokenization, and image preprocessing internally. + + Args: + noise: Pre-generated noise tensor for flow matching denoising, + shape (ACTION_HORIZON, MODEL_ACTION_DIM). When the same noise is + passed for multiple prompt conditions, actions differ only due to + the prompt, not random noise. + """ + joint_position = state[:7] + gripper_position = state[7:8] + + obs: dict[str, Any] = { + "observation/exterior_image_1_left": exterior_image, + "observation/wrist_image_left": wrist_image, + "observation/joint_position": joint_position, + "observation/gripper_position": gripper_position, + "prompt": prompt, + "mode": "action_only", + } + if noise is not None: + obs["noise"] = noise + return obs + + +def build_warmup_observation(mode: InferenceMode = "action_only") -> dict[str, Any]: + """Build a dummy observation for server warmup.""" + if mode == "subtask_only": + return build_subtask_observation( + exterior_image=np.zeros((224, 224, 3), dtype=np.uint8), + wrist_image=np.zeros((224, 224, 3), dtype=np.uint8), + prompt="warmup", + ) + return build_action_observation( + exterior_image=np.zeros((224, 224, 3), dtype=np.uint8), + wrist_image=np.zeros((224, 224, 3), dtype=np.uint8), + state=np.zeros(DROID_ACTION_DIM, dtype=np.float32), + prompt="warmup", + ) + + +def generate_frame_noise(episode_id: str, frame_idx: int) -> np.ndarray: + """Generate deterministic noise for flow matching denoising. + + Uses a hash of (episode_id, frame_idx) as the seed so that multiple + prompt conditions on the same frame get identical noise, making the + comparison fair. + """ + from .constants import ACTION_HORIZON + + rng = np.random.RandomState(hash((episode_id, frame_idx)) % (2**31)) + return rng.randn(ACTION_HORIZON, MODEL_ACTION_DIM).astype(np.float32) + + +def decode_droid_image(img_bytes: bytes | str | np.ndarray) -> np.ndarray: + """Decode a DROID image from RLDS format. + + Handles both encoded (JPEG bytes) and pre-decoded (ndarray) formats + that appear in different DROID dataset versions. + """ + import tensorflow as tf # ty: ignore[unresolved-import] + + if isinstance(img_bytes, (bytes, str)) or ( + hasattr(img_bytes, "dtype") + and (img_bytes.dtype == np.object_ or img_bytes.dtype.kind in ("S", "U")) + ): + return tf.io.decode_image(img_bytes, expand_animations=False, dtype=tf.uint8).numpy() + return np.asarray(img_bytes, dtype=np.uint8) diff --git a/experiments/subtask_probe/droid_eval/visualize_results.py b/experiments/subtask_probe/droid_eval/visualize_results.py new file mode 100644 index 0000000..c0ac065 --- /dev/null +++ b/experiments/subtask_probe/droid_eval/visualize_results.py @@ -0,0 +1,502 @@ +#!/usr/bin/env python3 +"""Generate a self-contained HTML report for DROID evaluation results. + +Produces a single HTML file with embedded images (base64) that can be +opened in any browser and shared without dependencies. + +Sections: + 1. Summary metrics — overall L2, cosine sim, gripper accuracy + 2. Pairwise comparisons — Wilcoxon tests, % better + 3. Subtask gallery — every frame with images + generated subtask text + 4. Action trajectories — per-dimension action plots for sample frames + +Usage: + uv run python experiments/subtask_probe/droid_eval/visualize_results.py \ + --samples_dir ./.experiments_cache/droid_eval \ + --output ./.experiments_cache/droid_eval/report.html +""" + +from __future__ import annotations + +import argparse +import base64 +import io +import json +import logging +from pathlib import Path +from typing import Literal + +import numpy as np + +from .constants import CONDITION_COLORS, JOINT_NAMES +from .utils import load_manifest, load_subtask_entries, load_subtask_records + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + + +ImageFormat = Literal["jpeg", "png"] + + +def _is_coherent_subtask(text: str) -> bool: + """Check if a subtask string is coherent (non-empty ASCII text, not Unicode garbage).""" + return bool(text.strip()) and text.isascii() + + +def image_to_base64(image_array: np.ndarray, fmt: ImageFormat = "jpeg", quality: int = 80) -> str: + """Convert a numpy image array (HWC, uint8) to a base64-encoded data URI.""" + from PIL import Image + + img = Image.fromarray(image_array) + buffer = io.BytesIO() + if fmt == "jpeg": + img.save(buffer, format="JPEG", quality=quality) + else: + img.save(buffer, format="PNG") + encoded = base64.b64encode(buffer.getvalue()).decode("ascii") + mime = "image/jpeg" if fmt == "jpeg" else "image/png" + return f"data:{mime};base64,{encoded}" + + +def make_action_svg( + ground_truth: np.ndarray, + predictions: dict[str, np.ndarray], + dim_idx: int, + dim_name: str, + width: int = 280, + height: int = 120, +) -> str: + """Generate an inline SVG showing action trajectories for one dimension.""" + margin_left = 35 + margin_right = 10 + margin_top = 20 + margin_bottom = 25 + plot_width = width - margin_left - margin_right + plot_height = height - margin_top - margin_bottom + + # Collect all values for axis scaling + all_values = [ground_truth[:, dim_idx]] + for pred in predictions.values(): + all_values.append(pred[:, dim_idx]) + all_flat = np.concatenate(all_values) + y_min = float(np.min(all_flat)) + y_max = float(np.max(all_flat)) + y_range = y_max - y_min + if y_range < 1e-6: + y_range = 1.0 + y_min -= y_range * 0.1 + y_max += y_range * 0.1 + y_range = y_max - y_min + + num_steps = ground_truth.shape[0] + + def to_svg_coords(step: int, value: float) -> tuple[float, float]: + x = margin_left + (step / max(num_steps - 1, 1)) * plot_width + y = margin_top + (1 - (value - y_min) / y_range) * plot_height + return x, y + + def make_polyline(values: np.ndarray, color: str, dashed: bool = False) -> str: + points = " ".join( + f"{to_svg_coords(i, v)[0]:.1f},{to_svg_coords(i, v)[1]:.1f}" + for i, v in enumerate(values) + ) + dash = ' stroke-dasharray="4,3"' if dashed else "" + return ( + f'' + ) + + lines = [f''] + lines.append(f'') + + # Title + lines.append( + f'{dim_name}' + ) + + # Y-axis labels + for frac in [0, 0.5, 1.0]: + y_val = y_min + frac * y_range + _, svg_y = to_svg_coords(0, y_val) + lines.append( + f'{y_val:.2f}' + ) + lines.append( + f'' + ) + + # Ground truth (dashed gray) + lines.append( + make_polyline(ground_truth[:, dim_idx], CONDITION_COLORS["ground_truth"], dashed=True) + ) + + # Predictions + for condition_name, pred in predictions.items(): + lines.append(make_polyline(pred[:, dim_idx], CONDITION_COLORS[condition_name])) + + lines.append("") + return "\n".join(lines) + + +def generate_html( + samples_dir: Path, + max_gallery_frames: int, + sample_action_frames: int, + subtasks_path: Path | None = None, +) -> str: + """Generate the full HTML report. + + ``subtasks_path`` overrides the default ``samples_dir/subtasks.json`` so the + same report template can visualize subtasks from different backends (e.g. + pi0.5 vs Gemini) pointing at the same cache. + """ + # Load all data + manifest = load_manifest(samples_dir) + resolved_subtasks_path = subtasks_path or (samples_dir / "subtasks.json") + subtask_index = load_subtask_entries(resolved_subtasks_path) + + # Load raw subtask list for unique count in header + subtasks = load_subtask_records(resolved_subtasks_path) + + results_path = samples_dir / "results.json" + has_results = results_path.exists() + results = {} + if has_results: + with results_path.open() as f: + results = json.load(f) + + predictions_dir = samples_dir / "predictions" + has_predictions = predictions_dir.exists() + + total_frames = sum(ep["num_frames"] for ep in manifest) + + # --- Start HTML --- + html_parts: list[str] = [] + html_parts.append(""" + + + + +DROID Subtask Evaluation Report + + + +
+""") + + # --- Header --- + html_parts.append(f""" +

DROID Subtask Evaluation Report

+

{len(manifest)} episodes · {total_frames} frames · {len({s["subtask_text"] for s in subtasks})} unique subtasks

+""") + + # --- Summary stats --- + if has_results: + html_parts.append("

Summary Metrics

") + + # Stat boxes + baseline_l2 = results["baseline"]["overall"]["l2_distance"]["mean"] + subtask_l2 = results["subtask"]["overall"]["l2_distance"]["mean"] + p_val = results.get("pairwise", {}).get("baseline_vs_subtask", {}).get("wilcoxon_p", 1.0) + + html_parts.append('
') + for label, l2_val in [("Baseline L2", baseline_l2), ("Subtask L2", subtask_l2)]: + html_parts.append( + f'
{l2_val:.3f}
{label}
' + ) + cls = "sig" if p_val < 0.05 else "not-sig" + sig_label = "significant" if p_val < 0.05 else "not significant" + html_parts.append( + f'
p={p_val:.4f}
Baseline vs Subtask ({sig_label})
' + ) + html_parts.append("
") + + # Overall metrics table + html_parts.append(""" + + +""") + for condition in ["baseline", "subtask"]: + o = results[condition]["overall"] + l2 = o["l2_distance"] + cos = o["cosine_similarity"] + grip = o["gripper_accuracy"] + html_parts.append( + f"" + f"" + f"" + f"" + f"" + ) + html_parts.append("
ConditionL2 DistanceCosine SimilarityGripper AccuracyN
{condition}{l2['mean']:.4f} ± {l2['std']:.4f}{cos['mean']:.4f} ± {cos['std']:.4f}{grip['mean']:.4f} ± {grip['std']:.4f}{o['n_frames']}
") + + # Per-dimension MAE table + html_parts.append("

Per-Dimension MAE

") + html_parts.append("") + for name in JOINT_NAMES: + html_parts.append(f"") + html_parts.append("") + for condition in ["baseline", "subtask"]: + per_dim = results[condition]["overall"]["per_dim_mae"] + html_parts.append(f"") + for name in JOINT_NAMES: + val = per_dim.get(name, 0) + html_parts.append(f"") + html_parts.append("") + html_parts.append("
Condition{name}
{condition}{val:.4f}
") + + # Pairwise comparisons + html_parts.append("

Pairwise Comparisons

") + html_parts.append( + "" + ) + for key, data in results.get("pairwise", {}).items(): + p_val = data.get("wilcoxon_p", None) + p_str = f"{p_val:.4f}" if p_val is not None else "N/A" + cls = "sig" if p_val is not None and p_val < 0.05 else "not-sig" + html_parts.append( + f"" + f"" + f"" + f'' + ) + html_parts.append("
ComparisonL2 Diff (mean)% B BetterWilcoxon p
{key}{data['l2_diff_mean']:.4f}{data['pct_b_better']:.1f}%{p_str}
") + + # --- Subtask Gallery --- + html_parts.append("

Subtask Gallery

") + html_parts.append( + '

Click an episode to expand. Coherent/garbage counts shown in the header.

' + ) + + for episode in manifest: + episode_id = episode["episode_id"] + task_type_cls = "multi" if episode["task_type"] == "multi_step" else "" + + # Count coherent vs garbage subtasks for this episode + ep_subtasks = [subtask_index.get((episode_id, f["frame_idx"])) for f in episode["frames"]] + ep_subtasks = [s for s in ep_subtasks if s is not None] + coherent_count = sum(1 for s in ep_subtasks if _is_coherent_subtask(s["subtask_text"])) + garbage_count = len(ep_subtasks) - coherent_count + quality_label = f"{coherent_count}/{len(ep_subtasks)} coherent" + if garbage_count > 0: + quality_label += f", {garbage_count} garbage" + quality_color = ( + "#4caf50" + if garbage_count == 0 + else ("#ff9800" if coherent_count > garbage_count else "#f44336") + ) + + html_parts.append(f""" +
garbage_count else ""}> + +
+ {episode_id} + {episode["task_type"].replace("_", " ")} + {quality_label} + {episode["num_frames"]} frames +
+
“{episode["instruction"]}”
+
+
+""") + + for frame_info in episode["frames"]: + frame_idx = frame_info["frame_idx"] + subtask_entry = subtask_index.get((episode_id, frame_idx)) + + # Load images + frame_path = samples_dir / frame_info["file"] + if frame_path.exists(): + frame_data = np.load(frame_path) + ext_uri = image_to_base64(frame_data["exterior_image"]) + wrist_uri = image_to_base64(frame_data["wrist_image"]) + else: + ext_uri = "" + wrist_uri = "" + + subtask_text = subtask_entry["subtask_text"] if subtask_entry else "(no subtask)" + gen_time = subtask_entry.get("generation_time_s", 0) if subtask_entry else 0 + is_garbage = not _is_coherent_subtask(subtask_text) + border_color = "#f44336" if is_garbage else "#eee" + + html_parts.append(f""" +
+
+ exterior + wrist +
+
Subtask: {subtask_text}
+
Frame {frame_idx} · {gen_time:.2f}s
+
+""") + + html_parts.append("
") # frame-grid, details + + # --- Action Trajectories --- + if has_predictions: + html_parts.append("

Action Trajectories (Sample Frames)

") + html_parts.append(""" +
+
Ground truth
+
Baseline
+
Subtask
+
+""") + + action_frame_count = 0 + for episode in manifest: + episode_id = episode["episode_id"] + # Pick evenly spaced frames from this episode + frames = episode["frames"] + if len(frames) <= 2: + selected_frames = frames + else: + step = max(1, len(frames) // 2) + selected_frames = frames[::step][:3] + + for frame_info in selected_frames: + if action_frame_count >= sample_action_frames: + break + + frame_idx = frame_info["frame_idx"] + pred_file = predictions_dir / f"{episode_id}_frame_{frame_idx:05d}.npz" + frame_file = samples_dir / frame_info["file"] + + if not pred_file.exists() or not frame_file.exists(): + continue + + pred_data = np.load(pred_file) + frame_data = np.load(frame_file) + ground_truth = frame_data["ground_truth_actions"] + + predictions = { + "baseline": pred_data["baseline"], + "subtask": pred_data["subtask"], + } + + # Trim to common horizon + min_h = min(ground_truth.shape[0], *(p.shape[0] for p in predictions.values())) + ground_truth_trimmed = ground_truth[:min_h] + predictions_trimmed = {k: v[:min_h] for k, v in predictions.items()} + + subtask_entry = subtask_index.get((episode_id, frame_idx)) + subtask_text = subtask_entry["subtask_text"] if subtask_entry else "" + ext_uri = image_to_base64(frame_data["exterior_image"]) + + html_parts.append(f""" +
+
+ {episode_id} / frame {frame_idx} + Subtask: {subtask_text} +
+
+ +
+""") + num_dims = min(ground_truth_trimmed.shape[1], len(JOINT_NAMES)) + for dim_idx in range(num_dims): + svg = make_action_svg( + ground_truth_trimmed, predictions_trimmed, dim_idx, JOINT_NAMES[dim_idx] + ) + html_parts.append(svg) + + html_parts.append("
") + action_frame_count += 1 + + if action_frame_count >= sample_action_frames: + break + + # --- Footer --- + html_parts.append(""" +
+ Generated by experiments/subtask_probe/droid_eval/visualize_results.py +
+
+ + +""") + + return "".join(html_parts) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Generate HTML evaluation report") + parser.add_argument("--samples_dir", type=str, required=True) + parser.add_argument( + "--output", + type=str, + default=None, + help="Output HTML file (default: samples_dir/report.html)", + ) + parser.add_argument( + "--max_gallery_frames", type=int, default=100, help="Max frames to show in subtask gallery" + ) + parser.add_argument( + "--sample_action_frames", + type=int, + default=15, + help="Number of frames to show action trajectories for", + ) + parser.add_argument( + "--subtasks", + type=str, + default=None, + help="Path to the subtasks JSON (defaults to samples_dir/subtasks.json)", + ) + args = parser.parse_args() + + samples_dir = Path(args.samples_dir) + output_path = Path(args.output) if args.output else samples_dir / "report.html" + subtasks_path = Path(args.subtasks) if args.subtasks else None + + logger.info("Generating report from %s", samples_dir) + html = generate_html( + samples_dir, + max_gallery_frames=args.max_gallery_frames, + sample_action_frames=args.sample_action_frames, + subtasks_path=subtasks_path, + ) + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(html) + logger.info("Report saved to %s (%.1f MB)", output_path, output_path.stat().st_size / 1e6) + + +if __name__ == "__main__": + main() diff --git a/experiments/subtask_probe/droid_eval/visualize_subtasks.py b/experiments/subtask_probe/droid_eval/visualize_subtasks.py new file mode 100644 index 0000000..0198e2a --- /dev/null +++ b/experiments/subtask_probe/droid_eval/visualize_subtasks.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +"""Render frames + per-frame subtask text without running the action eval. + +Two output modes: + + * **HTML** (default): one self-contained HTML file per episode with the + exterior frame and its subtask caption inlined side-by-side. Good for + scrolling through a full episode. + + * **Video** (``--video``): one mp4 per episode with the subtask string + drawn onto the exterior frame, played back at ``--fps`` (default 2 Hz, + matching the DROID cache subsample rate so wall-clock ≈ real time). + +Usage:: + + uv run python -m experiments.subtask_probe.droid_eval.visualize_subtasks \\ + --samples_dir ./.experiments_cache/droid_eval_ah15 \\ + --subtasks ./.experiments_cache/droid_eval_ah15/subtasks_comet_qwen30b.json \\ + --output_dir ./.experiments_cache/droid_eval_ah15/subtask_videos \\ + --video --fps 2 +""" + +from __future__ import annotations + +import argparse +import base64 +import io +import logging +from pathlib import Path + +import imageio.v3 as iio3 +import numpy as np +from PIL import Image, ImageDraw, ImageFont + +from experiments.subtask_probe.droid_eval.utils import load_manifest, load_subtask_index + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + + +def _load_frame_images(frame_path: Path) -> dict[str, np.ndarray]: + """Load both camera views from a cached .npz as uint8 HxWx3 arrays. + + Returns a dict keyed by camera name (``exterior``, ``wrist``). Both views + exist on every cached DROID frame; surfacing both makes plan-tracking + failures easier to diagnose (e.g. gripper is clearly closed in the wrist + view but the reasoner still emits "grasp the cube"). + """ + data = np.load(frame_path) + return { + "exterior": np.asarray(data["exterior_image"], dtype=np.uint8), + "wrist": np.asarray(data["wrist_image"], dtype=np.uint8), + } + + +def _composite_side_by_side(views: dict[str, np.ndarray], separator_px: int = 4) -> np.ndarray: + """Concatenate multiple same-height views horizontally with a dark divider. + + Returns a single uint8 HxWx3 array so downstream caption/encode code can + treat the multi-view frame as one image. + """ + images = list(views.values()) + heights = {img.shape[0] for img in images} + if len(heights) != 1: + raise ValueError(f"camera views must share a height; got {heights}") + h = images[0].shape[0] + divider = np.full((h, separator_px, 3), 24, dtype=np.uint8) + pieces: list[np.ndarray] = [] + for i, img in enumerate(images): + if i > 0: + pieces.append(divider) + pieces.append(img) + return np.concatenate(pieces, axis=1) + + +def _draw_caption( + image: np.ndarray, + caption: str, + footer: str | None = None, + camera_labels: list[tuple[int, str]] | None = None, +) -> np.ndarray: + """Overlay a subtask caption on the bottom of the frame plus a dark banner. + + ``camera_labels`` is an optional list of ``(x_offset, label)`` pairs drawn + in the top-left of each view, useful when the image is a composite of + multiple camera feeds stitched together by ``_composite_side_by_side``. + """ + img = Image.fromarray(image).convert("RGB") + draw = ImageDraw.Draw(img, "RGBA") + w, h = img.size + + # Try a bundled TrueType font; fall back to default if unavailable. + try: + font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", size=max(16, h // 18)) + except OSError: + font = ImageFont.load_default() + label_font = ImageFont.load_default() + + if camera_labels: + for x_offset, label in camera_labels: + draw.rectangle( + [(x_offset, 0), (x_offset + 8 + 6 * len(label), 18)], + fill=(0, 0, 0, 180), + ) + draw.text((x_offset + 4, 3), label, fill=(220, 220, 220, 255), font=label_font) + + banner_h = int(h * 0.22) + draw.rectangle([(0, h - banner_h), (w, h)], fill=(0, 0, 0, 180)) + + text = caption or "" + pad = 10 + draw.text((pad, h - banner_h + pad), text, fill=(255, 255, 255, 255), font=font) + + if footer: + draw.text((pad, h - 14), footer, fill=(200, 200, 200, 255), font=label_font) + + return np.asarray(img) + + +def _encode_png_base64(image: np.ndarray) -> str: + buf = io.BytesIO() + Image.fromarray(image).save(buf, format="PNG") + return base64.b64encode(buf.getvalue()).decode("ascii") + + +def _write_html( + out_path: Path, + episode_id: str, + instruction: str, + items: list[tuple[int, str, dict[str, np.ndarray]]], +) -> None: + """Emit a self-contained HTML file with both camera views + subtask per step.""" + rows = [] + for frame_idx, subtask, views in items: + ext_b64 = _encode_png_base64(views["exterior"]) + wrist_b64 = _encode_png_base64(views["wrist"]) + rows.append( + f"" + f"{frame_idx}" + f"" + f"
exterior
" + f"" + f"" + f"" + f"
wrist
" + f"" + f"" + f"{subtask or '(empty)'}" + f"" + ) + html = ( + "" + f"Subtasks — {episode_id}" + "" + f"

{episode_id}

" + f"

Instruction: {instruction} · {len(items)} frames

" + "" + "" + "" + "".join(rows) + "
frameexteriorwristsubtask
" + "" + ) + out_path.write_text(html) + + +def _write_mp4( + out_path: Path, + frames_with_captions: list[np.ndarray], + fps: int, +) -> None: + """Write an mp4 of the given captioned frames using imageio-ffmpeg.""" + iio3.imwrite( + out_path, + np.stack(frames_with_captions), + fps=fps, + codec="libx264", + macro_block_size=1, # Allow odd dims without forced rescaling + ) + + +def _process_episode( + samples_dir: Path, + episode: dict, + subtask_index: dict[tuple[str, int], str], + output_dir: Path, + video: bool, + fps: int, +) -> None: + episode_id = episode["episode_id"] + instruction = episode["instruction"] + + items: list[tuple[int, str, dict[str, np.ndarray]]] = [] + captioned: list[np.ndarray] = [] + + for frame_info in episode["frames"]: + frame_idx = frame_info["frame_idx"] + views = _load_frame_images(samples_dir / frame_info["file"]) + subtask = subtask_index.get((episode_id, frame_idx), "") + items.append((frame_idx, subtask, views)) + if video: + composite = _composite_side_by_side(views) + ext_w = views["exterior"].shape[1] + captioned.append( + _draw_caption( + composite, + caption=subtask, + footer=f"{episode_id} frame {frame_idx} | task: {instruction}", + camera_labels=[(4, "EXTERIOR"), (ext_w + 8, "WRIST")], + ) + ) + + if video: + mp4_path = output_dir / f"{episode_id}.mp4" + _write_mp4(mp4_path, captioned, fps=fps) + logger.info("%s -> %s (%d frames, %d fps)", episode_id, mp4_path, len(captioned), fps) + else: + html_path = output_dir / f"{episode_id}.html" + _write_html(html_path, episode_id, instruction, items) + logger.info("%s -> %s (%d frames)", episode_id, html_path, len(items)) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Render subtasks over DROID frames") + parser.add_argument("--samples_dir", type=str, required=True) + parser.add_argument("--subtasks", type=str, required=True) + parser.add_argument( + "--output_dir", + type=str, + required=True, + help="Directory to write per-episode HTML or mp4 files into.", + ) + parser.add_argument( + "--video", + action="store_true", + help="Emit mp4 per episode (caption drawn on each frame) instead of HTML.", + ) + parser.add_argument( + "--fps", + type=int, + default=2, + help="Frames-per-second for mp4 output. Default 2 (~DROID cache subsample rate).", + ) + args = parser.parse_args() + + samples_dir = Path(args.samples_dir) + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + manifest = load_manifest(samples_dir) + subtask_index = load_subtask_index(Path(args.subtasks)) + logger.info("Loaded %d episodes, %d subtask records", len(manifest), len(subtask_index)) + + for episode in manifest: + _process_episode( + samples_dir=samples_dir, + episode=episode, + subtask_index=subtask_index, + output_dir=output_dir, + video=args.video, + fps=args.fps, + ) + + logger.info("Done. Output: %s", output_dir) + + +if __name__ == "__main__": + main() diff --git a/experiments/subtask_probe/dual_runtime_benchmark.py b/experiments/subtask_probe/dual_runtime_benchmark.py new file mode 100644 index 0000000..949f1f3 --- /dev/null +++ b/experiments/subtask_probe/dual_runtime_benchmark.py @@ -0,0 +1,371 @@ +#!/usr/bin/env python3 +"""Test running JAX subtask generation + PyTorch action generation simultaneously. + +Both models loaded on the same L40S GPU at the same time. +Requires: XLA_PYTHON_CLIENT_MEM_FRACTION=0.4 (or similar) to prevent JAX from +grabbing all VRAM. + +Usage: + XLA_PYTHON_CLIENT_MEM_FRACTION=0.4 uv run python experiments/subtask_probe/dual_runtime_test.py +""" + +from __future__ import annotations + +import string +import sys +import time +from pathlib import Path + +import numpy as np + +OPENPI_SRC = Path(__file__).resolve().parents[2] / "src" +HOSTING_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(HOSTING_ROOT)) +sys.path.insert(0, str(OPENPI_SRC)) + + +def check_jax_memory_fraction() -> None: + """Verify JAX memory fraction is set before importing JAX.""" + import os + + fraction = os.environ.get("XLA_PYTHON_CLIENT_MEM_FRACTION") + preallocate = os.environ.get("XLA_PYTHON_CLIENT_PREALLOCATE") + if fraction is None and preallocate != "false": + print( + "ERROR: Set XLA_PYTHON_CLIENT_MEM_FRACTION=0.4 (or XLA_PYTHON_CLIENT_PREALLOCATE=false)" + ) + print(" Without this, JAX will grab all GPU memory and PyTorch won't load.") + sys.exit(1) + print(f"JAX memory config: MEM_FRACTION={fraction}, PREALLOCATE={preallocate}") + + +check_jax_memory_fraction() + +import jax # noqa: E402 +import jax.numpy as jnp # noqa: E402 +import openpi.shared.download as download # noqa: E402 +import safetensors.torch # noqa: E402 +import sentencepiece # noqa: E402 +import torch # noqa: E402 +from openpi.models import model as _model # noqa: E402 +from openpi.models.pi0 import Pi0, make_attn_mask # noqa: E402 +from openpi.models.pi0_config import Pi0Config # noqa: E402 +from openpi.models_pytorch.pi0_pytorch import PI0Pytorch # noqa: E402 + + +def load_tokenizer() -> sentencepiece.SentencePieceProcessor: + path = download.maybe_download( + "gs://big_vision/paligemma_tokenizer.model", gs={"token": "anon"} + ) + with path.open("rb") as f: + return sentencepiece.SentencePieceProcessor(model_proto=f.read()) # ty: ignore[unknown-argument] + + +def report_gpu_memory(label: str) -> None: + """Print GPU memory usage from both JAX and PyTorch perspectives.""" + # PyTorch view + if torch.cuda.is_available(): + allocated_mb = torch.cuda.memory_allocated() / 1024 / 1024 + reserved_mb = torch.cuda.memory_reserved() / 1024 / 1024 + print( + f" [{label}] PyTorch: {allocated_mb:.0f} MB allocated, {reserved_mb:.0f} MB reserved" + ) + + # JAX view + try: + for device in jax.devices(): + stats = device.memory_stats() + if stats: + used_mb = stats.get("bytes_in_use", 0) / 1024 / 1024 + limit_mb = stats.get("bytes_limit", 0) / 1024 / 1024 + print( + f" [{label}] JAX ({device}): {used_mb:.0f} MB used / {limit_mb:.0f} MB limit" + ) + except Exception: + pass + + +# --------------------------------------------------------------------------- +# JAX subtask generation +# --------------------------------------------------------------------------- + + +def generate_subtask_jax( + jax_model: Pi0, + tokenizer: sentencepiece.SentencePieceProcessor, + task_prompt: str, + max_tokens: int = 20, +) -> tuple[str, float]: + """Generate subtask text using JAX. Returns (subtask_text, elapsed_seconds).""" + cleaned = task_prompt.lower().strip().replace("_", " ").replace("\n", " ") + if cleaned and cleaned[-1] in string.punctuation: + cleaned = cleaned[:-1] + prefix_str = f"Task: {cleaned}. Subtask: " + tokens = tokenizer.encode(prefix_str, add_bos=True) # ty: ignore[unresolved-attribute] + num_real = len(tokens) + max_len = 200 + if num_real < max_len: + mask = [True] * num_real + [False] * (max_len - num_real) + tokens = tokens + [0] * (max_len - num_real) + else: + tokens = tokens[:max_len] + mask = [True] * max_len + num_real = max_len + + tokens_np = np.asarray(tokens, dtype=np.int32) + mask_np = np.asarray(mask, dtype=np.bool_) + + action_dim = 32 + zero_img = np.zeros((224, 224, 3), dtype=np.float32) + obs = _model.Observation( + images={ + "base_0_rgb": jnp.array(zero_img[None]), + "left_wrist_0_rgb": jnp.array(zero_img[None]), + "right_wrist_0_rgb": jnp.array(zero_img[None]), + }, + image_masks={ + "base_0_rgb": jnp.array([True]), + "left_wrist_0_rgb": jnp.array([True]), + "right_wrist_0_rgb": jnp.array([True]), + }, + state=jnp.array(np.zeros(action_dim, dtype=np.float32)[None]), + tokenized_prompt=jnp.array(tokens_np[None]), + tokenized_prompt_mask=jnp.array(mask_np[None]), + ) + + start = time.monotonic() + + obs = _model.preprocess_observation(None, obs, train=False) + prefix_tokens, prefix_mask, prefix_ar_mask = jax_model.embed_prefix(obs) + prefix_attn_mask = make_attn_mask(prefix_mask, prefix_ar_mask) + positions = jnp.cumsum(prefix_mask, axis=1) - 1 # ty: ignore[invalid-argument-type] + + (prefix_out, _), kv_cache = jax_model.PaliGemma.llm( + [prefix_tokens, None], + mask=prefix_attn_mask, + positions=positions, + adarms_cond=[None, None], + ) + + B, prefix_S = prefix_tokens.shape[:2] + seq_indices = jnp.arange(prefix_S)[None, :] + last_pos = jnp.max(jnp.where(prefix_mask, seq_indices, -1), axis=1).astype(jnp.int32) # ty: ignore[no-matching-overload] + last_hidden = prefix_out[jnp.arange(B), last_pos, :] + + embed_table = jax_model.PaliGemma.llm.embedder["input_embedding"].value # ty: ignore[unresolved-attribute] + logits = jnp.dot(last_hidden, embed_table.T) + + generated_ids = [] + next_pos = jnp.array([num_real], dtype=jnp.int32) + + for step in range(max_tokens): + token_id = int(jnp.argmax(logits[0])) + generated_ids.append(token_id) + if token_id in (0, 1): + break + + token_emb = jax_model.PaliGemma.llm(jnp.array([[token_id]]), method="embed") + gen_count = step + 1 + gen_mask = jnp.ones((1, gen_count), dtype=jnp.bool_) + full_mask = jnp.concatenate([prefix_mask, gen_mask], axis=1) # ty: ignore[invalid-argument-type] + attn_mask = full_mask[:, None, :] + + (new_out, _), kv_cache = jax_model.PaliGemma.llm( + [token_emb, None], + mask=attn_mask, + positions=next_pos[:, None], + kv_cache=kv_cache, + adarms_cond=[None, None], + ) + logits = jnp.dot(new_out[:, -1, :], embed_table.T) + next_pos = next_pos + 1 + + elapsed = time.monotonic() - start + + if 1 in generated_ids: + generated_ids = generated_ids[: generated_ids.index(1)] + return tokenizer.decode(generated_ids), elapsed # ty: ignore[unresolved-attribute] + + +# --------------------------------------------------------------------------- +# PyTorch action generation +# --------------------------------------------------------------------------- + + +class FakeObservation: + def __init__( + self, + tokenized_prompt: torch.Tensor, + tokenized_prompt_mask: torch.Tensor, + device: str = "cuda", + action_dim: int = 32, + ) -> None: + self.images = { + "base_0_rgb": torch.zeros(1, 3, 224, 224, device=device), + "left_wrist_0_rgb": torch.zeros(1, 3, 224, 224, device=device), + "right_wrist_0_rgb": torch.zeros(1, 3, 224, 224, device=device), + } + self.image_masks = {k: torch.ones(1, dtype=torch.bool, device=device) for k in self.images} + self.state = torch.zeros(1, action_dim, device=device) + self.tokenized_prompt = tokenized_prompt + self.tokenized_prompt_mask = tokenized_prompt_mask + self.token_ar_mask = None + self.token_loss_mask = None + + +def make_action_observation( + task_prompt: str, + tokenizer: sentencepiece.SentencePieceProcessor, + device: str = "cuda", + action_dim: int = 32, +) -> FakeObservation: + """Build observation with standard pi0.5 action prompt format.""" + cleaned = task_prompt.strip().replace("_", " ").replace("\n", " ") + state = np.zeros(action_dim, dtype=np.float32) + discretized_state = np.digitize(state, bins=np.linspace(-1, 1, 256 + 1)[:-1]) - 1 + state_str = " ".join(map(str, discretized_state)) + full_prompt = f"Task: {cleaned}, State: {state_str};\nAction: " + + tokens = tokenizer.encode(full_prompt, add_bos=True) # ty: ignore[unresolved-attribute] + num_real = len(tokens) + max_len = 200 + if num_real < max_len: + mask = [True] * num_real + [False] * (max_len - num_real) + tokens = tokens + [0] * (max_len - num_real) + else: + tokens = tokens[:max_len] + mask = [True] * max_len + + tok_t = torch.tensor([tokens], dtype=torch.long, device=device) + mask_t = torch.tensor([mask], dtype=torch.bool, device=device) + return FakeObservation(tok_t, mask_t, device, action_dim) + + +def generate_actions_pytorch( + pt_model: PI0Pytorch, + obs: FakeObservation, + device: str = "cuda", +) -> tuple[np.ndarray, float]: + """Run action inference. Returns (actions, elapsed_seconds).""" + start = time.monotonic() + with torch.no_grad(): + actions = pt_model.sample_actions(device, obs, num_steps=10) # ty: ignore[missing-argument, invalid-argument-type] + elapsed = time.monotonic() - start + return actions[0].detach().cpu().numpy(), elapsed + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def main() -> None: + import argparse + + parser = argparse.ArgumentParser(description="Dual runtime test: JAX + PyTorch on same GPU") + parser.add_argument( + "--jax_checkpoint", + type=str, + default=str(Path.home() / ".cache/openpi/openpi-assets/checkpoints/pi05_base"), + ) + parser.add_argument( + "--pytorch_checkpoint", + type=str, + default=str(Path.home() / "checkpoints/pi05_base_pytorch/model.safetensors"), + ) + parser.add_argument("--device", type=str, default="cuda") + args = parser.parse_args() + + tokenizer = load_tokenizer() + + report_gpu_memory("before loading") + + # ============================================= + # Load BOTH models simultaneously + # ============================================= + print("\n=== Loading JAX model ===") + jax_config = Pi0Config(pi05=True) + jax_model = jax_config.create(jax.random.key(0)) + params = _model.restore_params(f"{args.jax_checkpoint}/params", dtype=jnp.bfloat16) + import flax.nnx as nnx + + nnx.update(jax_model, nnx.State(params)) + jax_model.eval() + print("JAX model loaded.") + report_gpu_memory("after JAX load") + + print("\n=== Loading PyTorch model ===") + pt_config = Pi0Config(pi05=True, pytorch_compile_mode=None) + pt_model = PI0Pytorch(pt_config) + safetensors.torch.load_model(pt_model, args.pytorch_checkpoint) + pt_model = pt_model.to(args.device).eval() + pt_model.requires_grad_(False) + print("PyTorch model loaded.") + report_gpu_memory("after both loaded") + + # ============================================= + # Two-phase inference loop + # ============================================= + test_cases = [ + "pick up the red cup and place it on the shelf", + "fold the towel neatly", + "open the drawer and put the block inside", + "wipe the table with the sponge", + ] + + print(f"\n{'=' * 70}") + print(" TWO-PHASE INFERENCE: JAX subtask → PyTorch actions") + print(f"{'=' * 70}") + + for task_prompt in test_cases: + print(f'\n Task: "{task_prompt}"') + + # Phase 1: JAX subtask generation + subtask_text, subtask_time = generate_subtask_jax(jax_model, tokenizer, task_prompt) + print(f' Phase 1 (JAX subtask): "{subtask_text}" [{subtask_time * 1000:.0f}ms]') + + # Phase 2: PyTorch action generation (with subtask in prompt) + hybrid_prompt = f"{task_prompt}. Subtask: {subtask_text}" + obs = make_action_observation(hybrid_prompt, tokenizer, args.device) + actions, action_time = generate_actions_pytorch(pt_model, obs, args.device) + action_norm = np.linalg.norm(actions) + print(f" Phase 2 (PT actions): norm={action_norm:.4f} [{action_time * 1000:.0f}ms]") + print(f" Total latency: {(subtask_time + action_time) * 1000:.0f}ms") + + # ============================================= + # Latency benchmark (5 rounds) + # ============================================= + print(f"\n{'=' * 70}") + print(" LATENCY BENCHMARK (5 rounds, same prompt)") + print(f"{'=' * 70}") + + benchmark_prompt = "pick up the red cup and place it on the shelf" + subtask_times = [] + action_times = [] + + for i in range(5): + subtask_text, st = generate_subtask_jax(jax_model, tokenizer, benchmark_prompt) + hybrid_prompt = f"{benchmark_prompt}. Subtask: {subtask_text}" + obs = make_action_observation(hybrid_prompt, tokenizer, args.device) + _, at = generate_actions_pytorch(pt_model, obs, args.device) + subtask_times.append(st) + action_times.append(at) + print( + f" Round {i + 1}: subtask={st * 1000:.0f}ms action={at * 1000:.0f}ms total={(st + at) * 1000:.0f}ms" + ) + + avg_subtask = np.mean(subtask_times) * 1000 + avg_action = np.mean(action_times) * 1000 + print( + f"\n Average: subtask={avg_subtask:.0f}ms action={avg_action:.0f}ms total={avg_subtask + avg_action:.0f}ms" + ) + + report_gpu_memory("end of benchmark") + + print(f"\n{'=' * 70}") + print(" DONE — Both models coexisted on the same GPU successfully.") + print(f"{'=' * 70}") + + +if __name__ == "__main__": + main() From 0011b51cbfba1717108e2f603ab70e19d12fdc68 Mon Sep 17 00:00:00 2001 From: Kingston Date: Mon, 20 Apr 2026 15:50:45 +0800 Subject: [PATCH 02/17] refactor: update subtask generation and caching policies in findings and README; enhance task scheduling in foresight generation script --- experiments/subtask_probe/FINDINGS.md | 24 +++--- .../subtask_probe/droid_eval/README.md | 2 +- .../generate_foresight_lerobot.py | 76 +++++++++++++++++-- 3 files changed, 84 insertions(+), 18 deletions(-) diff --git a/experiments/subtask_probe/FINDINGS.md b/experiments/subtask_probe/FINDINGS.md index c699df8..e7e4cc1 100644 --- a/experiments/subtask_probe/FINDINGS.md +++ b/experiments/subtask_probe/FINDINGS.md @@ -275,9 +275,9 @@ Previous Modal attempt (2026-04-14) hit import bugs and client disconnect issues ### Production integration -9. **Two-process serving architecture** — JAX subtask service (port 8001) + PyTorch action service (port 8000) behind the existing QUIC endpoint. The action service calls the subtask service on localhost before each action generation cycle. +9. **Single-process combined-mode serving** — one openpi-flash process loads both slots, exposes the action policy on ports 8000 (WebSocket) / 5555 (QUIC) and the JAX planner on ports 8002 / 5556, and shares one `SubtaskGenerator` instance between them. The action endpoint calls the planner first and splices the subtask into the prompt before action inference. See `src/hosting/serve.py`, `src/hosting/subtask_policy.py`, and the README's *Subtask generation (planner)* section. -10. **Subtask caching and refresh policy** — decide how often to re-generate subtasks. Options: every action chunk, every N steps, or when visual change exceeds a threshold. @LisavilaLee's code caches by prompt only (never refreshes), but the paper's Figure 7 shows it should update with the scene. +10. **Subtask caching and refresh policy** — decide how often to re-generate subtasks. Options: every action chunk, every N steps, or when visual change exceeds a threshold. @LisavilaLee's code caches by prompt only (never refreshes), but the paper's Figure 7 shows it should update with the scene. Today combined mode regenerates on every action `infer()` call unless the client opts out with `obs["mode"] = "action_only"`. ### Future optimization: Flash attention for prefix forward @@ -611,9 +611,17 @@ Latency was 0.47-0.68s/frame on L40S (bf16), consistent with the DROID run. - `.experiments_cache/droid_eval_ah15/foresight_foreact/{ep_0000..ep_0004}/frame_*.png` (475 PNGs @ 640×480) - `.experiments_cache/droid_eval_ah15/foreact_html/*.html` (5 per-episode reports, exterior | wrist | foresight | subtask) - `.experiments_cache/droid_eval_ah15/foreact_videos/*.mp4` (5 per-episode mp4s at 2 fps) -- ForeActDataset / Galaxea R1 Lite in-distribution: - - `.experiments_cache/droid_eval_ah15/foresight_picksveg/episode_{000000,000003}/frame_*.png` (12 PNGs) + `actual/` subdirs with source frames - - `.experiments_cache/droid_eval_ah15/foresight_picksveg/picksveg_report.html` (single side-by-side HTML) +- ForeActDataset / Galaxea R1 Lite in-distribution (moved to its own cache dir since it's not a DROID run): + - `.experiments_cache/foreact_eval/foresight_picksveg/` (first 2-episode stride=15 test, with `picksveg_report.html`) + - `.experiments_cache/foreact_eval/foresight_picksveg_dense/` (5 episodes at stride=5, with per-episode mp4s) + - `.experiments_cache/foreact_eval/foresight_chain_eggplant/` (full 3-sub-episode chain ep[0,1,2], unified subtask text, seed=42 — apple-morphing artifact in placement phase) + - `.experiments_cache/foreact_eval/foresight_chain_eggplant_v2/` (**golden** — ep0+ep1 use `Pick up the eggplant`, ep2 uses `Place the eggplant into the plate`, seed=123; see `chain_eggplant_v2.mp4` / `chain_eggplant_v2.html`) + +Ablations tried for the golden chain (all drifted worse than v2, deleted): +- ep1+ep2 both `Place the eggplant into the plate` — drifted the eggplant identity starting mid-ep1 +- mid-ep1 swap at frame 55 — same drift starting at the swap point + +Takeaway on labeling: the generator was trained to predict *half-a-subtask ahead*, so the right text for a given frame describes the state ~2.5 s ahead, not the *current* motion phase. For ep1 mid-frames that future state is still "arm holding eggplant over plate" → a pick-phase description. Switching to `Place ...` before the arm is actually descending onto the plate creates a text-vs-image conflict and drifts the eggplant identity. ## Open Questions @@ -622,7 +630,5 @@ Latency was 0.47-0.68s/frame on L40S (bf16), consistent with the DROID run. 3. Is the short output (3-4 tokens) an inherent limitation of the base checkpoint, or a prompt format / missing-images issue? 4. Does the ~100 gradient step fine-tuning need real robot data, or would LLM-generated subtask decompositions work? 5. How much does subtask conditioning improve action quality for long-horizon tasks (the paper reports it's significant)? -6. Can JIT compilation reduce JAX subtask latency from 14s to <1s? The prefix forward is fixed-shape and should JIT well, but the AR loop's growing KV cache is a challenge. -7. Is 32GB system RAM sufficient for dual-runtime JIT, or do we need g6e.2xlarge (64GB)? -8. For the hybrid prompt approach (no retraining), does injecting subtask text into the action format produce *better* actions with real images, or just *different* ones? -9. Does ASCII vocabulary masking eliminate Unicode garbage and preserve English subtask quality? (pending re-run) +6. For the hybrid prompt approach (no retraining), does injecting subtask text into the action format produce *better* actions with real images, or just *different* ones? +7. Does ASCII vocabulary masking eliminate Unicode garbage and preserve English subtask quality? (pending re-run) diff --git a/experiments/subtask_probe/droid_eval/README.md b/experiments/subtask_probe/droid_eval/README.md index bc24871..4e24752 100644 --- a/experiments/subtask_probe/droid_eval/README.md +++ b/experiments/subtask_probe/droid_eval/README.md @@ -71,7 +71,7 @@ uv run python -m experiments.subtask_probe.droid_eval.extract_droid_samples \ --min_duration_s 60 --require_multi_step \ --output_dir ./.experiments_cache/droid_eval_2min -# Phase 1: Generate subtasks (~14s per frame with JAX) +# Phase 1: Generate subtasks (JAX subtask generator, ~1s per frame warm) uv run python experiments/subtask_probe/droid_eval/generate_subtasks.py \ --samples_dir ./.experiments_cache/droid_eval \ --output ./.experiments_cache/droid_eval/subtasks.json diff --git a/experiments/subtask_probe/droid_eval/foreact_eval/generate_foresight_lerobot.py b/experiments/subtask_probe/droid_eval/foreact_eval/generate_foresight_lerobot.py index ca16cdf..4b2abc1 100644 --- a/experiments/subtask_probe/droid_eval/foreact_eval/generate_foresight_lerobot.py +++ b/experiments/subtask_probe/droid_eval/foreact_eval/generate_foresight_lerobot.py @@ -133,9 +133,53 @@ def _parse_args() -> argparse.Namespace: parser.add_argument("--image_guidance_scale", type=float, default=1.5) parser.add_argument("--num_inference_steps", type=int, default=8) parser.add_argument("--seed", type=int, default=42) + parser.add_argument( + "--force_task", + type=str, + default=None, + help=( + "Override the per-episode task label with this text for every processed " + "episode. Use when rendering a linked chain where later sub-episodes are " + "labeled 'Finish.' but are really continuations of the earlier task." + ), + ) + parser.add_argument( + "--task_schedule", + type=str, + default=None, + help=( + "Path to a JSON file mapping (episode, frame-range) -> subtask text. " + "Expected shape: list of {episode, start_frame, end_frame, task}. " + "Frames are source-frame indices (pre-stride). A frame is conditioned " + "on the first matching entry. Overrides --force_task when both are set." + ), + ) return parser.parse_args() +def _load_task_schedule(path: str | None) -> list[dict[str, Any]] | None: + if path is None: + return None + with Path(path).open() as f: + schedule = json.load(f) + for entry in schedule: + required = {"episode", "start_frame", "end_frame", "task"} + if not required.issubset(entry): + raise ValueError(f"task_schedule entry missing keys {required - entry.keys()}: {entry}") + return schedule + + +def _lookup_scheduled_task( + schedule: list[dict[str, Any]] | None, ep_idx: int, frame_idx: int +) -> str | None: + if schedule is None: + return None + for entry in schedule: + if entry["episode"] == ep_idx and entry["start_frame"] <= frame_idx <= entry["end_frame"]: + return entry["task"] + return None + + def main() -> None: args = _parse_args() dataset_root = Path(args.dataset_root) @@ -169,16 +213,22 @@ def main() -> None: torch.manual_seed(args.seed) torch.cuda.manual_seed_all(args.seed) + schedule = _load_task_schedule(args.task_schedule) + generation_records: list[dict[str, Any]] = [] for ep_idx in episode_indices: meta = episode_meta[ep_idx] tasks: list[str] = meta.get("tasks") or [] - # Skip episodes whose label is the "Finish." filler used as a between- - # task reset signal (task_index=1 in tasks.jsonl). - if not tasks or tasks[0].strip().lower().rstrip(".") == "finish": + episode_fallback_task: str | None + if args.force_task is not None: + episode_fallback_task = args.force_task + elif tasks and tasks[0].strip().lower().rstrip(".") != "finish": + episode_fallback_task = tasks[0] + elif schedule is not None and any(e["episode"] == ep_idx for e in schedule): + episode_fallback_task = None # schedule will supply the text + else: logger.info("Skipping episode %d (task=%r)", ep_idx, tasks) continue - subtask_text = tasks[0] video_path = ( dataset_root / "videos" / "chunk-000" / args.camera_key / f"episode_{ep_idx:06d}.mp4" ) @@ -189,11 +239,12 @@ def main() -> None: logger.info("Decoding %s ...", video_path) frames = _decode_mp4_frames(video_path, stride=args.stride) logger.info( - "Episode %d: %d strided frames (length=%d) — subtask=%r", + "Episode %d: %d strided frames (length=%d) — fallback subtask=%r (schedule=%s)", ep_idx, len(frames), meta.get("length"), - subtask_text, + episode_fallback_task, + "yes" if schedule is not None else "no", ) episode_dir = output_dir / f"episode_{ep_idx:06d}" @@ -208,11 +259,20 @@ def main() -> None: frame_idx = step_idx * args.stride Image.fromarray(frame_rgb).save(src_dir / f"frame_{frame_idx:05d}.png") seed = (args.seed, ep_idx, frame_idx).__hash__() & 0x7FFFFFFF + scheduled = _lookup_scheduled_task(schedule, ep_idx, frame_idx) + per_frame_task = scheduled if scheduled is not None else episode_fallback_task + if per_frame_task is None: + logger.warning( + "No task text for ep%d frame %d (no schedule match + no fallback); skipping", + ep_idx, + frame_idx, + ) + continue try: image, elapsed = _process_frame( pipeline, frame_rgb, - subtask_text, + per_frame_task, guidance_scale=args.guidance_scale, image_guidance_scale=args.image_guidance_scale, num_inference_steps=args.num_inference_steps, @@ -228,7 +288,7 @@ def main() -> None: { "episode_index": ep_idx, "frame_idx": frame_idx, - "subtask_text": subtask_text, + "subtask_text": per_frame_task, "output": str(out_path.relative_to(output_dir)), "generation_time_s": round(elapsed, 3), "seed": seed, From e48775e583eb6ad40c445fb82b2c16c11179db17 Mon Sep 17 00:00:00 2001 From: Kingston Date: Mon, 20 Apr 2026 22:41:33 +0800 Subject: [PATCH 03/17] feat: add nano-banana foresight generation and visualization scripts; enhance subtask generation with transport options --- .../droid_eval/foreact_eval/_io.py | 59 +++ .../generate_foresight_nano_banana.py | 422 ++++++++++++++++++ .../foreact_eval/visualize_nano_banana.py | 136 ++++++ .../droid_eval/generate_subtasks.py | 49 +- 4 files changed, 661 insertions(+), 5 deletions(-) create mode 100644 experiments/subtask_probe/droid_eval/foreact_eval/_io.py create mode 100644 experiments/subtask_probe/droid_eval/foreact_eval/generate_foresight_nano_banana.py create mode 100644 experiments/subtask_probe/droid_eval/foreact_eval/visualize_nano_banana.py diff --git a/experiments/subtask_probe/droid_eval/foreact_eval/_io.py b/experiments/subtask_probe/droid_eval/foreact_eval/_io.py new file mode 100644 index 0000000..6ba6e26 --- /dev/null +++ b/experiments/subtask_probe/droid_eval/foreact_eval/_io.py @@ -0,0 +1,59 @@ +"""Shared helpers for the foreact_eval package. + +Currently imported by the nano-banana generator + visualizer. The ForeAct +generators (generate_foresight.py, generate_foresight_lerobot.py) run in a +separate conda env on the remote GPU box and don't import these helpers, +but nothing here prevents them from doing so later. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Literal, NamedTuple + + +def foresight_path(output_dir: Path, episode_index: int, frame_idx: int) -> Path: + """Where a foresight generator writes its PNG for one frame. + + Single owner of the on-disk ``episode_{:06d}/frame_{:05d}.png`` layout. + If we ever change it (e.g. to include model name in the path), this is + the only place to touch. + """ + return output_dir / f"episode_{episode_index:06d}" / f"frame_{frame_idx:05d}.png" + + +class SourceFrame(NamedTuple): + """One input frame for foresight generation or visualization. + + Produced by ``iter_source_frames`` at the filesystem boundary so + downstream code treats (episode_index, frame_idx) as one logical ID + and doesn't re-parse filenames. + """ + + episode_index: int + frame_idx: int + actual_path: Path + + +def iter_source_frames(source_root: Path) -> list[SourceFrame]: + """Every frame under ``source_root/episode_*/actual/frame_*.png``. + + Sorted by (episode_index, frame_idx) so callers render or generate in + temporal order. + """ + frames: list[SourceFrame] = [] + for episode_dir in sorted(source_root.glob("episode_*")): + actual_dir = episode_dir / "actual" + if not actual_dir.exists(): + continue + episode_index = int(episode_dir.name.removeprefix("episode_")) + for path in sorted(actual_dir.glob("frame_*.png")): + frame_idx = int(path.stem.removeprefix("frame_")) + frames.append(SourceFrame(episode_index, frame_idx, path)) + return frames + + +# Chain-mode manifest records one of these per frame. Typing as a Literal +# (not free-form str) means a typo in any write site fails at type-check +# instead of silent log-grep later. +ForesightStatus = Literal["cached", "generated", "failed", "refused"] diff --git a/experiments/subtask_probe/droid_eval/foreact_eval/generate_foresight_nano_banana.py b/experiments/subtask_probe/droid_eval/foreact_eval/generate_foresight_nano_banana.py new file mode 100644 index 0000000..e4dfe2c --- /dev/null +++ b/experiments/subtask_probe/droid_eval/foreact_eval/generate_foresight_nano_banana.py @@ -0,0 +1,422 @@ +#!/usr/bin/env python3 +"""Foresight image generation via Gemini 3.1 Flash Image ("Nano Banana"). + +Drop-in alternative to the ForeAct SANA+Gemma generator. Doesn't require a +GPU or any pretrained robot-specific weights — just the google-genai SDK and +a careful generic scene-rules prompt. + +Two structural choices worth knowing: + +1. **Generic scene-rules prompt + subtask slot.** A single ``SCENE_RULES_TEMPLATE`` + encodes the scene inventory, robot anatomy, grasp physics, and preservation + constraints. Per-frame subtask labels (from ``CHAIN_PHASES`` or, eventually, + a Qwen3-VL planner) slot into ``{subtask}``. This replaces the earlier + per-phase hand-crafted prompts and makes short planner output pluggable. + +2. **Two-image conditioning.** Each API call passes (reference_frame, + current_observation, prompt). The reference frame (ep0 f00, all objects on + the table, nothing occluded) gives the stateless image generator a visual + anchor for object identity. This is specifically to combat the "eggplant + morphs to apple during carry frames" failure mode we saw with single-image + conditioning when the eggplant is occluded inside the closed gripper. +""" + +from __future__ import annotations + +import argparse +import io +import json +import logging +import os +from pathlib import Path +from typing import Any, NamedTuple + +from google import genai +from google.genai import types +from PIL import Image + +from experiments.subtask_probe.droid_eval.foreact_eval._io import ( + ForesightStatus, + foresight_path, + iter_source_frames, +) + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + + +DEFAULT_MODEL = "gemini-3.1-flash-image-preview" + + +# --------------------------------------------------------------------------- +# Generic scene-rules prompt + per-phase subtask slot. +# +# Rationale: the earlier version had a bespoke ~200-word descriptive prompt +# per phase (PICK_UP_PROMPT / PLACE_PROMPT / RETURN_HOME_PROMPT). That made +# each phase's prompt a one-shot artifact — you couldn't plug in a short +# subtask label from a VLM planner (Qwen3-VL) without rewriting the prompt. +# The new design splits out (a) a stable SCENE_RULES prompt that encodes the +# physics and scene inventory once, and (b) a short ``subtask`` string that +# slots in per frame. Now any short subtask label (hardcoded from +# CHAIN_PHASES or dynamic from a planner) drives the generator. +# +# Multi-image input: we also pass a REFERENCE FRAME (ep0 f00, all objects on +# the table with nothing occluded) alongside the current observation. With a +# stateless single-image generator, the eggplant's identity collapses during +# carry frames where it's occluded behind the closed gripper. The reference +# frame gives the model an anchor for what the eggplant looks like. +# --------------------------------------------------------------------------- + +SCENE_RULES_TEMPLATE = """\ +You will be given TWO images followed by a subtask instruction. + +IMAGE 1 — REFERENCE FRAME. The scene in its starting state with all objects \ +clearly visible. Use this STRICTLY as a reference for OBJECT IDENTITY — \ +especially what the small dark-purple eggplant looks like, since it will be \ +occluded in later frames. Do NOT output this frame as the future; it is \ +only for identity anchoring. + +IMAGE 2 — CURRENT OBSERVATION. This is the frame from which you will \ +predict the scene's FUTURE state. The future image must preserve this \ +frame's camera viewpoint, lighting, table surface, and background walls \ +EXACTLY; only what the subtask requires should change. + +SCENE INVENTORY (left to right, as shown in the reference frame): green \ +leek with white base on the far left; small red chili pepper next to the \ +leek; empty blue plate in the middle of the table; small dark-purple \ +eggplant (oval, glossy dark-purple skin, with a short stem); green \ +zucchini; yellow corn on the far right. + +ROBOT: Galaxea R1 Lite right arm. Enters from the UPPER-RIGHT EDGE of the \ +frame and is always continuous to that edge — no floating grippers. \ +Orange-red upper-arm segment connected to the top-right corner, black \ +forearm, black parallel-finger gripper with exactly two parallel fingers \ +(not a claw). Grasps oblong objects by their stem, so a held object hangs \ +BELOW the closed fingers as a visible silhouette. + +SUBTASK SEMANTICS: subtask instructions name actions the ROBOT ARM takes, \ +not actions that undo previous work. Specifically, "return to home \ +position" or "go home" means the ARM RETRACTS upward/out of frame — it \ +does NOT mean moving any object back. Once an object is in the blue \ +plate, it stays in the plate unless a future subtask explicitly says to \ +move it somewhere else. Only the subtask in "CURRENT SUBTASK" below is \ +active; do not reverse earlier subtasks. + +WORLD PRESERVATION RULE (strict): anything the current subtask does NOT \ +explicitly act on must appear in the output image EXACTLY as it does in \ +IMAGE 2 — same position, same orientation, same color, same condition. \ +Do not delete, duplicate, relocate, or alter any object the subtask does \ +not touch. If an object (including the eggplant, the blue plate, or any \ +vegetable) is visible in IMAGE 2, it must be visible in the same place \ +in the output unless the subtask explicitly moves it. The world is \ +static except where the robot arm is actively manipulating something. + +CURRENT SUBTASK: {subtask} + +TASK: Predict the scene at roughly the half-subtask-ahead point of this \ +subtask (the paper's "t + 0.5 times subtask duration" target). If the subtask \ +involves moving the eggplant, the predicted frame should show that motion \ +well underway or complete; if the eggplant ends up inside the blue plate, \ +render it as the SAME dark-purple eggplant shown in IMAGE 1 (do not morph \ +it to an apple, plum, or any other fruit). If the eggplant is occluded \ +inside the closed gripper, do NOT draw it on the table or invent a \ +substitute. The four non-target vegetables stay in their exact positions \ +from IMAGE 2 unless the subtask says otherwise. +""" + + +class Phase(NamedTuple): + """One subtask's extent within the chain. + + ``start_frame`` / ``end_frame`` are inclusive and refer to raw frame + indices inside ``episode_index`` (same scale as filenames on disk — + stride=5 between consecutive frames). Frames outside any phase are + skipped by both the generator and the visualizer. + """ + + episode_index: int + start_frame: int + end_frame: int + subtask_label: str + + +# Boundaries tuned against the v2 golden chain's actual physics: +# * ep0 f00-25: arm not in frame yet → trim (no API call, no video frame). +# * ep0 f30 → ep1 f25: approach + grasp → "Pick up" subtask. +# * ep1 f30 → ep2 f15: eggplant in air, carried + placed → "Place" subtask. +# * ep2 f20 → f55: arm retracting to home → "Return home" subtask. +# * ep2 f60+: arm already home, scene static → trim. +CHAIN_PHASES: list[Phase] = [ + Phase(episode_index=0, start_frame=30, end_frame=100, subtask_label="Pick up the eggplant."), + Phase(episode_index=1, start_frame=0, end_frame=25, subtask_label="Pick up the eggplant."), + Phase( + episode_index=1, + start_frame=30, + end_frame=75, + subtask_label="Place the eggplant into the plate.", + ), + Phase( + episode_index=2, + start_frame=0, + end_frame=15, + subtask_label="Place the eggplant into the plate.", + ), + Phase(episode_index=2, start_frame=20, end_frame=55, subtask_label="Return to home position."), +] + + +# Default reference frame — ep0 f00 of the v2 chain has all objects on the +# table with nothing occluded, which is what we want as an identity anchor. +DEFAULT_REFERENCE_FRAME = Path( + ".experiments_cache/foreact_eval/foresight_chain_eggplant_v2/episode_000000/actual/frame_00000.png" +) + + +def lookup_phase(episode_index: int, frame_idx: int) -> Phase | None: + """Return the phase that owns (episode_index, frame_idx), or None if trimmed.""" + for phase in CHAIN_PHASES: + if phase.episode_index != episode_index: + continue + if phase.start_frame <= frame_idx <= phase.end_frame: + return phase + return None + + +def _extract_image(response: Any) -> bytes | None: + """Pull the first inline image from a Gemini generate_content response.""" + candidates = getattr(response, "candidates", None) or [] + for candidate in candidates: + content = getattr(candidate, "content", None) + parts = getattr(content, "parts", None) or [] + for part in parts: + inline = getattr(part, "inline_data", None) + if inline is not None and getattr(inline, "data", None): + return inline.data + return None + + +def _extract_text(response: Any) -> str: + """Concatenate any text parts — Gemini sometimes narrates alongside the image.""" + texts: list[str] = [] + candidates = getattr(response, "candidates", None) or [] + for candidate in candidates: + content = getattr(candidate, "content", None) + parts = getattr(content, "parts", None) or [] + for part in parts: + text = getattr(part, "text", None) + if text: + texts.append(text) + return "\n".join(texts).strip() + + +def _generate_one( + client: genai.Client, + model: str, + reference_bytes: bytes, + current_bytes: bytes, + prompt: str, +) -> tuple[bytes | None, str]: + """Call Gemini with (reference_frame, current_frame, prompt) and return (image, text). + + Passing two images in order lets the model anchor object identity to the + reference frame — critical for the "Place" phase where the eggplant is + occluded inside the closed gripper in the current observation. + """ + response = client.models.generate_content( + model=model, + contents=[ + types.Part.from_bytes(data=reference_bytes, mime_type="image/png"), + types.Part.from_bytes(data=current_bytes, mime_type="image/png"), + prompt, + ], + config=types.GenerateContentConfig(response_modalities=["IMAGE", "TEXT"]), + ) + return _extract_image(response), _extract_text(response) + + +def _chain_record( + *, + episode_index: int, + frame_idx: int, + subtask_text: str, + status: ForesightStatus, + output: str | None, + extra: dict[str, Any] | None = None, +) -> dict[str, Any]: + record: dict[str, Any] = { + "episode_index": episode_index, + "frame_idx": frame_idx, + "subtask_text": subtask_text, + "output": output, + "status": status, + } + if extra: + record.update(extra) + return record + + +def _run_chain( + client: genai.Client, + model: str, + v2_root: Path, + output_dir: Path, + reference_frame_path: Path, + force: bool, +) -> None: + """Generate foresight for every in-phase frame of the v2 chain. + + Each call passes (reference_frame, current_frame, generic_prompt) to + anchor object identity. Frames outside all CHAIN_PHASES (e.g. pre-arm + intro frames or post-arm-home tail frames) are trimmed — no API call, + no manifest entry. Output layout matches the ForeAct generator so + downstream visualization tools can point at this directory: + ``/episode_{id:06d}/frame_{idx:05d}.png``. + """ + if not reference_frame_path.exists(): + raise SystemExit(f"reference frame missing: {reference_frame_path}") + reference_bytes = reference_frame_path.read_bytes() + logger.info("Using reference frame: %s (%d bytes)", reference_frame_path, len(reference_bytes)) + + all_source_frames = iter_source_frames(v2_root) + frames = [ + f for f in all_source_frames if lookup_phase(f.episode_index, f.frame_idx) is not None + ] + trimmed = len(all_source_frames) - len(frames) + logger.info("Chain has %d in-phase frames (trimmed %d)", len(frames), trimmed) + + records: list[dict[str, Any]] = [] + for i, frame in enumerate(frames): + phase = lookup_phase(frame.episode_index, frame.frame_idx) + assert phase is not None # filtered above + out_path = foresight_path(output_dir, frame.episode_index, frame.frame_idx) + out_path.parent.mkdir(parents=True, exist_ok=True) + relative_out = str(out_path.relative_to(output_dir)) + progress = f"[{i + 1}/{len(frames)}] ep{frame.episode_index} f{frame.frame_idx:05d}" + + if out_path.exists() and not force: + logger.info("%s: already exists, skipping", progress) + records.append( + _chain_record( + episode_index=frame.episode_index, + frame_idx=frame.frame_idx, + subtask_text=phase.subtask_label, + status="cached", + output=relative_out, + ) + ) + continue + + current_bytes = frame.actual_path.read_bytes() + prompt = SCENE_RULES_TEMPLATE.format(subtask=phase.subtask_label) + logger.info("%s: generating (subtask=%r)", progress, phase.subtask_label) + try: + out_bytes, narration = _generate_one( + client, model, reference_bytes, current_bytes, prompt + ) + except Exception as exc: + logger.warning("%s: failed: %s", progress, exc) + records.append( + _chain_record( + episode_index=frame.episode_index, + frame_idx=frame.frame_idx, + subtask_text=phase.subtask_label, + status="failed", + output=None, + extra={"error": str(exc)}, + ) + ) + continue + if out_bytes is None: + logger.warning("%s: no image (narration=%r)", progress, narration[:200]) + records.append( + _chain_record( + episode_index=frame.episode_index, + frame_idx=frame.frame_idx, + subtask_text=phase.subtask_label, + status="refused", + output=None, + extra={"narration": narration}, + ) + ) + continue + image = Image.open(io.BytesIO(out_bytes)).convert("RGB") + image.save(out_path) + records.append( + _chain_record( + episode_index=frame.episode_index, + frame_idx=frame.frame_idx, + subtask_text=phase.subtask_label, + status="generated", + output=relative_out, + ) + ) + + manifest_path = output_dir / "foresight_manifest.json" + with manifest_path.open("w") as f: + json.dump( + { + "source_dataset": str(v2_root), + "model": model, + "chain_phases": [phase._asdict() for phase in CHAIN_PHASES], + "records": records, + }, + f, + indent=2, + ) + logger.info("Wrote manifest -> %s", manifest_path) + counts: dict[str, int] = {} + for record in records: + counts[record["status"]] = counts.get(record["status"], 0) + 1 + logger.info("Status counts: %s", counts) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Nano Banana foresight generator (generic scene-rules + 2-image conditioning)" + ) + parser.add_argument( + "--v2_root", + type=Path, + default=Path(".experiments_cache/foreact_eval/foresight_chain_eggplant_v2"), + help="Directory containing the v2 golden chain episodes with actual/ frames.", + ) + parser.add_argument( + "--output_dir", + type=Path, + default=Path(".experiments_cache/foreact_eval/foresight_nano_banana_chain"), + ) + parser.add_argument( + "--reference_frame", + type=Path, + default=DEFAULT_REFERENCE_FRAME, + help="Identity-anchor frame shown alongside each current observation. Default: ep0 f00.", + ) + parser.add_argument("--model", type=str, default=DEFAULT_MODEL) + parser.add_argument( + "--force", + action="store_true", + help="Regenerate even if an output PNG already exists.", + ) + return parser.parse_args() + + +def main() -> None: + args = _parse_args() + if not os.environ.get("GEMINI_API_KEY") and not os.environ.get("GOOGLE_API_KEY"): + raise SystemExit("Set GEMINI_API_KEY (or GOOGLE_API_KEY) before running.") + + args.output_dir.mkdir(parents=True, exist_ok=True) + client = genai.Client() + _run_chain( + client, + args.model, + args.v2_root, + args.output_dir, + args.reference_frame, + args.force, + ) + logger.info("Done. Outputs in %s", args.output_dir) + + +if __name__ == "__main__": + main() diff --git a/experiments/subtask_probe/droid_eval/foreact_eval/visualize_nano_banana.py b/experiments/subtask_probe/droid_eval/foreact_eval/visualize_nano_banana.py new file mode 100644 index 0000000..94a0cc7 --- /dev/null +++ b/experiments/subtask_probe/droid_eval/foreact_eval/visualize_nano_banana.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Render the nano-banana foresight chain as a side-by-side mp4. + +``actual | foresight`` per frame, subtask caption overlaid on the bottom, +episode/frame footer under it. Runs at 2 fps by default to match the +ForeAct v2 golden chain mp4. + +Caption/composite drawing is shared with ``visualize_subtasks.py`` so this +visualizer and ``visualize_foreact.py`` produce stylistically matched +videos. Episode-phase subtask labels come from +``generate_foresight_nano_banana.EPISODE_PHASES`` so the label shown in the +mp4 is literally the same string the generator wrote into its manifest. +""" + +from __future__ import annotations + +import argparse +import logging +from pathlib import Path + +import imageio.v3 as iio3 +import numpy as np +from PIL import Image, ImageDraw + +from experiments.subtask_probe.droid_eval.foreact_eval._io import ( + foresight_path, + iter_source_frames, +) +from experiments.subtask_probe.droid_eval.foreact_eval.generate_foresight_nano_banana import ( + lookup_phase, +) +from experiments.subtask_probe.droid_eval.visualize_subtasks import ( + _composite_side_by_side, + _draw_caption, +) + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + + +FRAME_W = 480 +FRAME_H = 270 +SEPARATOR_PX = 4 + + +def _load_resized(path: Path, *, w: int, h: int) -> np.ndarray: + with Image.open(path) as img: + resized = img.convert("RGB").resize((w, h), Image.Resampling.LANCZOS) + return np.asarray(resized, dtype=np.uint8) + + +def _missing_placeholder(w: int, h: int, text: str) -> np.ndarray: + img = Image.new("RGB", (w, h), color=(40, 40, 40)) + draw = ImageDraw.Draw(img) + tw = draw.textlength(text) + draw.text(((w - tw) / 2, h / 2 - 8), text, fill=(200, 200, 200)) + return np.asarray(img, dtype=np.uint8) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Render nano-banana chain as mp4") + parser.add_argument( + "--v2_root", + type=Path, + default=Path(".experiments_cache/foreact_eval/foresight_chain_eggplant_v2"), + help="Source 'actual' frames dir (episode_*/actual/frame_*.png).", + ) + parser.add_argument( + "--foresight_dir", + type=Path, + default=Path(".experiments_cache/foreact_eval/foresight_nano_banana_chain"), + help="Nano-banana foresight dir (episode_*/frame_*.png).", + ) + parser.add_argument( + "--output_path", + type=Path, + default=Path( + ".experiments_cache/foreact_eval/foresight_nano_banana_chain/chain_nano_banana.mp4" + ), + ) + parser.add_argument("--fps", type=int, default=2) + return parser.parse_args() + + +def main() -> None: + args = _parse_args() + all_frames = iter_source_frames(args.v2_root) + phased = [(f, lookup_phase(f.episode_index, f.frame_idx)) for f in all_frames] + in_phase = [(f, p) for (f, p) in phased if p is not None] + if not in_phase: + raise SystemExit(f"No in-phase frames found in {args.v2_root}") + logger.info( + "Rendering %d in-phase frames (trimmed %d) @ %d fps", + len(in_phase), + len(all_frames) - len(in_phase), + args.fps, + ) + + composites: list[np.ndarray] = [] + for frame, phase in in_phase: + actual = _load_resized(frame.actual_path, w=FRAME_W, h=FRAME_H) + foresight_file = foresight_path(args.foresight_dir, frame.episode_index, frame.frame_idx) + foresight = ( + _load_resized(foresight_file, w=FRAME_W, h=FRAME_H) + if foresight_file.exists() + else _missing_placeholder(FRAME_W, FRAME_H, "(no foresight)") + ) + composite = _composite_side_by_side( + {"actual": actual, "foresight": foresight}, separator_px=SEPARATOR_PX + ) + footer = f"episode {frame.episode_index:03d} \u00b7 frame {frame.frame_idx:05d}" + composites.append( + _draw_caption( + composite, + caption=phase.subtask_label, + footer=footer, + camera_labels=[ + (4, "ACTUAL"), + (FRAME_W + SEPARATOR_PX + 4, "FORESIGHT"), + ], + ) + ) + + args.output_path.parent.mkdir(parents=True, exist_ok=True) + iio3.imwrite( + args.output_path, + np.stack(composites), + fps=args.fps, + codec="libx264", + macro_block_size=1, + ) + logger.info("Wrote %s", args.output_path) + + +if __name__ == "__main__": + main() diff --git a/experiments/subtask_probe/droid_eval/generate_subtasks.py b/experiments/subtask_probe/droid_eval/generate_subtasks.py index d154354..5cbdedf 100644 --- a/experiments/subtask_probe/droid_eval/generate_subtasks.py +++ b/experiments/subtask_probe/droid_eval/generate_subtasks.py @@ -34,11 +34,14 @@ import httpx import numpy as np +from openpi_client import websocket_client_policy as _websocket_client_policy from hosting.admin_server import DEFAULT_ADMIN_PORT from hosting.flash_transport_policy import FlashTransportPolicy from .constants import DEFAULT_QUIC_PORT + +DEFAULT_PLANNER_WS_PORT = 8002 from .utils import build_subtask_observation, build_warmup_observation, load_manifest logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") @@ -112,6 +115,31 @@ def main() -> None: help="Server address (e.g., 43.200.36.250)", ) parser.add_argument("--port", type=int, default=DEFAULT_QUIC_PORT, help="Server QUIC port") + parser.add_argument( + "--transport", + choices=["quic", "ws"], + default="quic", + help=( + "Transport to use for subtask inference. QUIC's Arrow codec currently " + "cannot carry planner responses (no array columns), so use 'ws' when " + "hitting the planner endpoint." + ), + ) + parser.add_argument( + "--ws_port", + type=int, + default=DEFAULT_PLANNER_WS_PORT, + help="WebSocket port for the planner endpoint (used when --transport=ws).", + ) + parser.add_argument( + "--admin_host", + type=str, + default=None, + help=( + "Host for the admin HTTP endpoint. Defaults to --server. Use 127.0.0.1 with " + "an SSH tunnel when the deployed server binds admin to localhost." + ), + ) parser.add_argument( "--admin_port", type=int, @@ -137,18 +165,29 @@ def main() -> None: # Set or read the active subtask prompt format on the server before any inference. # The runtime config is read on every generate() call server-side, so this takes # effect on the next request. + admin_host = args.admin_host or args.server if args.prompt_format is not None: active_prompt_format = _set_server_prompt_format( - args.server, args.admin_port, args.prompt_format + admin_host, args.admin_port, args.prompt_format ) logger.info("Set server subtask prompt format to %r", active_prompt_format) else: - active_prompt_format = _get_server_prompt_format(args.server, args.admin_port) + active_prompt_format = _get_server_prompt_format(admin_host, args.admin_port) logger.info("Using server's current subtask prompt format: %r", active_prompt_format) - # Connect to server via QUIC - policy = FlashTransportPolicy(args.server, port=args.port) - logger.info("Connected to server at %s:%d via QUIC", args.server, args.port) + # Connect to server. Prefer QUIC for latency, but fall back to WS when + # hitting the planner endpoint because the Arrow codec requires at least + # one array column and planner responses contain only scalar metadata. + if args.transport == "ws": + policy = _websocket_client_policy.WebsocketClientPolicy(host=args.server, port=args.ws_port) + logger.info( + "Connected to server at %s:%d via WebSocket (planner endpoint)", + args.server, + args.ws_port, + ) + else: + policy = FlashTransportPolicy(args.server, port=args.port) + logger.info("Connected to server at %s:%d via QUIC", args.server, args.port) # Warm up connection policy.infer(build_warmup_observation(mode="subtask_only")) From 4dcf65c50174da022382f6964daf5140d96049be Mon Sep 17 00:00:00 2001 From: Kingston Date: Mon, 20 Apr 2026 23:31:15 +0800 Subject: [PATCH 04/17] refactor: update logging configuration and streamline transport connection in subtask generation script --- .../generate_foresight_nano_banana.py | 101 +++++++++--------- .../droid_eval/generate_subtasks.py | 37 +------ 2 files changed, 56 insertions(+), 82 deletions(-) diff --git a/experiments/subtask_probe/droid_eval/foreact_eval/generate_foresight_nano_banana.py b/experiments/subtask_probe/droid_eval/foreact_eval/generate_foresight_nano_banana.py index e4dfe2c..c289f5c 100644 --- a/experiments/subtask_probe/droid_eval/foreact_eval/generate_foresight_nano_banana.py +++ b/experiments/subtask_probe/droid_eval/foreact_eval/generate_foresight_nano_banana.py @@ -70,58 +70,61 @@ SCENE_RULES_TEMPLATE = """\ You will be given TWO images followed by a subtask instruction. -IMAGE 1 — REFERENCE FRAME. The scene in its starting state with all objects \ -clearly visible. Use this STRICTLY as a reference for OBJECT IDENTITY — \ -especially what the small dark-purple eggplant looks like, since it will be \ -occluded in later frames. Do NOT output this frame as the future; it is \ -only for identity anchoring. - -IMAGE 2 — CURRENT OBSERVATION. This is the frame from which you will \ -predict the scene's FUTURE state. The future image must preserve this \ -frame's camera viewpoint, lighting, table surface, and background walls \ -EXACTLY; only what the subtask requires should change. - -SCENE INVENTORY (left to right, as shown in the reference frame): green \ -leek with white base on the far left; small red chili pepper next to the \ -leek; empty blue plate in the middle of the table; small dark-purple \ -eggplant (oval, glossy dark-purple skin, with a short stem); green \ -zucchini; yellow corn on the far right. - -ROBOT: Galaxea R1 Lite right arm. Enters from the UPPER-RIGHT EDGE of the \ -frame and is always continuous to that edge — no floating grippers. \ -Orange-red upper-arm segment connected to the top-right corner, black \ -forearm, black parallel-finger gripper with exactly two parallel fingers \ -(not a claw). Grasps oblong objects by their stem, so a held object hangs \ -BELOW the closed fingers as a visible silhouette. - -SUBTASK SEMANTICS: subtask instructions name actions the ROBOT ARM takes, \ -not actions that undo previous work. Specifically, "return to home \ -position" or "go home" means the ARM RETRACTS upward/out of frame — it \ -does NOT mean moving any object back. Once an object is in the blue \ -plate, it stays in the plate unless a future subtask explicitly says to \ -move it somewhere else. Only the subtask in "CURRENT SUBTASK" below is \ -active; do not reverse earlier subtasks. - -WORLD PRESERVATION RULE (strict): anything the current subtask does NOT \ -explicitly act on must appear in the output image EXACTLY as it does in \ -IMAGE 2 — same position, same orientation, same color, same condition. \ -Do not delete, duplicate, relocate, or alter any object the subtask does \ -not touch. If an object (including the eggplant, the blue plate, or any \ -vegetable) is visible in IMAGE 2, it must be visible in the same place \ -in the output unless the subtask explicitly moves it. The world is \ -static except where the robot arm is actively manipulating something. +IMAGE 1 — IDENTITY REFERENCE. An early frame of this scene, used ONLY to \ +show you what each object in the scene looks like (color, shape, size, \ +texture). It is NOT a target state to restore to, and object positions \ +shown here may be out of date. Never use IMAGE 1 to determine where an \ +object currently is. + +IMAGE 2 — CURRENT OBSERVATION. The exact current state of the scene. Its \ +pixels are the ground truth for every object's current position and \ +for the camera, lighting, surfaces, and background. Your prediction must \ +preserve IMAGE 2 exactly except where the current subtask requires a \ +change. + +SCENE OBJECTS: the set of objects in the scene is exactly the set of \ +objects visible in IMAGE 1. Do not add, remove, or substitute any \ +object. Use IMAGE 2 for every object's current position. + +ROBOT: the scene contains one robot arm that enters from one side of \ +the frame and is always continuous to that edge — no floating grippers \ +or arms that appear disconnected from the frame boundary. If the arm's \ +anatomy is visible in IMAGE 2, match its visual style exactly; if the \ +arm is not in IMAGE 2, follow the same visual style used in other \ +frames of this episode. The gripper grasps oblong objects by their \ +stem, so a held oblong object hangs BELOW the closed fingers as a \ +visible silhouette. CURRENT SUBTASK: {subtask} -TASK: Predict the scene at roughly the half-subtask-ahead point of this \ -subtask (the paper's "t + 0.5 times subtask duration" target). If the subtask \ -involves moving the eggplant, the predicted frame should show that motion \ -well underway or complete; if the eggplant ends up inside the blue plate, \ -render it as the SAME dark-purple eggplant shown in IMAGE 1 (do not morph \ -it to an apple, plum, or any other fruit). If the eggplant is occluded \ -inside the closed gripper, do NOT draw it on the table or invent a \ -substitute. The four non-target vegetables stay in their exact positions \ -from IMAGE 2 unless the subtask says otherwise. +SUBTASK SEMANTICS: a subtask describes one action the ROBOT ARM takes \ +next. It never undoes previous work, and it never moves any object \ +unless that object is explicitly named as being moved to a specific \ +location. A subtask of the form "move X to Y" names both an object (X) \ +and a destination (Y); any other subtask — for example "return to home", \ +"go home", "retract", "finish", "idle" — names neither, so nothing \ +moves except the arm. An object's location at the moment the current \ +subtask starts is the location it keeps, unless the current subtask \ +explicitly relocates it. + +OUTPUT RULES (strict pixel preservation): treat IMAGE 2 as the baseline. \ +Only two kinds of change are allowed between IMAGE 2 and the output: \ +(1) the robot arm's pose may update to reflect progress through the \ +current subtask; (2) any object the current subtask explicitly names as \ +being moved may change position accordingly. Everything else — every \ +other object, the background, the surface, the lighting, the camera — \ +must match IMAGE 2 exactly. An object contained inside another object \ +in IMAGE 2 stays contained. An object resting on a surface in IMAGE 2 \ +stays on that surface. If the current subtask does not explicitly name \ +an object as being moved, that object does not move. + +IDENTITY ANCHORING: if an object is occluded or hard to see in IMAGE 2 \ +(e.g. hidden inside the closed gripper), use IMAGE 1 to recall its \ +identity so you do not hallucinate a different-looking object in its \ +place. Do not morph an object into a different type. + +PREDICTION HORIZON: predict the scene at roughly the half-subtask-ahead \ +point — partway through or at the end of the current subtask's action. """ diff --git a/experiments/subtask_probe/droid_eval/generate_subtasks.py b/experiments/subtask_probe/droid_eval/generate_subtasks.py index 5cbdedf..aedbedc 100644 --- a/experiments/subtask_probe/droid_eval/generate_subtasks.py +++ b/experiments/subtask_probe/droid_eval/generate_subtasks.py @@ -34,17 +34,14 @@ import httpx import numpy as np -from openpi_client import websocket_client_policy as _websocket_client_policy from hosting.admin_server import DEFAULT_ADMIN_PORT from hosting.flash_transport_policy import FlashTransportPolicy from .constants import DEFAULT_QUIC_PORT - -DEFAULT_PLANNER_WS_PORT = 8002 from .utils import build_subtask_observation, build_warmup_observation, load_manifest -logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", force=True) logger = logging.getLogger(__name__) @@ -115,22 +112,6 @@ def main() -> None: help="Server address (e.g., 43.200.36.250)", ) parser.add_argument("--port", type=int, default=DEFAULT_QUIC_PORT, help="Server QUIC port") - parser.add_argument( - "--transport", - choices=["quic", "ws"], - default="quic", - help=( - "Transport to use for subtask inference. QUIC's Arrow codec currently " - "cannot carry planner responses (no array columns), so use 'ws' when " - "hitting the planner endpoint." - ), - ) - parser.add_argument( - "--ws_port", - type=int, - default=DEFAULT_PLANNER_WS_PORT, - help="WebSocket port for the planner endpoint (used when --transport=ws).", - ) parser.add_argument( "--admin_host", type=str, @@ -175,19 +156,9 @@ def main() -> None: active_prompt_format = _get_server_prompt_format(admin_host, args.admin_port) logger.info("Using server's current subtask prompt format: %r", active_prompt_format) - # Connect to server. Prefer QUIC for latency, but fall back to WS when - # hitting the planner endpoint because the Arrow codec requires at least - # one array column and planner responses contain only scalar metadata. - if args.transport == "ws": - policy = _websocket_client_policy.WebsocketClientPolicy(host=args.server, port=args.ws_port) - logger.info( - "Connected to server at %s:%d via WebSocket (planner endpoint)", - args.server, - args.ws_port, - ) - else: - policy = FlashTransportPolicy(args.server, port=args.port) - logger.info("Connected to server at %s:%d via QUIC", args.server, args.port) + # Connect to server via QUIC + policy = FlashTransportPolicy(args.server, port=args.port) + logger.info("Connected to server at %s:%d via QUIC", args.server, args.port) # Warm up connection policy.infer(build_warmup_observation(mode="subtask_only")) From 14124d4742b558b9a5ce61bcf9d450ddb9ad404d Mon Sep 17 00:00:00 2001 From: Kingston Date: Tue, 21 Apr 2026 18:49:12 +0800 Subject: [PATCH 05/17] feat: implement ForeAct planner for v2 eggplant chain and enhance visualization with optional subtasks JSON --- .../foreact_eval/generate_subtasks_v2chain.py | 129 ++++++++++++++++++ .../droid_eval/foreact_eval/planner.py | 124 +++++++++++++---- .../foreact_eval/visualize_nano_banana.py | 60 ++++++-- 3 files changed, 273 insertions(+), 40 deletions(-) create mode 100644 experiments/subtask_probe/droid_eval/foreact_eval/generate_subtasks_v2chain.py diff --git a/experiments/subtask_probe/droid_eval/foreact_eval/generate_subtasks_v2chain.py b/experiments/subtask_probe/droid_eval/foreact_eval/generate_subtasks_v2chain.py new file mode 100644 index 0000000..40aadc9 --- /dev/null +++ b/experiments/subtask_probe/droid_eval/foreact_eval/generate_subtasks_v2chain.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Run the ForeAct planner (Qwen3-VL) over the v2 eggplant chain. + +Walks the v2 chain's ``episode_*/actual/frame_*.png`` frames in temporal +order, keeps the planner stateful across frames (no reset between linked +episodes since they form one continuous task), and writes per-frame subtask +predictions to JSON. + +This is the "use the paper's VLM correctly" companion to +``generate_foresight_nano_banana.py`` — we've been hardcoding subtask +labels in ``CHAIN_PHASES`` for both foresight generators; this script +replaces that with what a real Qwen3-VL-8B planner would say frame by +frame. +""" + +from __future__ import annotations + +import argparse +import json +import logging +from pathlib import Path +from typing import Any + +import numpy as np +from PIL import Image + +from experiments.subtask_probe.droid_eval.foreact_eval._io import iter_source_frames +from experiments.subtask_probe.droid_eval.foreact_eval.planner import OpenAICompatPlanner + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + + +DEFAULT_TASK = "Pick up the eggplant and place it into the blue plate." +DEFAULT_MODEL = "Qwen/Qwen3-VL-8B-Instruct" +DEFAULT_BASE_URL = "http://localhost:8000/v1" + + +def _load_rgb(path: Path) -> np.ndarray: + with Image.open(path) as img: + return np.asarray(img.convert("RGB"), dtype=np.uint8) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run Qwen3-VL planner over the v2 chain") + parser.add_argument( + "--v2_root", + type=Path, + default=Path(".experiments_cache/foreact_eval/foresight_chain_eggplant_v2"), + ) + parser.add_argument( + "--output", + type=Path, + default=Path(".experiments_cache/foreact_eval/subtasks_qwen3_v2chain.json"), + ) + parser.add_argument("--task", type=str, default=DEFAULT_TASK) + parser.add_argument("--model", type=str, default=DEFAULT_MODEL) + parser.add_argument("--base_url", type=str, default=DEFAULT_BASE_URL) + parser.add_argument( + "--no_schema", + action="store_true", + help="Disable JSON schema enforcement on the VLM response (closer to Table 5).", + ) + return parser.parse_args() + + +def main() -> None: + args = _parse_args() + frames = iter_source_frames(args.v2_root) + logger.info( + "Running Qwen3-VL planner over %d frames (task=%r, base_url=%s)", + len(frames), + args.task, + args.base_url, + ) + + planner = OpenAICompatPlanner( + base_url=args.base_url, model=args.model, use_schema=not args.no_schema + ) + logger.info("use_schema=%s", not args.no_schema) + + records: list[dict[str, Any]] = [] + for i, frame in enumerate(frames): + image = _load_rgb(frame.actual_path) + try: + result = planner.generate_subtask(args.task, image) + except Exception as exc: + logger.warning("ep%d f%05d failed: %s", frame.episode_index, frame.frame_idx, exc) + result = { + "subtask": "", + "previous_finished": False, + "prompt_phase": "error", + "error": str(exc), + } + records.append( + { + "episode_index": frame.episode_index, + "frame_idx": frame.frame_idx, + **result, + } + ) + logger.info( + "[%d/%d] ep%d f%05d (%s): previous_finished=%s subtask=%r", + i + 1, + len(frames), + frame.episode_index, + frame.frame_idx, + result.get("prompt_phase", "?"), + result.get("previous_finished"), + result.get("subtask"), + ) + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps( + { + "task": args.task, + "model": args.model, + "base_url": args.base_url, + "results": records, + }, + indent=2, + ) + ) + logger.info("Wrote %s", args.output) + + +if __name__ == "__main__": + main() diff --git a/experiments/subtask_probe/droid_eval/foreact_eval/planner.py b/experiments/subtask_probe/droid_eval/foreact_eval/planner.py index 8b4c5a3..e83551b 100644 --- a/experiments/subtask_probe/droid_eval/foreact_eval/planner.py +++ b/experiments/subtask_probe/droid_eval/foreact_eval/planner.py @@ -82,10 +82,20 @@ class BasePlanner(ABC): - """Stateful per-episode ForeAct planner; subclasses implement the VLM call.""" - - def __init__(self) -> None: + """Stateful per-episode ForeAct planner; subclasses implement the VLM call. + + ``use_schema`` controls whether we force ``{"subtask": str, + "previous_finished": bool}`` JSON output via the backend's response-format + hook. The paper (Table 5, §3.3) doesn't mention any schema — it only asks + for "concise and deterministic" free-form text. Our schema is an addition + that helped the Comet-style reasoner but may be hurting step-level + decomposition here (see FINDINGS). Set ``False`` to reproduce the paper + more literally. + """ + + def __init__(self, use_schema: bool = True) -> None: self.previous_subtask: str | None = None + self._use_schema = use_schema def reset(self) -> None: self.previous_subtask = None @@ -114,9 +124,13 @@ def generate_subtask(self, task: str, current_image: np.ndarray) -> dict[str, An prompt=prompt, image=current_image, image_position=image_position, - response_schema=SUBTASK_SCHEMA, + response_schema=SUBTASK_SCHEMA if self._use_schema else None, + ) + parsed = ( + _parse_response_json(raw) + if self._use_schema + else _parse_response_freeform(raw, self.previous_subtask) ) - parsed = _parse_response(raw) if parsed["subtask"]: self.previous_subtask = parsed["subtask"] return {**parsed, "prompt_phase": prompt_phase} @@ -127,12 +141,13 @@ def _chat( prompt: str, image: np.ndarray, image_position: ImagePosition, - response_schema: dict[str, Any], + response_schema: dict[str, Any] | None, ) -> str: - """Return the raw JSON string emitted by the VLM.""" + """Return the raw VLM output — JSON-shape when ``response_schema`` is set, + free-form text when it is ``None``.""" -def _parse_response(raw: str) -> dict[str, Any]: +def _parse_response_json(raw: str) -> dict[str, Any]: try: payload = json.loads(raw) except json.JSONDecodeError: @@ -143,6 +158,26 @@ def _parse_response(raw: str) -> dict[str, Any]: return {"subtask": subtask, "previous_finished": previous_finished} +def _parse_response_freeform(raw: str, previous_subtask: str | None) -> dict[str, Any]: + """Parse a free-form VLM response. + + The paper's prompt asks for a concise instruction with no other text, so + the common case is a single short sentence. We strip surrounding quotes / + whitespace and, as a safety net, take the last non-empty line if the model + was chatty. ``previous_finished`` is inferred from whether the new subtask + string differs from ``previous_subtask`` — this matches the paper's + semantics where "if finished, give the next step; if not, repeat the + current one". + """ + cleaned = raw.strip().strip("\"'`") + lines = [line.strip().strip("\"'`") for line in cleaned.splitlines() if line.strip()] + subtask = lines[-1] if lines else "" + previous_finished = bool( + previous_subtask is not None and subtask and subtask != previous_subtask + ) + return {"subtask": subtask, "previous_finished": previous_finished} + + # --------------------------------------------------------------------------- # Backend A — OpenAI-compatible (targets local vLLM hosting Qwen3-VL-8B-Instruct, # which is the exact model the paper uses for both VLM+\u03c0_0 and ForeAct \u00a74.3) @@ -161,41 +196,71 @@ def __init__( api_key: str = "none", temperature: float = 1.0, timeout_s: float = 600.0, + use_schema: bool = True, ) -> None: - super().__init__() + super().__init__(use_schema=use_schema) self._client = OpenAI(base_url=base_url, api_key=api_key, timeout=timeout_s) self._model = model self._temperature = temperature + # Conversation history for the reason-execute-monitor cycle. Each + # turn appends (user_message_text_only, assistant_response). We strip + # image parts from the stored user message — keeping 57 base64- + # encoded images in context would blow past Qwen3-VL's 32k window + # within ~20 turns. The model's plan persistence relies on its own + # earlier assistant text, not on re-observing previous frames. + self._conversation: list[ChatCompletionMessageParam] = [] + + def reset(self) -> None: + super().reset() + self._conversation = [] def _chat( self, prompt: str, image: np.ndarray, image_position: ImagePosition, - response_schema: dict[str, Any], + response_schema: dict[str, Any] | None, ) -> str: png_bytes = encode_png(image) data_url = f"data:image/png;base64,{base64.b64encode(png_bytes).decode('utf-8')}" image_part: dict[str, Any] = {"type": "image_url", "image_url": {"url": data_url}} text_part: dict[str, Any] = {"type": "text", "text": prompt} content = [text_part, image_part] if image_position == "end" else [image_part, text_part] - # The OpenAI SDK's TypedDict is stricter than the general-purpose dict - # we build; cast once here rather than maintain parallel literals. - messages = cast(list[ChatCompletionMessageParam], [{"role": "user", "content": content}]) - response = self._client.chat.completions.create( - model=self._model, - messages=messages, - temperature=self._temperature, - response_format={ + # Active call sees: full conversation history + the current turn + # (with the CURRENT image). Previous turns are text-only in history. + current_user_msg = cast( + ChatCompletionMessageParam, {"role": "user", "content": content} + ) + messages: list[ChatCompletionMessageParam] = [*self._conversation, current_user_msg] + kwargs: dict[str, Any] = { + "model": self._model, + "messages": messages, + "temperature": self._temperature, + } + if response_schema is not None: + kwargs["response_format"] = { "type": "json_schema", "json_schema": { "name": "foreact_subtask", "schema": response_schema, "strict": True, }, - }, + } + response = self._client.chat.completions.create(**kwargs) + assistant_text = (response.choices[0].message.content or "").strip() + + # Persist the turn to history. Strip the image part to keep context + # cheap; the assistant's plan lives in the text it emits. + user_text_only = cast( + ChatCompletionMessageParam, {"role": "user", "content": prompt} + ) + assistant_msg = cast( + ChatCompletionMessageParam, {"role": "assistant", "content": assistant_text} ) - return (response.choices[0].message.content or "").strip() + self._conversation.append(user_text_only) + self._conversation.append(assistant_msg) + + return assistant_text # --------------------------------------------------------------------------- @@ -214,8 +279,9 @@ def __init__( thinking_budget: int = 0, max_retries: int = 10, request_timeout_s: float = 120.0, + use_schema: bool = True, ) -> None: - super().__init__() + super().__init__(use_schema=use_schema) # google-genai is an optional dependency for this backend only — # import lazily so OpenAI-only users don't need it installed. from google import genai @@ -234,19 +300,21 @@ def _chat( prompt: str, image: np.ndarray, image_position: ImagePosition, - response_schema: dict[str, Any], + response_schema: dict[str, Any] | None, ) -> str: types = self._types image_part = types.Part.from_bytes(data=encode_png(image), mime_type="image/png") contents: list[Any] = ( [prompt, image_part] if image_position == "end" else [image_part, prompt] ) - config = types.GenerateContentConfig( - temperature=1.0, - thinking_config=types.ThinkingConfig(thinking_budget=self._thinking_budget), - response_mime_type="application/json", - response_schema=response_schema, - ) + config_kwargs: dict[str, Any] = { + "temperature": 1.0, + "thinking_config": types.ThinkingConfig(thinking_budget=self._thinking_budget), + } + if response_schema is not None: + config_kwargs["response_mime_type"] = "application/json" + config_kwargs["response_schema"] = response_schema + config = types.GenerateContentConfig(**config_kwargs) def _call() -> str: response = self._client.models.generate_content( diff --git a/experiments/subtask_probe/droid_eval/foreact_eval/visualize_nano_banana.py b/experiments/subtask_probe/droid_eval/foreact_eval/visualize_nano_banana.py index 94a0cc7..d437aaa 100644 --- a/experiments/subtask_probe/droid_eval/foreact_eval/visualize_nano_banana.py +++ b/experiments/subtask_probe/droid_eval/foreact_eval/visualize_nano_banana.py @@ -15,6 +15,7 @@ from __future__ import annotations import argparse +import json import logging from pathlib import Path @@ -79,25 +80,60 @@ def _parse_args() -> argparse.Namespace: ), ) parser.add_argument("--fps", type=int, default=2) + parser.add_argument( + "--subtasks_json", + type=Path, + default=None, + help=( + "Optional per-frame subtasks JSON (shape: {'results': [{episode_index, " + "frame_idx, subtask}, ...]}). When provided, overrides CHAIN_PHASES: " + "captions come from this file and every frame with an entry is rendered " + "(no phase-based trimming)." + ), + ) return parser.parse_args() +def _load_subtasks_by_frame(path: Path) -> dict[tuple[int, int], str]: + payload = json.loads(path.read_text()) + records = payload.get("results") if isinstance(payload, dict) else payload + by_frame: dict[tuple[int, int], str] = {} + for record in records or []: + subtask = (record.get("subtask") or "").strip() + if not subtask: + continue + by_frame[(record["episode_index"], record["frame_idx"])] = subtask + return by_frame + + def main() -> None: args = _parse_args() all_frames = iter_source_frames(args.v2_root) - phased = [(f, lookup_phase(f.episode_index, f.frame_idx)) for f in all_frames] - in_phase = [(f, p) for (f, p) in phased if p is not None] - if not in_phase: - raise SystemExit(f"No in-phase frames found in {args.v2_root}") - logger.info( - "Rendering %d in-phase frames (trimmed %d) @ %d fps", - len(in_phase), - len(all_frames) - len(in_phase), - args.fps, - ) + + if args.subtasks_json is not None: + per_frame_subtasks = _load_subtasks_by_frame(args.subtasks_json) + captioned = [ + (f, per_frame_subtasks[(f.episode_index, f.frame_idx)]) + for f in all_frames + if (f.episode_index, f.frame_idx) in per_frame_subtasks + ] + logger.info( + "Rendering %d frames from %s (@ %d fps)", len(captioned), args.subtasks_json, args.fps + ) + else: + phased = [(f, lookup_phase(f.episode_index, f.frame_idx)) for f in all_frames] + captioned = [(f, p.subtask_label) for (f, p) in phased if p is not None] + logger.info( + "Rendering %d in-phase frames (trimmed %d) @ %d fps", + len(captioned), + len(all_frames) - len(captioned), + args.fps, + ) + if not captioned: + raise SystemExit(f"No frames to render from {args.v2_root}") composites: list[np.ndarray] = [] - for frame, phase in in_phase: + for frame, subtask in captioned: actual = _load_resized(frame.actual_path, w=FRAME_W, h=FRAME_H) foresight_file = foresight_path(args.foresight_dir, frame.episode_index, frame.frame_idx) foresight = ( @@ -112,7 +148,7 @@ def main() -> None: composites.append( _draw_caption( composite, - caption=phase.subtask_label, + caption=subtask, footer=footer, camera_labels=[ (4, "ACTUAL"), From 898f8f7c225acff5e1b57050be40f6a3f13f18f5 Mon Sep 17 00:00:00 2001 From: Kingston Date: Fri, 24 Apr 2026 20:35:24 +0800 Subject: [PATCH 06/17] add transport bench --- experiments/transport_bench/benchmark.py | 382 ++++++++++++++++++++++ experiments/transport_bench/run_matrix.sh | 134 ++++++++ 2 files changed, 516 insertions(+) create mode 100644 experiments/transport_bench/benchmark.py create mode 100755 experiments/transport_bench/run_matrix.sh diff --git a/experiments/transport_bench/benchmark.py b/experiments/transport_bench/benchmark.py new file mode 100644 index 0000000..e6a5237 --- /dev/null +++ b/experiments/transport_bench/benchmark.py @@ -0,0 +1,382 @@ +"""Same-host transport benchmark for flash-transport vs. openpi WebSocket. + +Runs serial inference calls at a target pacing against a running server and +records per-call latency. Designed to be invoked from inside a Docker +container that already has the openpi-flash wheel + its dependencies, so the +client dependency on ``openpi_client`` and ``hosting.flash_transport_policy`` +is satisfied without a separate install step. + +See ``run_matrix.sh`` for the wrapper that runs the full profile matrix with +bidirectional netem shaping (ifb-mirrored ingress). For a one-shot invocation +the shape is: + + python benchmark.py --transport quic --host 127.0.0.1 --port 5555 \\ + --target-rate-hz 20 --min-samples 200 --min-duration-s 30 \\ + --max-duration-s 600 --output /tmp/quic-clean.json + +The server is assumed to be running locally (action slot on 8000/TCP + 5555/UDP). +""" + +from __future__ import annotations + +import argparse +import json +import statistics +import sys +import time +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any, Literal, Protocol + +import numpy as np + +# Reach into the openpi-flash install tree. These import paths assume we are +# running inside the openpi-flash container image (or a local dev checkout +# with ``uv sync``). +from hosting.warmup import make_droid_observation # noqa: E402 +from openpi_client import websocket_client_policy as _websocket_client_policy # noqa: E402 + +Transport = Literal["ws", "quic"] + + +class InferablePolicy(Protocol): + def infer(self, obs: dict[str, Any]) -> dict[str, Any]: ... + def get_server_metadata(self) -> dict[str, Any]: ... + + +@dataclass +class CallSample: + """Per-call timing row.""" + + iteration_index: int + send_unix_s: float + client_round_trip_ms: float + server_infer_ms: float + policy_forward_ms: float + failed: bool = False + error_message: str | None = None + + @property + def network_overhead_ms(self) -> float: + if self.failed: + return 0.0 + return max(0.0, self.client_round_trip_ms - self.server_infer_ms) + + +@dataclass +class RunSummary: + transport: Transport + host: str + port: int + target_rate_hz: float + min_samples: int + min_duration_s: float + max_duration_s: float + warmup_iterations: int + total_iterations: int + successful_iterations: int + failure_count: int + wall_clock_duration_s: float + stop_reason: str + effective_rate_hz: float + client_round_trip_ms_p50: float + client_round_trip_ms_p95: float + client_round_trip_ms_p99: float + client_round_trip_ms_mean: float + client_round_trip_ms_stddev: float + client_round_trip_ms_max: float + server_infer_ms_mean: float + network_overhead_ms_mean: float + network_overhead_ms_p95: float + network_overhead_ms_p99: float + samples: list[dict[str, Any]] = field(default_factory=list) + + +def _percentile(values: list[float], percentile: float) -> float: + if not values: + return 0.0 + if len(values) == 1: + return values[0] + sorted_values = sorted(values) + rank = (percentile / 100.0) * (len(sorted_values) - 1) + lower_index = int(rank) + upper_index = min(lower_index + 1, len(sorted_values) - 1) + fractional = rank - lower_index + return sorted_values[lower_index] + fractional * ( + sorted_values[upper_index] - sorted_values[lower_index] + ) + + +def build_policy(transport: Transport, host: str, port: int) -> InferablePolicy: + if transport == "ws": + return _websocket_client_policy.WebsocketClientPolicy(host=host, port=port) + if transport == "quic": + from hosting.flash_transport_policy import FlashTransportPolicy + + return FlashTransportPolicy(host=host, port=port) + raise ValueError(f"Unknown transport: {transport}") + + +def run_benchmark( + *, + transport: Transport, + host: str, + port: int, + target_rate_hz: float, + min_samples: int, + min_duration_s: float, + max_duration_s: float, + warmup_iterations: int, + prompt: str, + seed: int, +) -> RunSummary: + rng = np.random.default_rng(seed) + + def make_observation() -> dict[str, Any]: + # Fresh random frames per call so server-side preprocessing can't + # short-circuit. + observation = make_droid_observation(prompt=prompt) + observation["observation/joint_position"] = rng.random(7) + return observation + + print(f"Building {transport} policy → {host}:{port}", flush=True) + policy = build_policy(transport, host, port) + try: + metadata = policy.get_server_metadata() + print(f"Server metadata: {metadata}", flush=True) + + print(f"Warmup: {warmup_iterations} iteration(s) ...", flush=True) + warmup_start = time.monotonic() + for _ in range(warmup_iterations): + policy.infer(make_observation()) + print( + f"Warmup done in {1000 * (time.monotonic() - warmup_start):.0f}ms", + flush=True, + ) + + inter_call_interval_s = 1.0 / target_rate_hz if target_rate_hz > 0 else 0.0 + wall_clock_start = time.monotonic() + min_deadline = wall_clock_start + min_duration_s + max_deadline = wall_clock_start + max_duration_s + + samples: list[CallSample] = [] + iteration_index = 0 + next_send_monotonic = wall_clock_start + stop_reason = "max_duration_s_reached" + + while True: + now_for_stop_check = time.monotonic() + successful_so_far = sum(1 for sample in samples if not sample.failed) + # Stop when we have enough samples AND the minimum runtime has + # elapsed (so target-rate pacing gets enough wall-clock time to + # stabilize). Hard cap on max_duration_s regardless. + if now_for_stop_check >= max_deadline: + stop_reason = "max_duration_s_reached" + break + if successful_so_far >= min_samples and now_for_stop_check >= min_deadline: + stop_reason = "min_samples_reached" + break + # Sleep until next scheduled send (serial, paced). + now_monotonic = time.monotonic() + if now_monotonic < next_send_monotonic: + time.sleep(next_send_monotonic - now_monotonic) + + send_unix = time.time() + call_start_monotonic = time.monotonic() + try: + action = policy.infer(make_observation()) + client_round_trip_ms = 1000 * (time.monotonic() - call_start_monotonic) + server_infer_ms = float( + action.get("server_timing", {}).get("infer_ms", 0.0) + ) + policy_forward_ms = float( + action.get("policy_timing", {}).get("infer_ms", 0.0) + ) + samples.append( + CallSample( + iteration_index=iteration_index, + send_unix_s=send_unix, + client_round_trip_ms=client_round_trip_ms, + server_infer_ms=server_infer_ms, + policy_forward_ms=policy_forward_ms, + ) + ) + except Exception as error: # noqa: BLE001 + client_round_trip_ms = 1000 * (time.monotonic() - call_start_monotonic) + samples.append( + CallSample( + iteration_index=iteration_index, + send_unix_s=send_unix, + client_round_trip_ms=client_round_trip_ms, + server_infer_ms=0.0, + policy_forward_ms=0.0, + failed=True, + error_message=f"{type(error).__name__}: {error}", + ) + ) + + iteration_index += 1 + next_send_monotonic += inter_call_interval_s + # If the call itself ran over the interval, skip ahead so we + # don't queue up a burst of back-to-back sends. + if time.monotonic() > next_send_monotonic: + next_send_monotonic = time.monotonic() + + wall_clock_duration_s = time.monotonic() - wall_clock_start + finally: + close_fn = getattr(policy, "close", None) + if callable(close_fn): + try: + close_fn() + except Exception: # noqa: BLE001 + pass + + successful_samples = [s for s in samples if not s.failed] + failure_count = len(samples) - len(successful_samples) + client_round_trip_values = [s.client_round_trip_ms for s in successful_samples] + server_infer_values = [s.server_infer_ms for s in successful_samples] + network_overhead_values = [s.network_overhead_ms for s in successful_samples] + + summary = RunSummary( + transport=transport, + host=host, + port=port, + target_rate_hz=target_rate_hz, + min_samples=min_samples, + min_duration_s=min_duration_s, + max_duration_s=max_duration_s, + warmup_iterations=warmup_iterations, + total_iterations=len(samples), + successful_iterations=len(successful_samples), + failure_count=failure_count, + wall_clock_duration_s=wall_clock_duration_s, + stop_reason=stop_reason, + effective_rate_hz=( + len(successful_samples) / wall_clock_duration_s if wall_clock_duration_s else 0.0 + ), + client_round_trip_ms_p50=_percentile(client_round_trip_values, 50), + client_round_trip_ms_p95=_percentile(client_round_trip_values, 95), + client_round_trip_ms_p99=_percentile(client_round_trip_values, 99), + client_round_trip_ms_mean=( + statistics.fmean(client_round_trip_values) if client_round_trip_values else 0.0 + ), + client_round_trip_ms_stddev=( + statistics.pstdev(client_round_trip_values) + if len(client_round_trip_values) > 1 + else 0.0 + ), + client_round_trip_ms_max=max(client_round_trip_values) if client_round_trip_values else 0.0, + server_infer_ms_mean=( + statistics.fmean(server_infer_values) if server_infer_values else 0.0 + ), + network_overhead_ms_mean=( + statistics.fmean(network_overhead_values) if network_overhead_values else 0.0 + ), + network_overhead_ms_p95=_percentile(network_overhead_values, 95), + network_overhead_ms_p99=_percentile(network_overhead_values, 99), + samples=[asdict(sample) for sample in samples], + ) + + return summary + + +def print_summary_table(summary: RunSummary) -> None: + print("\n--- Benchmark summary ---") + print( + f"transport={summary.transport} host={summary.host}:{summary.port} " + f"target_rate={summary.target_rate_hz:.1f}Hz " + f"min_samples={summary.min_samples} " + f"min_duration={summary.min_duration_s:.0f}s " + f"max_duration={summary.max_duration_s:.0f}s " + f"stop={summary.stop_reason} " + f"wall_clock={summary.wall_clock_duration_s:.1f}s" + ) + print( + f"iterations: total={summary.total_iterations} " + f"ok={summary.successful_iterations} fail={summary.failure_count} " + f"effective_rate={summary.effective_rate_hz:.2f}Hz" + ) + print("Client round-trip ms:") + print(f" mean={summary.client_round_trip_ms_mean:.1f} stddev={summary.client_round_trip_ms_stddev:.1f}") + print( + f" p50={summary.client_round_trip_ms_p50:.1f} " + f"p95={summary.client_round_trip_ms_p95:.1f} " + f"p99={summary.client_round_trip_ms_p99:.1f} " + f"max={summary.client_round_trip_ms_max:.1f}" + ) + print(f"Server infer ms mean: {summary.server_infer_ms_mean:.1f}") + print( + f"Network overhead ms: mean={summary.network_overhead_ms_mean:.1f} " + f"p95={summary.network_overhead_ms_p95:.1f} " + f"p99={summary.network_overhead_ms_p99:.1f}" + ) + + +def parse_cli_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Transport benchmark harness") + parser.add_argument("--transport", choices=["ws", "quic"], required=True) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, required=True) + parser.add_argument( + "--min-samples", + type=int, + default=200, + help="Stop once this many successful calls have been collected (and --min-duration-s has elapsed).", + ) + parser.add_argument( + "--min-duration-s", + type=float, + default=30.0, + help="Minimum wall-clock run time before early termination is allowed.", + ) + parser.add_argument( + "--max-duration-s", + type=float, + default=600.0, + help="Hard wall-clock cap. Run terminates at this deadline even if min_samples is not reached.", + ) + parser.add_argument("--target-rate-hz", type=float, default=20.0) + parser.add_argument("--warmup-iterations", type=int, default=3) + parser.add_argument("--prompt", default="pick up the red cup") + parser.add_argument("--seed", type=int, default=0) + parser.add_argument( + "--output", + type=Path, + default=None, + help="If set, write raw samples + summary JSON to this path.", + ) + parser.add_argument( + "--tag", + default=None, + help="Optional free-form label written to the output JSON (e.g. 'quic-50ms-1pct').", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_cli_args(argv) + summary = run_benchmark( + transport=args.transport, + host=args.host, + port=args.port, + target_rate_hz=args.target_rate_hz, + min_samples=args.min_samples, + min_duration_s=args.min_duration_s, + max_duration_s=args.max_duration_s, + warmup_iterations=args.warmup_iterations, + prompt=args.prompt, + seed=args.seed, + ) + print_summary_table(summary) + if args.output is not None: + payload = asdict(summary) + if args.tag is not None: + payload["tag"] = args.tag + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(payload, indent=2)) + print(f"\nWrote {args.output}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/experiments/transport_bench/run_matrix.sh b/experiments/transport_bench/run_matrix.sh new file mode 100755 index 0000000..f4dc04c --- /dev/null +++ b/experiments/transport_bench/run_matrix.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# Run the transport benchmark across a matrix of netem profiles × transports, +# with **bidirectional** shaping (egress + ifb-redirected ingress) and a +# **sample-target** stop condition so heavy-loss cells still gather enough +# samples for stable tail percentiles. +# +# Requires: +# - openpi-flash action slot reachable on the docker host, with ports +# published to 0.0.0.0 ({8000,5555}) — the client container reaches the +# server via the docker bridge gateway ("host.docker.internal" on Linux +# with --add-host=host-gateway). +# - benchmark.py mounted into the client container +# - iproute2 available in the client container (installed on demand below) +# +# IMPORTANT: the client container uses bridge networking (NOT host networking) +# so that `tc qdisc add dev eth0 ...` inside the container shapes only the +# container's veth, not the host's real network interface. Using host +# networking here would disrupt SSH. +# +# Why bidi shaping: shaping only the container's egress leaves the return +# path (server→client) unshaped, which is asymmetric and biases the +# comparison for response-heavy workloads. We fold container ingress into an +# IFB device and apply the same netem profile there so both directions see +# the same delay/loss. +# +# Why no jitter: the first-pass matrix (2026-04-21) used `delay 25ms 2ms +# distribution normal`, which applies per-packet jitter that can reorder +# packets on the wire. QUIC's reorder detection triggered spurious +# retransmits on ~300-packet DROID observation messages, producing a +# misleading 940ms p50 at only +25ms delay while TCP absorbed it cleanly. +# Zero-jitter profiles isolate the transport comparison from this artifact. +# A "jitter study" profile set can be added separately if we want to +# characterize reorder sensitivity explicitly. +# +# Usage (on the EC2 host): +# export IMAGE=438136598620.dkr.ecr.us-west-2.amazonaws.com/openpi-flash:latest +# bash run_matrix.sh /tmp/bench/results +set -euo pipefail + +OUTPUT_DIR="${1:-/tmp/bench/results}" +IMAGE="${IMAGE:?IMAGE env var must be set to the client container image}" +# Reach the server on the docker bridge gateway. host-gateway resolves to the +# host's IP on the docker bridge (typically 172.17.0.1). +HOST="${HOST:-host.docker.internal}" +TARGET_RATE_HZ="${TARGET_RATE_HZ:-20}" +WARMUP_ITERATIONS="${WARMUP_ITERATIONS:-3}" +# Sample-target termination — run each cell until MIN_SAMPLES successful +# calls have been collected AND MIN_DURATION_S has elapsed, capped at +# MAX_DURATION_S. See benchmark.py for details. +MIN_SAMPLES="${MIN_SAMPLES:-200}" +MIN_DURATION_S="${MIN_DURATION_S:-30}" +MAX_DURATION_S="${MAX_DURATION_S:-600}" +WS_PORT="${WS_PORT:-8000}" +QUIC_PORT="${QUIC_PORT:-5555}" + +mkdir -p "$OUTPUT_DIR" + +# Each entry is: profile_name|tc-netem-args (empty args => no shaping). +# Args are passed to `netem` on BOTH the container's egress (eth0) and the +# ifb device carrying its mirrored ingress, so both request and response +# experience the profile. Zero jitter — see header comment. +PROFILES=( + "clean|" + "delay25ms|delay 25ms" + "delay75ms_loss0_1pct|delay 75ms loss 0.1%" + "delay150ms_loss0_5pct|delay 150ms loss 0.5%" +) + +TRANSPORTS=( + "ws ${WS_PORT}" + "quic ${QUIC_PORT}" +) + +run_one() { + local profile_name="$1" + local tc_args="$2" + local transport="$3" + local port="$4" + local output_path="${OUTPUT_DIR}/${transport}_${profile_name}.json" + + echo "=== ${transport} @ ${profile_name} → ${output_path} ===" + + # Build the tc setup inside-container. For non-clean profiles we: + # 1. Apply netem on eth0 root → shapes container egress (client → server) + # 2. Create ifb0 in the container's netns + # 3. Add an ingress qdisc on eth0 and mirror ingress packets to ifb0 + # 4. Apply the same netem on ifb0 root → shapes container ingress (server → client) + # Net result: both directions see the same delay/loss profile. + local tc_setup="" + if [[ -n "${tc_args}" ]]; then + tc_setup="tc qdisc add dev eth0 root netem ${tc_args}; + ip link add ifb0 type ifb; + ip link set ifb0 up; + tc qdisc add dev eth0 handle ffff: ingress; + tc filter add dev eth0 parent ffff: protocol all u32 match u32 0 0 action mirred egress redirect dev ifb0; + tc qdisc add dev ifb0 root netem ${tc_args}; + echo [tc] eth0:; tc qdisc show dev eth0; + echo [tc] ifb0:; tc qdisc show dev ifb0;" + fi + + docker run --rm --cap-add=NET_ADMIN \ + --add-host=host.docker.internal:host-gateway \ + -v "$(pwd)/benchmark.py:/tmp/benchmark.py:ro" \ + -v "${OUTPUT_DIR}:/out" \ + "${IMAGE}" \ + bash -lc "set -euo pipefail; + if ! command -v tc >/dev/null 2>&1; then + apt-get update -qq && apt-get install -y --no-install-recommends iproute2 >/dev/null + fi; + ${tc_setup} + python /tmp/benchmark.py \ + --transport ${transport} \ + --host ${HOST} \ + --port ${port} \ + --target-rate-hz ${TARGET_RATE_HZ} \ + --min-samples ${MIN_SAMPLES} \ + --min-duration-s ${MIN_DURATION_S} \ + --max-duration-s ${MAX_DURATION_S} \ + --warmup-iterations ${WARMUP_ITERATIONS} \ + --tag ${transport}_${profile_name} \ + --output /out/${transport}_${profile_name}.json" +} + +for profile_entry in "${PROFILES[@]}"; do + profile_name="${profile_entry%%|*}" + tc_args="${profile_entry#*|}" + for transport_entry in "${TRANSPORTS[@]}"; do + read -r transport port <<<"${transport_entry}" + run_one "${profile_name}" "${tc_args}" "${transport}" "${port}" + done +done + +echo "All runs complete. Results in ${OUTPUT_DIR}:" +ls -l "${OUTPUT_DIR}" From 8d95042995dd93b5b89ee3c75e2f6b274ef2c5f7 Mon Sep 17 00:00:00 2001 From: Kingston Date: Sat, 25 Apr 2026 09:56:16 +0800 Subject: [PATCH 07/17] add sim --- experiments/bimanual_sim/.gitignore | 9 + experiments/bimanual_sim/README.md | 191 ++ experiments/bimanual_sim/arm_handles.py | 99 + experiments/bimanual_sim/cameras.py | 188 ++ experiments/bimanual_sim/ik.py | 209 +++ experiments/bimanual_sim/paths.py | 78 + experiments/bimanual_sim/pyproject.toml | 50 + experiments/bimanual_sim/robots/__init__.py | 4 + experiments/bimanual_sim/robots/piper.py | 144 ++ experiments/bimanual_sim/robots/tiago.py | 166 ++ experiments/bimanual_sim/runner.py | 482 +++++ experiments/bimanual_sim/scene_base.py | 140 ++ experiments/bimanual_sim/scene_check.py | 534 ++++++ experiments/bimanual_sim/scenes/__init__.py | 0 .../bimanual_sim/scenes/data_center.py | 1557 ++++++++++++++++ .../bimanual_sim/scenes/data_center_layout.py | 371 ++++ experiments/bimanual_sim/serve.sh | 57 + experiments/bimanual_sim/tools/__init__.py | 0 experiments/bimanual_sim/tools/_runtime.py | 343 ++++ experiments/bimanual_sim/tools/mj.py | 710 +++++++ experiments/bimanual_sim/uv.lock | 1654 +++++++++++++++++ experiments/bimanual_sim/viser_render.py | 213 +++ experiments/bimanual_sim/welds.py | 123 ++ 23 files changed, 7322 insertions(+) create mode 100644 experiments/bimanual_sim/.gitignore create mode 100644 experiments/bimanual_sim/README.md create mode 100644 experiments/bimanual_sim/arm_handles.py create mode 100644 experiments/bimanual_sim/cameras.py create mode 100644 experiments/bimanual_sim/ik.py create mode 100644 experiments/bimanual_sim/paths.py create mode 100644 experiments/bimanual_sim/pyproject.toml create mode 100644 experiments/bimanual_sim/robots/__init__.py create mode 100644 experiments/bimanual_sim/robots/piper.py create mode 100644 experiments/bimanual_sim/robots/tiago.py create mode 100644 experiments/bimanual_sim/runner.py create mode 100644 experiments/bimanual_sim/scene_base.py create mode 100644 experiments/bimanual_sim/scene_check.py create mode 100644 experiments/bimanual_sim/scenes/__init__.py create mode 100644 experiments/bimanual_sim/scenes/data_center.py create mode 100644 experiments/bimanual_sim/scenes/data_center_layout.py create mode 100755 experiments/bimanual_sim/serve.sh create mode 100644 experiments/bimanual_sim/tools/__init__.py create mode 100644 experiments/bimanual_sim/tools/_runtime.py create mode 100644 experiments/bimanual_sim/tools/mj.py create mode 100644 experiments/bimanual_sim/uv.lock create mode 100644 experiments/bimanual_sim/viser_render.py create mode 100644 experiments/bimanual_sim/welds.py diff --git a/experiments/bimanual_sim/.gitignore b/experiments/bimanual_sim/.gitignore new file mode 100644 index 0000000..74d3c4a --- /dev/null +++ b/experiments/bimanual_sim/.gitignore @@ -0,0 +1,9 @@ +# uv / Python +.venv/ +__pycache__/ +*.pyc +.ruff_cache/ + +# Runtime artifacts +*.log +*.pid diff --git a/experiments/bimanual_sim/README.md b/experiments/bimanual_sim/README.md new file mode 100644 index 0000000..c23cd4c --- /dev/null +++ b/experiments/bimanual_sim/README.md @@ -0,0 +1,191 @@ +# bimanual_sim + +MuJoCo + Viser sim experiment. One scene today: + +- `data_center` — bimanual AgileX Pipers on a PAL TIAGo mobile base performing + a rack server swap: unplug 3 color-coded cables, pull the old server out, + stow in an onboard compartment, slot a replacement in from the upper + compartment, replug the cables. + +Scene modules live under `scenes/`. The runner in `runner.py` is scene-agnostic +so additional scenes slot in without touching the shared infra. + +## Layout + +``` +runner.py generic main loop + Viser GUI (CLI: --scene NAME) +scene_base.py Step dataclass + shape aliases + CubeID helper +scene_check.py compile-time sanity checks + schematic printer + (AttachmentConstraint, check_scene, print_schematic) +ik.py mink-backed differential IK +arm_handles.py Piper-specific joint/actuator/body lookups + ArmSide enum +cameras.py Viser camera-frustum widgets + CameraRole enum +viser_render.py MuJoCo geom → Viser mesh bridge +welds.py equality-weld grasp cheat + generic attachment welds +paths.py Menagerie path resolution + (PIPER_XML / TIAGO_XML / D435I_XML / D405_MESH_STL) +robots/ + tiago.py TIAGo loader: TiagoConfig + load_tiago() — strip + upstream single arm + head, drop base freejoint, + prune orphan excludes. Menagerie-shape assertions. + piper.py Piper loader: PiperConfig + attach_piper() — attach + with prefix, override per-joint kp/kv/forcerange. +scenes/ + data_center.py the scene (MJCF build, IK task plan, weld registry) + data_center_layout.py declarative geometry: every dimension/anchor as a + frozen dataclass with cross-component invariants +tools/ + mj.py unified debug CLI (typer): snapshot / video / grid / + plan / diff / ik — `uv run python tools/mj.py --help` + _runtime.py shared scene-build + timeline-advance helpers +serve.sh start/stop/status/logs helper +``` + +## Debug tools (`tools/mj.py`) + +Headless renders + plan inspection for agent-driven debugging. One +typer CLI, six subcommands; every subcommand defaults to +`--scene data_center`. Renders go through MuJoCo's native `Renderer` +over EGL (forced before the `mujoco` import so no X display is +needed); on a GPU host the GL driver offloads rendering automatically +with no explicit switch. `_runtime.py` owns the "import scene → +compile spec → apply initial state → advance task plan to time t" +plumbing so each subcommand stays a thin wrapper. + +```bash +# Single frame (free-cam orbit: --az/--el/--dist/--lookat) +uv run python tools/mj.py snapshot --az 45 --el -20 --out /tmp/home.png + +# Mid-task frame from a named scene camera +uv run python tools/mj.py snapshot --t 22 --camera top_d435i_cam --out /tmp/cable.png + +# Time-lapse: one PNG per 0.5 s from 0..30 s (requires --out-prefix + --t > 0) +uv run python tools/mj.py snapshot --t 30 --every 0.5 --out-prefix /tmp/run_ + +# Stitch a {prefix}*.png sequence into mp4 (libx264) or gif +uv run python tools/mj.py video --prefix /tmp/run_ --fps 20 --out /tmp/run.mp4 + +# All scene cameras + free-cam tiled into a labeled grid at one sim time +uv run python tools/mj.py grid --t 22 --out /tmp/grid.png + +# Task plan as a timeline table: side, start, dur, gripper, label, attach±, weld± +uv run python tools/mj.py plan | less + +# Pixel-diff heat-map between two equal-size renders; prints max/mean/%changed +uv run python tools/mj.py diff --a /tmp/before.png --b /tmp/after.png --out /tmp/d.png + +# IK feasibility sweep: replays each waypoint's arm_q, re-solves from 5 seeds, +# labels each step OK / FRAGILE (converges from only some seeds) / FAIL. +uv run python tools/mj.py ik +``` + +Run `tools/mj.py --help` or `tools/mj.py --help` for the full +option list. Shared option aliases (`--scene`, `--t`, `--width`, `--height`, +free-cam knobs) live at the top of `mj.py` so they behave identically across +subcommands. + +## Prerequisites + +1. Clone MuJoCo Menagerie to `~/mujoco_menagerie` (paths can be overridden via + the `MENAGERIE_PATH` env var — see `paths.py`): + ```bash + git clone --depth 1 https://github.com/google-deepmind/mujoco_menagerie.git ~/mujoco_menagerie + ``` +2. `uv` installed: + ```bash + curl -LsSf https://astral.sh/uv/install.sh | sh + ``` + +## Running locally + +```bash +cd experiments/bimanual_sim +uv sync # first time only +./serve.sh start # defaults to scene=data_center +# open http://localhost:8080 +``` + +Invoke `runner.py` directly for extra knobs: + +```bash +uv run python runner.py --scene data_center [--speed 1.0] [--render-hz 60] [--max-rate] +``` + +- `--speed` — multiplier on Step durations (also live-adjustable from the GUI slider). +- `--render-hz` — cap on render/physics tick rate. 60 is plenty for the browser; + higher just burns websocket CPU. +- `--max-rate` — drop the realtime throttle entirely. Useful for batch + trajectory generation or headless video capture. + +The Viser page exposes runtime controls: ▶ play / ⏸ pause, ↺ reset, speed +slider, per-arm status lines. + +## Running on a remote GPU box + +`serve.sh` is location-agnostic — it `cd`s to its own directory via +`$BASH_SOURCE`, so you can drop this tree anywhere on the remote. + +**Deploy** (adjust key + host): + +```bash +rsync -av --delete \ + --exclude='.venv' --exclude='__pycache__' \ + --exclude='*.log' --exclude='*.pid' --exclude='.ruff_cache' \ + -e "ssh -i ~/.ssh/YOUR_KEY.pem" \ + experiments/bimanual_sim/ \ + user@your.host:/path/on/remote/ +``` + +**Start / stop / inspect on remote**: + +```bash +ssh -i ~/.ssh/YOUR_KEY.pem user@your.host /path/on/remote/serve.sh start +ssh -i ~/.ssh/YOUR_KEY.pem user@your.host /path/on/remote/serve.sh status +ssh -i ~/.ssh/YOUR_KEY.pem user@your.host /path/on/remote/serve.sh logs 80 +ssh -i ~/.ssh/YOUR_KEY.pem user@your.host /path/on/remote/serve.sh stop +``` + +**View in a browser from your laptop.** Tunnel the Viser port: + +```bash +ssh -i ~/.ssh/YOUR_KEY.pem -L 8080:localhost:8080 user@your.host +# leave that session open, then: +open http://localhost:8080 +``` + +The runner binds to `127.0.0.1` by default — 8080 is never exposed publicly, +the SSH tunnel is the only way to reach it. + +## Scene contract (if adding another) + +A scene module under `scenes/` must export: + +```python +from arm_handles import ArmSide +from cameras import CameraRole +from scene_base import Step + +NAME = "my_scene" +ARM_PREFIXES: tuple[ArmSide, ...] = (ArmSide.LEFT, ArmSide.RIGHT) # or () for no arm +N_CUBES = 0 # number of grippable objects + +# Optional: camera frustum widgets drawn in Viser. +CAMERAS: tuple[tuple[str, CameraRole], ...] = () + +# Optional: scene-owned (non-arm) actuators addressable via Step.aux_ctrl. +AUX_ACTUATOR_NAMES: tuple[str, ...] = () + +def build_spec() -> mujoco.MjSpec: ... +def apply_initial_state(model, data, arms, cube_body_ids) -> None: ... + +# One of: +def make_task_plan(model, data, arms, cube_body_ids) -> dict[ArmSide, list[Step]]: ... +def step_free_play(t, model, data) -> None: ... +``` + +`Step` carries `weld_activate`/`weld_deactivate` (grasp cheats indexed by +cube id), `attach_activate`/`attach_deactivate` (body↔body welds addressed by +MJCF name), and `aux_ctrl` (dict of aux actuator name → target). See +`scenes/data_center.py` for a worked example that uses all of them. + +Start the new scene with: `./serve.sh start my_scene`. diff --git a/experiments/bimanual_sim/arm_handles.py b/experiments/bimanual_sim/arm_handles.py new file mode 100644 index 0000000..cdef986 --- /dev/null +++ b/experiments/bimanual_sim/arm_handles.py @@ -0,0 +1,99 @@ +"""Piper-specific arm accessors. + +`get_arm_handles` looks up the fully-qualified joint, actuator, body, and +equality IDs for one prefixed Piper instance in a compiled model. The result +is a lightweight dataclass the runner/IK/weld code passes around instead of +re-doing name lookups every tick. + +This helper assumes the Piper MJCF's naming: `{prefix}joint1..8`, +`{prefix}gripper`, `{prefix}link6`, `{prefix}grasp_cube{i}`. A scene using a +different arm would need its own handles builder. + +Arm identity is modelled as a `StrEnum` whose members' values are the MuJoCo +name prefixes ("left_", "right_"). Because `StrEnum` inherits from `str`, +f-string concatenation (`f"{side}joint1"`) yields the expected body names +without any `.value` boilerplate, while downstream code that used to take +`prefix: str` now rejects arbitrary strings at type-check time. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + +import mujoco +import numpy as np + + +class ArmSide(StrEnum): + """Bimanual arm identity; value is the MuJoCo body-name prefix.""" + + LEFT = "left_" + RIGHT = "right_" + + +@dataclass +class ArmHandles: + side: ArmSide + qpos_idx: np.ndarray # 8 qpos indices: joint1..6, finger_l (joint7), finger_r (joint8) + dof_idx: np.ndarray # 8 dof indices, same ordering + arm_dof_idx: np.ndarray # first 6 dof indices (arm joints only) + jnt_ids: np.ndarray # joint ids (length 8) + act_arm_ids: np.ndarray # actuator ids for joint1..6 position actuators + act_gripper_id: int # actuator id for the gripper + link6_id: int # body id at end of arm (parent of fingers) + tcp_site_id: int # site id at the grip center (link6 + 0.14 m along +z) + gripper_open: float # ctrl value that opens the gripper + gripper_closed: float # ctrl value that closes the gripper + weld_ids: np.ndarray # equality ids, one per cube (may be empty if n_cubes=0) + + @property + def arm_qpos_idx(self) -> np.ndarray: + return self.qpos_idx[:6] + + @property + def tcp_site_name(self) -> str: + return f"{self.side}tcp" + + +def get_arm_handles(model: mujoco.MjModel, side: ArmSide, n_cubes: int) -> ArmHandles: + jnt_names = [f"{side}joint{i}" for i in range(1, 9)] + jnt_ids = np.array([mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, n) for n in jnt_names]) + qpos_idx = np.array([model.jnt_qposadr[j] for j in jnt_ids]) + dof_idx = np.array([model.jnt_dofadr[j] for j in jnt_ids]) + act_arm_ids = np.array( + [ + mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_ACTUATOR, f"{side}joint{i}") + for i in range(1, 7) + ] + ) + act_gripper_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_ACTUATOR, f"{side}gripper") + link6_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, f"{side}link6") + tcp_site_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_SITE, f"{side}tcp") + + # Gripper actuator controls joint7 (range 0..0.035). Higher ctrl = more open. + gripper_jnt = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, f"{side}joint7") + lo, hi = model.jnt_range[gripper_jnt] + + weld_ids = np.array( + [ + mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_EQUALITY, f"{side}grasp_cube{i}") + for i in range(n_cubes) + ], + dtype=np.int64, + ) + + return ArmHandles( + side=side, + qpos_idx=qpos_idx, + dof_idx=dof_idx, + arm_dof_idx=dof_idx[:6], + jnt_ids=jnt_ids, + act_arm_ids=act_arm_ids, + act_gripper_id=act_gripper_id, + link6_id=link6_id, + tcp_site_id=tcp_site_id, + gripper_open=float(hi), + gripper_closed=float(lo), + weld_ids=weld_ids, + ) diff --git a/experiments/bimanual_sim/cameras.py b/experiments/bimanual_sim/cameras.py new file mode 100644 index 0000000..be710b8 --- /dev/null +++ b/experiments/bimanual_sim/cameras.py @@ -0,0 +1,188 @@ +"""Viser camera frustum widgets (and a place for future EGL image capture). + +A scene that wants cameras declares:: + + CAMERAS: tuple[tuple[str, CameraRole], ...] = ( + ("top_cam", CameraRole.TOP), + ("left_wrist", CameraRole.WRIST), + ("right_wrist", CameraRole.WRIST), + ) + +Each entry pairs the MuJoCo camera name with the role that picks its +intrinsics (FOV / aspect / widget scale). Role is an explicit `StrEnum` +rather than a substring inferred from the camera name — the earlier +substring logic would happily match `CameraRole.TOP` against a camera +named `topology_cam`, and the iteration order decided ties silently. + +The runner calls `add_frustum_widgets(...)` once at startup and +`update_frustum_widgets(...)` each frame so the frustum tracks the +camera's world pose (important for cameras attached to moving bodies +like the lift carriage or wrist links). + +Per-frame updates take the same fast path as `viser_render.update_viser`: +matrix→quaternion via `mujoco.mju_mat2Quat` (not `vtf.SO3.from_matrix`), +direct `buffer.push(...)` instead of the property setter, and one +`server.atomic()` block around the loop. Camera frustums are few (a +handful per scene) so the absolute CPU savings are modest, but they kept +the old hot-path shapes alive in the profile — this closes that gap. + +Future extension: `CameraRenderer` (sketched at the bottom, not implemented) +will use `mujoco.Renderer` with EGL to produce actual images and push them +as Viser image widgets. Keeping that concern here means the scene modules +don't need to change — they just declare camera names. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + +import mujoco +import numpy as np +import viser +from viser._messages import SetOrientationMessage, SetPositionMessage + + +class CameraRole(StrEnum): + """Classifies a scene camera so the right default intrinsics apply.""" + + TOP = "top" + WRIST = "wrist" + + +@dataclass(frozen=True) +class CameraIntrinsics: + """Viser frustum-widget intrinsics. `scale_m` is the visual size in metres.""" + + fov_deg: float + aspect: float # width / height + scale_m: float + + +_INTRINSICS: dict[CameraRole, CameraIntrinsics] = { + CameraRole.TOP: CameraIntrinsics(fov_deg=60.0, aspect=16.0 / 9.0, scale_m=0.10), + CameraRole.WRIST: CameraIntrinsics(fov_deg=87.0, aspect=4.0 / 3.0, scale_m=0.05), +} + + +@dataclass +class _FrustumHandle: + camera_id: int + handle: viser.CameraFrustumHandle + + +_MJ_TO_VISER_FLIP_QUAT = np.array([0.0, 1.0, 0.0, 0.0], dtype=np.float64) +"""180° rotation about x. MuJoCo cameras look down -z with +y up (OpenGL); +Viser's `add_camera_frustum` opens the frustum along +z with -y up. Applying +this rotation to the MuJoCo camera's world-frame quaternion (right-multiply) +flips the -z/+y axes to +z/-y so the frustum points the way the camera looks. +Without it, every frustum appears 180° about the camera's side axis — which +is the symptom the user saw ("cameras facing the wrong way").""" + + +def _camera_quat(data: mujoco.MjData, camera_id: int, out: np.ndarray) -> None: + """Fill `out` (shape (4,), float64) with the wxyz quaternion of the named + MuJoCo camera's world-frame orientation, converted to the viser frustum + convention. Uses `mujoco.mju_mat2Quat` + `mujoco.mju_mulQuat` (C kernels) + rather than `vtf.SO3.from_matrix`, which runs multiple `np.allclose` scans + to pick among four quaternion branches.""" + mj_quat = np.empty(4, dtype=np.float64) + mujoco.mju_mat2Quat(mj_quat, data.cam_xmat[camera_id]) + mujoco.mju_mulQuat(out, mj_quat, _MJ_TO_VISER_FLIP_QUAT) + + +def add_frustum_widgets( + server: viser.ViserServer, + model: mujoco.MjModel, + data: mujoco.MjData, + cameras: tuple[tuple[str, CameraRole], ...], +) -> list[_FrustumHandle]: + """Publish a Viser frustum for each declared camera; return handles to update later.""" + out: list[_FrustumHandle] = [] + quat_buf = np.empty(4, dtype=np.float64) + for name, role in cameras: + cam_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_CAMERA, name) + if cam_id < 0: + raise ValueError(f"camera {name!r} not found in compiled model") + intrinsics = _INTRINSICS[role] + pos = data.cam_xpos[cam_id] + _camera_quat(data, cam_id, quat_buf) + handle = server.scene.add_camera_frustum( + f"/cameras/{name}", + fov=np.deg2rad(intrinsics.fov_deg), + aspect=intrinsics.aspect, + scale=intrinsics.scale_m, + color=(0.9, 0.5, 0.2), + position=(float(pos[0]), float(pos[1]), float(pos[2])), + wxyz=( + float(quat_buf[0]), + float(quat_buf[1]), + float(quat_buf[2]), + float(quat_buf[3]), + ), + ) + out.append(_FrustumHandle(camera_id=cam_id, handle=handle)) + return out + + +def update_frustum_widgets( + server: viser.ViserServer, + data: mujoco.MjData, + handles: list[_FrustumHandle], +) -> None: + """Push world pose of each frustum widget using the same fast path as + `viser_render.update_viser`: cache `buffer.push`, bypass the property + setter's `np.allclose` diff, wrap the whole loop in `server.atomic()`.""" + if not handles: + return + + wsi = handles[0].handle._impl.api._websock_interface # noqa: SLF001 + buffer_push = wsi.get_message_buffer().push + + set_pos_msg = SetPositionMessage + set_ori_msg = SetOrientationMessage + mat2quat = mujoco.mju_mat2Quat + cam_xpos = data.cam_xpos + cam_xmat = data.cam_xmat + + mul_quat = mujoco.mju_mulQuat + mj_quat_buf = np.empty(4, dtype=np.float64) + quat_buf = np.empty(4, dtype=np.float64) + with server.atomic(): + for fh in handles: + cid = fh.camera_id + pos = cam_xpos[cid] + mat2quat(mj_quat_buf, cam_xmat[cid]) + mul_quat(quat_buf, mj_quat_buf, _MJ_TO_VISER_FLIP_QUAT) + pos_tuple = (float(pos[0]), float(pos[1]), float(pos[2])) + wxyz_tuple = ( + float(quat_buf[0]), + float(quat_buf[1]), + float(quat_buf[2]), + float(quat_buf[3]), + ) + impl = fh.handle._impl # noqa: SLF001 — viser-internal fast path + impl.position[:] = pos_tuple + impl.wxyz[:] = wxyz_tuple + name = impl.name + buffer_push(set_pos_msg(name, pos_tuple)) + buffer_push(set_ori_msg(name, wxyz_tuple)) + + +# --------------------------------------------------------------------------- +# Future: EGL image capture. +# +# class CameraRenderer: +# def __init__(self, model: mujoco.MjModel, camera_name: str, +# width: int = 320, height: int = 240) -> None: +# self.renderer = mujoco.Renderer(model, height=height, width=width) +# self.camera_id = mujoco.mj_name2id( +# model, mujoco.mjtObj.mjOBJ_CAMERA, camera_name) +# +# def render(self, data: mujoco.MjData) -> Float[np.ndarray, "h w 3"]: +# self.renderer.update_scene(data, camera=self.camera_id) +# return self.renderer.render() # HxWx3 uint8 +# +# The runner would build one per camera in CAMERAS, throttle to ~10 Hz, and +# push via `server.gui.add_image(...)` or an equivalent viser widget. Adding +# this requires no scene changes — only cameras.py + runner.py wiring. diff --git a/experiments/bimanual_sim/ik.py b/experiments/bimanual_sim/ik.py new file mode 100644 index 0000000..ef968db --- /dev/null +++ b/experiments/bimanual_sim/ik.py @@ -0,0 +1,209 @@ +"""Differential IK over a Piper arm's 6-DOF chain, driven by mink. + +`mink.solve_ik` is a QP (ProxQP-backed by default, switchable via `solver=...`) +over joint velocities, with joint limits and optional tasks. We iterate it to +convergence on a target frame — the `{prefix}tcp` site declared by the scene. + +ProxQP (via `proxsuite`) is typically 2-5x faster than DAQP on small 6-DoF QPs +and pays back the planning-phase wait (~100 QPs per waypoint x ~13 waypoints +x 2 arms at scene startup). Override with `solver="daqp"` to fall back. + +Three orientation modes encoded as a discriminated union so the three +combinations are mutually exclusive at the type level: + + * `PositionOnly()` — position-only; gripper orientation is free. + * `AlignGripperDown()` — keep TCP +z pointing world -z; rotation about + that axis is free. + * `FullPose(target_quat_wxyz)` — full pose tracking. + +Compared to the old hand-rolled DLS loop, joint limits are enforced as hard +QP constraints instead of post-step clamping — which is what used to park +the solver at Piper's ±70° j5 limit on top-down targets. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import mink +import mujoco +import numpy as np +import viser.transforms as vtf + +from arm_handles import ArmHandles +from scene_base import JointConfig, Position3, QuatWxyz + + +@dataclass(frozen=True) +class PositionOnly: + """Match TCP position; orientation free.""" + + +@dataclass(frozen=True) +class AlignGripperDown: + """Match TCP position AND keep TCP +z → world -z; in-axis rotation free.""" + + +@dataclass(frozen=True) +class FullPose: + """Match full TCP pose (wxyz quaternion).""" + + target_quat_wxyz: QuatWxyz + + +OrientationMode = PositionOnly | AlignGripperDown | FullPose + + +def _target_pose(target_pos: Position3, orientation: OrientationMode) -> mink.SE3: + match orientation: + case PositionOnly(): + # Orientation is weighted 0 in the task cost; quat is a placeholder. + rot = vtf.SO3.identity() + case AlignGripperDown(): + # 180° about world +x maps site +z (the gripper axis) → world -z. + rot = vtf.SO3.from_x_radians(np.pi) + case FullPose(target_quat_wxyz): + rot = vtf.SO3(wxyz=np.asarray(target_quat_wxyz, dtype=float)) + + mat4 = np.eye(4) + mat4[:3, :3] = rot.as_matrix() + mat4[:3, 3] = np.asarray(target_pos, dtype=float) + return mink.SE3.from_matrix(mat4) + + +def _costs(orientation: OrientationMode) -> tuple[float, float]: + match orientation: + case PositionOnly(): + return 1.0, 0.0 + case AlignGripperDown(): + return 1.0, 0.5 + case FullPose(): + return 1.0, 1.0 + + +_MAX_JOINT_VEL_RAD_S = np.pi # 180°/s — sane manipulation cap +_LOCKED_JOINT_VEL = 1e-6 # effectively frozen + + +def _velocity_limit( + model: mujoco.MjModel, + locked_joint_names: tuple[str, ...] = (), +) -> mink.VelocityLimit: + """Build a VelocityLimit over every single-DOF joint. + + Hinge joints get the default manipulation cap; any joint whose name is in + `locked_joint_names` is clamped to ~0 — use this to freeze scene-owned + DOFs (a lift prismatic, a second arm's joints that shouldn't move during + this IK call) so the QP can't "solve" them to compensate. + + Without this cap at all, mink's QP happily returns 100+ rad/s when the + position error is large; the integrator then overshoots wildly. + """ + limits: dict[str, float] = {} + for jid in range(model.njnt): + jtype = model.jnt_type[jid] + if jtype not in (mujoco.mjtJoint.mjJNT_HINGE, mujoco.mjtJoint.mjJNT_SLIDE): + continue + name = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_JOINT, jid) + if not name: + continue + limits[name] = _MAX_JOINT_VEL_RAD_S + for name in locked_joint_names: + limits[name] = _LOCKED_JOINT_VEL + return mink.VelocityLimit(model, limits) + + +# Keep the old name working for sink_bimanual (no locked joints needed there). +def _velocity_limit_for_all_hinges(model: mujoco.MjModel) -> mink.VelocityLimit: + return _velocity_limit(model, locked_joint_names=()) + + +def solve_ik( + model: mujoco.MjModel, + data: mujoco.MjData, + arm: ArmHandles, + target_pos: Position3, + *, + orientation: OrientationMode = PositionOnly(), # noqa: B008 — frozen dataclass, no shared state + seed_q: JointConfig | None = None, + max_iters: int = 400, + rate_dt: float = 0.02, + pos_tol: float = 1.5e-3, + rot_tol: float = 2e-2, + solver: str = "proxqp", + damping: float = 1e-5, + locked_joint_names: tuple[str, ...] = (), +) -> tuple[JointConfig, float]: + """Place the `{prefix}tcp` site at the target; return (solved_arm_q, err). + + Only the target arm's joints are free to move — the other arm's DOFs don't + appear in the FrameTask's Jacobian, so with a small velocity damping they + stay at zero velocity throughout the solve. + """ + if seed_q is not None: + for i, idx in enumerate(arm.arm_qpos_idx): + data.qpos[idx] = seed_q[i] + + configuration = mink.Configuration(model, q=data.qpos.copy()) + + position_cost, orientation_cost = _costs(orientation) + frame_task = mink.FrameTask( + frame_name=arm.tcp_site_name, + frame_type="site", + position_cost=position_cost, + orientation_cost=orientation_cost, + ) + frame_task.set_target(_target_pose(np.asarray(target_pos, dtype=float), orientation)) + + limits: list[mink.Limit] = [ + mink.ConfigurationLimit(model), + _velocity_limit(model, locked_joint_names), + ] + + # Track "best" using the same weighting as the task cost — otherwise a + # PositionOnly solve would score the seed as best (its rot_err is small + # while the solved config's rot_err has drifted to ~pi), and the function + # would return the unchanged seed even though the arm reached the target. + def scored_err(pos: float, rot: float) -> float: + return position_cost * pos + orientation_cost * rot + + best_score = np.inf + best_q = np.array([configuration.q[i] for i in arm.arm_qpos_idx]) + best_pos_err = np.inf + best_rot_err = np.inf + + for _ in range(max_iters): + err = frame_task.compute_error(configuration) # [err_pos(3), err_rot(3)] + pos_err = float(np.linalg.norm(err[:3])) + rot_err = float(np.linalg.norm(err[3:])) + score = scored_err(pos_err, rot_err) + + if score < best_score: + best_score = score + best_pos_err = pos_err + best_rot_err = rot_err + best_q = np.array([configuration.q[i] for i in arm.arm_qpos_idx]) + + if pos_err < pos_tol and (orientation_cost == 0.0 or rot_err < rot_tol): + break + + velocity = mink.solve_ik( + configuration, + [frame_task], + rate_dt, + solver, + limits=limits, + damping=damping, + ) + configuration.integrate_inplace(velocity, rate_dt) + + # Commit best arm config back to the caller's data. + for i, idx in enumerate(arm.arm_qpos_idx): + data.qpos[idx] = best_q[i] + mujoco.mj_kinematics(model, data) + # Report the position error explicitly — callers care about "did the TCP + # reach the target", not the scored combination. + reported = ( + best_pos_err if orientation_cost == 0.0 else float(np.hypot(best_pos_err, best_rot_err)) + ) + return best_q, reported diff --git a/experiments/bimanual_sim/paths.py b/experiments/bimanual_sim/paths.py new file mode 100644 index 0000000..4bfb0ed --- /dev/null +++ b/experiments/bimanual_sim/paths.py @@ -0,0 +1,78 @@ +"""Filesystem paths used by scenes. + +Resolved lazily so running the code on a new machine (different user, laptop +vs. EC2, etc.) doesn't require editing Python. Convention: clone +`mujoco_menagerie` into `$HOME/mujoco_menagerie`; override with the +`MENAGERIE_PATH` env var if you put it elsewhere. + +External filesystem paths are parsed at import via `parse_menagerie_xml`: a +missing MJCF raises a clear error here rather than later as a cryptic MuJoCo +load failure downstream. Downstream code consumes the refined `MenagerieXml` +type, which carries the proof that the file exists. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import NewType + +# A menagerie-relative XML path whose existence has been verified at +# construction time. Produced only by `parse_menagerie_xml`. +MenagerieXml = NewType("MenagerieXml", Path) +# Same refinement pattern, for mesh assets (.stl / .obj). Kept as a distinct +# NewType from MenagerieXml so we can't accidentally hand an STL to code that +# expects a compilable MJCF. +MenagerieMesh = NewType("MenagerieMesh", Path) + + +def _resolve_menagerie() -> Path: + env = os.environ.get("MENAGERIE_PATH") + if env: + return Path(env).expanduser() + # Default: ~/mujoco_menagerie — same path works for the ubuntu user on the + # EC2 instance and for a developer's laptop. + return Path.home() / "mujoco_menagerie" + + +def parse_menagerie_xml(*parts: str) -> MenagerieXml: + """Resolve a menagerie-relative MJCF path; raise if it doesn't exist. + + The raise-at-import gives one clear failure site ("menagerie not cloned" + or "that XML path is wrong") instead of the opaque MuJoCo parse error + you'd get when `MjSpec.from_file` is eventually called on a missing file. + """ + path = _resolve_menagerie().joinpath(*parts) + if not path.is_file(): + raise FileNotFoundError( + f"menagerie MJCF not found: {path}. " + "Clone https://github.com/google-deepmind/mujoco_menagerie into " + "$HOME/mujoco_menagerie or set MENAGERIE_PATH." + ) + return MenagerieXml(path) + + +def parse_menagerie_mesh(*parts: str) -> MenagerieMesh: + """Resolve a menagerie-relative mesh path (.stl/.obj); raise if missing.""" + path = _resolve_menagerie().joinpath(*parts) + if not path.is_file(): + raise FileNotFoundError( + f"menagerie mesh not found: {path}. " + "Clone https://github.com/google-deepmind/mujoco_menagerie into " + "$HOME/mujoco_menagerie or set MENAGERIE_PATH." + ) + return MenagerieMesh(path) + + +MENAGERIE: Path = _resolve_menagerie() +PIPER_XML: MenagerieXml = parse_menagerie_xml("agilex_piper", "piper.xml") +FRANKA_SCENE_XML: MenagerieXml = parse_menagerie_xml("franka_emika_panda", "scene.xml") +# PAL TIAGo single-arm: differential-drive base + torso lift. Used by +# scenes/data_center.py as the mobile embodiment (its single arm gets stripped +# by robots.tiago.load_tiago). +TIAGO_XML: MenagerieXml = parse_menagerie_xml("pal_tiago", "tiago.xml") +# Intel RealSense D435i package (mesh assets + standalone MJCF). +D435I_XML: MenagerieXml = parse_menagerie_xml("realsense_d435i", "d435i.xml") +# D405 wrist cam mesh lives inside ALOHA's asset dir. Referenced directly +# (no separate D405 package in Menagerie). +D405_MESH_STL: MenagerieMesh = parse_menagerie_mesh("aloha", "assets", "d405_solid.stl") diff --git a/experiments/bimanual_sim/pyproject.toml b/experiments/bimanual_sim/pyproject.toml new file mode 100644 index 0000000..92cfe98 --- /dev/null +++ b/experiments/bimanual_sim/pyproject.toml @@ -0,0 +1,50 @@ +[project] +name = "sim" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "imageio[ffmpeg]>=2.37.3", + "jaxtyping>=0.3.9", + "mink>=1.1.0", + "mujoco>=3.7.0", + "numpy>=2.4.4", + # 0.7.2 ships cp312/cp313 wheels; the 0.7.2.post1 tag is cp310-only, so + # pinning to or above the post1 bound would wedge any non-3.10 environment. + "proxsuite>=0.7.2,!=0.7.2.post1", + # Typer (FastAPI author, built on Click) powers tools/mj.py — one CLI + # with subcommands for snapshot / video / grid / plan / diff / ik. + "typer>=0.12.5", + "viser>=1.0.26", +] + +[dependency-groups] +dev = [ + "ruff>=0.15.11", # + "ty>=0.0.32", +] + +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.ruff.lint] +select = [ + "E", + "F", + "W", # pycodestyle + pyflakes + "I", # isort + "UP", # pyupgrade + "B", # bugbear + "SIM", # simplify +] +ignore = [ + "E501", # line too long — ruff format handles this; complaints usually noise +] + +[tool.ty.rules] +# mujoco ships as C extensions without .pyi stubs, so every mujoco.* access +# looks "unresolved" to ty. The rule would otherwise be useful for our own +# code, but it's drowned by ~70 false positives from mujoco alone. Accept +# the tradeoff: runtime will surface real typos in dataclass field access +# quickly during dev. +unresolved-attribute = "ignore" diff --git a/experiments/bimanual_sim/robots/__init__.py b/experiments/bimanual_sim/robots/__init__.py new file mode 100644 index 0000000..b3ca65e --- /dev/null +++ b/experiments/bimanual_sim/robots/__init__.py @@ -0,0 +1,4 @@ +"""Per-robot Menagerie loaders. Each module here owns exactly one upstream +Menagerie MJCF, the typed config describing our local customizations of +it, and the assertions that fail-fast when upstream shape drifts. +""" diff --git a/experiments/bimanual_sim/robots/piper.py b/experiments/bimanual_sim/robots/piper.py new file mode 100644 index 0000000..c7dce4e --- /dev/null +++ b/experiments/bimanual_sim/robots/piper.py @@ -0,0 +1,144 @@ +"""Agilex Piper loader — Menagerie-adapter for the bimanual arm attachments. + +Menagerie's `agilex_piper/piper.xml` stays untouched on disk; our scene +calls `attach_piper(scene_spec, prefix=..., frame=..., config=...)` which +reads upstream, attaches with the requested prefix, then overrides a +fixed set of actuator parameters via native `MjsActuator` properties +(`gainprm`, `biasprm`, `forcerange`). + +Why we override: upstream ships `kp=80 N·m/rad kv=5 forcerange=±100` on +joints 1–3, tuned for an empty wrist. Our scene carries a wrist camera +payload and plans far-reach cable poses where gravity torque on joint 2 +reaches ~8 N·m; at the default PD that lands ~0.55 rad of steady-state +droop (~28 cm of TCP offset). The override stays additive — we don't +delete or rename anything from upstream — so Menagerie bumps carry over +cleanly. + +Boundary assertions (`_assert_menagerie_shape`) check that the actuator +names we plan to touch actually exist in the upstream XML. If Menagerie +renames `joint1` → `shoulder_pitch`, load fails here with a clear +message instead of silently no-op'ing and leaving the arm at stock +gains (which would reintroduce the 28 cm droop). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +import mujoco + +from paths import PIPER_XML + + +@dataclass(frozen=True) +class PiperJointGain: + """kp / kv / forcerange override for a single Piper position actuator. + + `joint_suffix` is the name upstream uses for both the joint and the + actuator that drives it (Menagerie's convention). We concatenate the + scene-supplied `prefix` at attach time to resolve the final name. + """ + + joint_suffix: str + kp: float + kv: float + forcerange: tuple[float, float] + + +@dataclass(frozen=True) +class PiperConfig: + """Declarative customization of Menagerie's `agilex_piper/piper.xml`. + + Only actuator parameter overrides live here — not joint damping, not + masses, not geom tweaks. That's deliberate: the more we change, the + more drift risk on upstream bumps. If a future scene needs extra + knobs (e.g. per-joint damping), add them as new fields here so all + customizations stay in one typed place. + """ + + gains: tuple[PiperJointGain, ...] = field(default_factory=lambda: _DEFAULT_DATA_CENTER_GAINS) + + +# Default gain profile used by the data-center scene. Calibrated to +# eliminate gravity droop on loaded cable-reach poses: +# * kp bumped ~60× over upstream's 80 N·m/rad so kp·err doesn't saturate +# at modest tracking error. +# * kv ≈ 1.2× critical damping (2·√(kp·I)) for each joint's effective +# inertia — same damping ratio upstream uses, just at a stiffer kp. +# Over-damping (an earlier kv=150 try) asymptoted slowly; under-damping +# introduces audible oscillation at the PD step-response. +# * forcerange widened ±1000 N·m so the PD has headroom to converge +# before saturating (upstream ±100 clamped error at 0.02 rad, leaving +# ~3 cm of droop on shoulder-heavy poses). +_DEFAULT_DATA_CENTER_GAINS: tuple[PiperJointGain, ...] = ( + PiperJointGain("joint1", kp=5000.0, kv=60.0, forcerange=(-1000.0, 1000.0)), + PiperJointGain("joint2", kp=5000.0, kv=60.0, forcerange=(-1000.0, 1000.0)), + PiperJointGain("joint3", kp=3000.0, kv=40.0, forcerange=(-1000.0, 1000.0)), + PiperJointGain("joint4", kp=1500.0, kv=25.0, forcerange=(-1000.0, 1000.0)), + PiperJointGain("joint5", kp=800.0, kv=15.0, forcerange=(-1000.0, 1000.0)), + PiperJointGain("joint6", kp=800.0, kv=15.0, forcerange=(-1000.0, 1000.0)), +) + + +_PIPER_REQUIRED_ACTUATORS: tuple[str, ...] = ( + "joint1", + "joint2", + "joint3", + "joint4", + "joint5", + "joint6", + "gripper", +) + + +def _assert_menagerie_shape(piper_spec: mujoco.MjSpec, config: PiperConfig) -> None: + """Fail fast if the upstream actuator names we plan to override are + missing or the config references one that isn't upstream.""" + upstream_actuators = {a.name for a in piper_spec.actuators if a.name} + for required in _PIPER_REQUIRED_ACTUATORS: + if required not in upstream_actuators: + raise RuntimeError( + f"Piper upstream XML missing expected actuator {required!r}. " + "Menagerie's agilex_piper/piper.xml may have changed shape — " + "update robots/piper.py if the new naming is intentional." + ) + for gain in config.gains: + if gain.joint_suffix not in upstream_actuators: + raise RuntimeError( + f"PiperConfig.gains references actuator {gain.joint_suffix!r} " + f"not in Piper upstream (actuators: {sorted(upstream_actuators)!r})." + ) + + +def attach_piper( + scene_spec: mujoco.MjSpec, + *, + prefix: str, + frame: mujoco.MjsFrame, + config: PiperConfig = PiperConfig(), # noqa: B008 — frozen dataclass, no shared state +) -> None: + """Attach a prefixed Piper to `scene_spec` at `frame`, then apply + gain/forcerange overrides per `config`. + + `prefix` is prepended to every Piper body/joint/actuator/geom name on + attach (MjSpec convention). The post-attach override uses the native + `spec.actuator(name).gainprm = [...]` form — same as writing the + values directly into `` elements, just expressed in Python. + + Mutates `scene_spec` in place; no return value. + """ + piper_spec = mujoco.MjSpec.from_file(str(PIPER_XML)) + _assert_menagerie_shape(piper_spec, config) + scene_spec.attach(piper_spec, prefix=prefix, frame=frame) + + for g in config.gains: + name = f"{prefix}{g.joint_suffix}" + act = scene_spec.actuator(name) + if act is None: + raise RuntimeError( + f"attach_piper: actuator {name!r} not found on scene spec after " + f"attach. Check prefix={prefix!r} / PiperConfig.gains." + ) + act.gainprm = [g.kp, 0, 0, 0, 0, 0, 0, 0, 0, 0] + act.biasprm = [0.0, -g.kp, -g.kv, 0, 0, 0, 0, 0, 0, 0] + act.forcerange = list(g.forcerange) diff --git a/experiments/bimanual_sim/robots/tiago.py b/experiments/bimanual_sim/robots/tiago.py new file mode 100644 index 0000000..87b8326 --- /dev/null +++ b/experiments/bimanual_sim/robots/tiago.py @@ -0,0 +1,166 @@ +"""PAL TIAGo loader — Menagerie-adapter for the data-center scene's mobile base. + +Menagerie's `pal_tiago/tiago.xml` stays untouched on disk; everything our +scene needs to change about it is declared here as a typed +`TiagoConfig` + applied by `load_tiago(config)`. Scenes import only +`load_tiago` and `torso_world_pos_at_zero` from this module — not +`TIAGO_XML` directly — so there's a single place to look when Menagerie +updates and something needs to adjust. + +Customizations we currently apply: + +* **Strip the upstream single arm** (`arm_1_link` subtree) — the data- + center scene replaces it with two Pipers attached via `robots/piper.py`. +* **Strip the upstream head** (`head_1_link` subtree) — unactuated in + our scenes; the camera it carried is replaced by a rigid pole + D435i + welded to base_link. +* **Delete the `reference` freejoint on base_link** — mink's IK is + purely kinematic and ignores equality constraints, so leaving a + floating base pinned only by an `mjEQ_WELD` lets the solver slide the + robot under a world-frame target during planning. That produced the + "grabs at mid-air" bug (IK reports near-zero error while runtime TCP + is 40 cm off). Removing the joint keeps planning and runtime kinematics + in agreement. + +Boundary assertions (see `_assert_menagerie_shape`) check the upstream +names we depend on exist BEFORE we try to delete or reference them. If +Menagerie renames `arm_1_link` → `left_arm_link` the load fails here +with a clear message instead of silently no-op'ing and failing 400 +lines later in IK-land. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from dataclasses import dataclass + +import mujoco + +from paths import TIAGO_XML + + +@dataclass(frozen=True) +class TiagoConfig: + """Declarative customization for Menagerie's `pal_tiago/tiago.xml`. + + Only the deltas our scenes need are listed here — everything else + (meshes, inertia, joint damping, actuator-less torso_lift_joint) + stays as upstream. Keeping the diff this small is deliberate: every + override is a drift risk on the next Menagerie bump. + """ + + strip_subtrees: tuple[str, ...] = ("arm_1_link", "head_1_link") + remove_freejoint_name: str | None = "reference" + + +# Names we dereference at load time; see `_assert_menagerie_shape`. +_TIAGO_REQUIRED_BODIES: tuple[str, ...] = ( + "base_link", + "torso_lift_link", + "arm_1_link", + "head_1_link", +) + + +def _iter_body_subtree(root: mujoco.MjsBody) -> Iterator[mujoco.MjsBody]: + """Pre-order traversal yielding `root` and every descendant body.""" + yield root + for child in root.bodies: + yield from _iter_body_subtree(child) + + +def _collect_dead_body_names(spec: mujoco.MjSpec, subtree_roots: tuple[str, ...]) -> set[str]: + """Names of every body inside each named subtree, collected BEFORE + `spec.delete(...)` runs so downstream orphan-reference pruning + (contact excludes) knows which names just disappeared.""" + dead: set[str] = set() + for root_name in subtree_roots: + root = spec.body(root_name) + for b in _iter_body_subtree(root): + if b.name: + dead.add(b.name) + return dead + + +def _assert_menagerie_shape(spec: mujoco.MjSpec, config: TiagoConfig) -> None: + """Fail at load time if the upstream names we plan to touch are + missing. Cheaper to triage here than as a mysterious MuJoCo compile + error 400 lines into `build_spec`. + """ + # Everything the scene downstream assumes exists: + for name in _TIAGO_REQUIRED_BODIES: + if spec.body(name) is None: + raise RuntimeError( + f"TIAGo upstream XML missing expected body {name!r}. " + "Menagerie's pal_tiago/tiago.xml may have changed shape — " + "update robots/tiago.py if the new naming is intentional." + ) + # Each strip target must actually be there: + for subtree in config.strip_subtrees: + if spec.body(subtree) is None: + raise RuntimeError( + f"TiagoConfig.strip_subtrees references {subtree!r} but it's " + "not in TIAGo's upstream XML." + ) + # Freejoint to remove must exist where we expect it: + if config.remove_freejoint_name is not None: + base = spec.body("base_link") + names = {j.name for j in base.joints if j.name} + if config.remove_freejoint_name not in names: + raise RuntimeError( + f"TiagoConfig.remove_freejoint_name={config.remove_freejoint_name!r} " + f"but base_link's joints are {sorted(names)!r}." + ) + + +def load_tiago(config: TiagoConfig = TiagoConfig()) -> mujoco.MjSpec: # noqa: B008 — frozen dataclass, no shared state + """Return a customized, uncompiled TIAGo MjSpec per `config`. + + Callers typically treat this as the root spec and add more bodies to + its worldbody before `spec.compile()` — that's what + `scenes/data_center.py::build_spec` does. + """ + spec = mujoco.MjSpec.from_file(str(TIAGO_XML)) + _assert_menagerie_shape(spec, config) + + dead_bodies = _collect_dead_body_names(spec, config.strip_subtrees) + for root_name in config.strip_subtrees: + spec.delete(spec.body(root_name)) # cascades to descendants + + if config.remove_freejoint_name is not None: + base = spec.body("base_link") + for j in list(base.joints): + if j.name == config.remove_freejoint_name: + spec.delete(j) + break + + # Prune orphaned `` entries (TIAGo's upstream declares 3, all + # referencing arm/gripper bodies that are now gone). TIAGo has no + # `` or `` block upstream, so nothing else to prune. + for exc in list(spec.excludes): + if exc.bodyname1 in dead_bodies or exc.bodyname2 in dead_bodies: + spec.delete(exc) + + return spec + + +def torso_world_pos_at_zero() -> tuple[float, float, float]: + """World pos of `torso_lift_link` at qpos=0, read directly from + Menagerie's tiago.xml. + + Referenced by `scenes/data_center_layout` so bin heights / IK targets + derive from the one authoritative source — no hardcoded `(0, 0, 0.8885)` + copy to drift silently if Menagerie updates the torso attach point. + + In TIAGo's upstream file `torso_lift_link` hangs off `torso_fixed_link` + whose own pos is (0, 0, 0), so the local pos is the world pos at qpos=0. + """ + spec = mujoco.MjSpec.from_file(str(TIAGO_XML)) + body = spec.body("torso_lift_link") + if body is None: + raise RuntimeError( + "TIAGo upstream XML missing torso_lift_link — " + "did Menagerie's pal_tiago/tiago.xml change shape?" + ) + pos = body.pos + return (float(pos[0]), float(pos[1]), float(pos[2])) diff --git a/experiments/bimanual_sim/runner.py b/experiments/bimanual_sim/runner.py new file mode 100644 index 0000000..98fde16 --- /dev/null +++ b/experiments/bimanual_sim/runner.py @@ -0,0 +1,482 @@ +"""Generic scene runner. + +Imports a scene module by name, compiles its spec, and plays it through +Viser. Two modes, picked by what the scene module exposes: + + * Task-planned scenes (`make_task_plan`): per-arm state machines advance in + parallel, each interpolating its ctrl through a list of Steps. Weld + activate/deactivate transitions fire on entry to the relevant step. + * Free-play scenes (`step_free_play`): the scene's callback is invoked + every render tick to set ctrl directly. + +CLI: + python runner.py --scene sink_bimanual [--host 127.0.0.1] [--port 8080] + [--speed 1.0] [--render-hz 60] + [--max-rate] + +`--render-hz` caps how often the Viser scene and physics are advanced — the +browser can't tell the difference between 60 Hz and 125 Hz updates, but the +viser/websocket CPU cost scales linearly with the rate. `--max-rate` drops +the realtime throttle entirely so the sim runs as fast as MuJoCo can step +(useful for batch trajectory generation or video capture). + +Connecting from a laptop when the runner is on a remote host: SSH-tunnel the +port, e.g. `ssh -L 8080:localhost:8080 user@host`, then open localhost:8080. +""" + +from __future__ import annotations + +import argparse +import importlib +import math +import sys +import time +from dataclasses import dataclass, field +from pathlib import Path + +import mujoco +import numpy as np +import viser + +# Make sibling modules importable regardless of the invoking cwd. +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from arm_handles import ArmHandles, ArmSide, get_arm_handles +from cameras import CameraRole, add_frustum_widgets, update_frustum_widgets +from scene_base import Step +from scene_check import AttachmentConstraint, check_scene, print_schematic +from viser_render import build_viser_scene, update_viser +from welds import ( + activate_attachment_weld, + activate_grasp_weld, + deactivate_grasp_weld, + deactivate_weld, +) + + +@dataclass +class ArmTimelineState: + """Per-arm progress through its Step list. Each arm advances independently.""" + + start_q: np.ndarray + start_g: float + # Remembers the most-recently committed target for each scene-owned aux + # actuator, so interpolation can continue smoothly across steps. Keys are + # actuator names (same as Step.aux_ctrl keys). Missing key ⇒ use current + # data.ctrl at tick entry. + start_aux: dict[str, float] = field(default_factory=dict) + step: int = 0 + t: float = 0.0 + + +def _load_scene(name: str): + return importlib.import_module(f"scenes.{name}") + + +def _collect_cube_body_ids(model: mujoco.MjModel, n_cubes: int) -> list[int]: + return [mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, f"cube{i}") for i in range(n_cubes)] + + +def _extract_attachment_constraints(scene) -> tuple[AttachmentConstraint, ...]: + """Adapt a scene's `ATTACHMENTS` tuple (of `_AttachmentWeldSpec`) into the + scene-agnostic `AttachmentConstraint` shape that `scene_check` consumes. + + Scenes expose their registry via a module-level `ATTACHMENTS` attribute + where each entry has `.name`, `.body_a`, `.body_b`, `.kind` (an enum + with `.value in {"weld", "connect"}`), and `.connect_anchor_in_a`. + Scenes without the registry get an empty tuple — `check_scene` then + skips the attachment validations silently. + """ + raw = getattr(scene, "ATTACHMENTS", ()) + if not raw: + return () + out: list[AttachmentConstraint] = [] + for a in raw: + # `a.kind` may be a StrEnum; its value is "weld" or "connect". + kind_value = getattr(a.kind, "value", a.kind) + out.append( + AttachmentConstraint( + name=str(a.name), + body_a=str(a.body_a), + body_b=str(a.body_b), + kind=kind_value, # type: ignore[arg-type] + connect_anchor_in_a=tuple(a.connect_anchor_in_a), + ) + ) + return tuple(out) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--scene", required=True, help="Scene module name under scenes/") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8080) + parser.add_argument("--speed", type=float, default=1.0) + parser.add_argument( + "--render-hz", + type=float, + default=45.0, + help=( + "viser + physics update rate; physics timestep is still the scene's" + " mj timestep. Default 45 Hz trades 60→45 of server-side updates for" + " ~25%% less websocket / message-buffer CPU; the browser renders" + " between updates via WebGL so the visual result stays smooth." + ), + ) + parser.add_argument( + "--max-rate", + action="store_true", + help="skip the realtime sleep throttle; run physics as fast as CPU allows", + ) + parser.add_argument( + "--inspect", + action="store_true", + help=( + "build + compile the scene, run scene_check, print a body/geom " + "schematic to stdout, and exit before physics/viser. Pair with a " + "layout edit to confirm the scene still passes invariants." + ), + ) + args = parser.parse_args() + + scene = _load_scene(args.scene) + print(f"Building scene: {getattr(scene, 'NAME', args.scene)}") + + spec = scene.build_spec() + model = spec.compile() + data = mujoco.MjData(model) + print( + f"compiled: nbody={model.nbody} njnt={model.njnt} nu={model.nu} " + f"neq={model.neq} ngeom={model.ngeom}" + ) + + arm_sides: tuple[ArmSide, ...] = getattr(scene, "ARM_PREFIXES", ()) + n_cubes: int = getattr(scene, "N_CUBES", 0) + cube_body_ids = _collect_cube_body_ids(model, n_cubes) + arms: dict[ArmSide, ArmHandles] = { + side: get_arm_handles(model, side, n_cubes) for side in arm_sides + } + + # Scene-owned actuators (e.g. a lift prismatic). Resolved once at startup; + # Step.aux_ctrl entries refer to these by name. We also resolve the + # underlying joint qpos/qvel addresses for puppet-mode direct writes. + aux_name_to_id: dict[str, int] = { + name: mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_ACTUATOR, name) + for name in getattr(scene, "AUX_ACTUATOR_NAMES", ()) + } + for name, aid in aux_name_to_id.items(): + if aid < 0: + raise ValueError( + f"scene declared aux actuator {name!r} but no such actuator in compiled model" + ) + aux_qposadr: dict[str, int] = {} + aux_dofadr: dict[str, int] = {} + for name, aid in aux_name_to_id.items(): + # Aux actuators must be JOINT-transmission position actuators + # (e.g. torso_lift_joint). actuator_trnid[aid][0] is the joint id. + jnt_id = int(model.actuator_trnid[aid][0]) + aux_qposadr[name] = int(model.jnt_qposadr[jnt_id]) + aux_dofadr[name] = int(model.jnt_dofadr[jnt_id]) + + scene.apply_initial_state(model, data, arms, cube_body_ids) + + # ---- Compile-time sanity checks (always-on; see scene_check) ------------ + # Runs before `make_task_plan` so IK-unfriendly geometry shows up as a + # scene error rather than an IK-residual-too-large panic. Scenes opt in to + # additional descriptors (allow-listed overlaps, the attachment-constraint + # registry, grippable-name list) via module-level attributes that we read + # here with `getattr` so scenes that don't declare them still work. + grippable_names: tuple[str, ...] = getattr(scene, "GRIPPABLES", ()) + allowed_overlaps: tuple[tuple[str, str], ...] = getattr(scene, "ALLOWED_STATIC_OVERLAPS", ()) + attachment_constraints = _extract_attachment_constraints(scene) + + if args.inspect: + # --inspect: print the schematic, run checks (which may raise), exit + # before physics/viser. Print happens first so the user always sees + # the body/geom tree, even if check_scene is about to raise. + print_schematic( + model, + data, + arms=arms, + grippable_names=grippable_names, + attachment_constraints=attachment_constraints, + ) + check_scene( + model, + data, + arms=arms, + grippable_names=grippable_names, + allowed_static_overlaps=allowed_overlaps, + attachment_constraints=attachment_constraints, + ) + print("\ncheck_scene: OK") + return + + check_scene( + model, + data, + arms=arms, + grippable_names=grippable_names, + allowed_static_overlaps=allowed_overlaps, + attachment_constraints=attachment_constraints, + ) + + task_plan: dict[ArmSide, list[Step]] | None = None + if hasattr(scene, "make_task_plan"): + print("Solving IK waypoints...") + task_plan = scene.make_task_plan(model, data, arms, cube_body_ids) + scene.apply_initial_state(model, data, arms, cube_body_ids) + + has_free_play = hasattr(scene, "step_free_play") + if task_plan is None and not has_free_play: + print( + "warning: scene provides neither make_task_plan nor step_free_play; " + "arms will hold their initial pose" + ) + + # Viser + server = viser.ViserServer(host=args.host, port=args.port) + # `build_viser_scene` now needs `data` so it can bake the initial world + # pose of static geoms into `add_mesh_simple` and drop them from the + # per-frame update list. Callers must have finished `apply_initial_state` + # by this point (we have, above). + handles = build_viser_scene(server, model, data) + + cameras: tuple[tuple[str, CameraRole], ...] = getattr(scene, "CAMERAS", ()) + frustum_handles = add_frustum_widgets(server, model, data, cameras) if cameras else [] + + gui_state = server.gui.add_text("state", initial_value="ready", disabled=True) + gui_speed = server.gui.add_slider( + "speed", min=0.1, max=3.0, step=0.1, initial_value=float(args.speed) + ) + gui_play = server.gui.add_button("▶ play / ⏸ pause") + gui_reset = server.gui.add_button("↺ reset") + + per_arm_gui: dict[ArmSide, viser.GuiTextHandle] = {} + for side in arm_sides: + per_arm_gui[side] = server.gui.add_text( + side.rstrip("_") or "arm", initial_value="-", disabled=True + ) + + control = {"playing": True, "reset_requested": False} + + @gui_play.on_click + def _on_play(_event): + control["playing"] = not control["playing"] + + @gui_reset.on_click + def _on_reset(_event): + control["reset_requested"] = True + + sim_dt = float(model.opt.timestep) + # Decouple render rate from the physics timestep. Every frame we step + # physics `phys_steps_per_frame` times so wall-clock advance = render_dt. + # At the default 60 Hz and a 2 ms mj timestep that's 8 steps/frame — half + # the per-second viser work of the previous 125 Hz loop for the same + # simulated-time budget. + render_dt = 1.0 / max(args.render_hz, 1e-3) + phys_steps_per_frame = max(1, int(round(render_dt / sim_dt))) + # Re-derive render_dt from the rounded step count so the physics clock and + # the sleep throttle agree exactly. + render_dt = sim_dt * phys_steps_per_frame + print( + f"render: {1.0 / render_dt:.1f} Hz " + f"({phys_steps_per_frame} physics steps × {sim_dt * 1000:.1f} ms)" + + (" [max-rate: throttle off]" if args.max_rate else "") + ) + + def fresh_state() -> dict[ArmSide, ArmTimelineState]: + return { + side: ArmTimelineState( + start_q=np.array([data.qpos[i] for i in arms[side].arm_qpos_idx]), + start_g=float(data.ctrl[arms[side].act_gripper_id]), + ) + for side in arm_sides + } + + per_arm: dict[ArmSide, ArmTimelineState] = fresh_state() + + def restart() -> None: + nonlocal per_arm + scene.apply_initial_state(model, data, arms, cube_body_ids) + per_arm = fresh_state() + + def advance_arm(side: ArmSide, dt: float) -> str: + assert task_plan is not None + script = task_plan[side] + st = per_arm[side] + arm = arms[side] + + if st.step >= len(script): + return "done" + + step = script[st.step] + duration = step.duration / max(gui_speed.value, 0.05) + + first_tick = st.t == 0.0 + st.t += dt + + if first_tick: + if step.weld_activate is not None: + activate_grasp_weld( + model, + data, + int(arm.weld_ids[step.weld_activate]), + arm.link6_id, + cube_body_ids[step.weld_activate], + arm.tcp_site_id, + ) + if step.weld_deactivate is not None: + deactivate_grasp_weld(data, int(arm.weld_ids[step.weld_deactivate])) + # Attachment equalities. Two kinds, branched by eq_type: + # WELD — freeze current relative pose (no teleport) so the + # body stays put when the constraint flips on. + # CONNECT — anchor is already baked into eq_data at build + # time; activation is a simple flag flip. The solver will + # pull body_b's origin to the anchor over the next few + # steps (small snap if the bodies were close). + for weld_name in step.attach_activate: + eq_id = int(mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_EQUALITY, weld_name)) + if eq_id < 0: + raise ValueError(f"step '{step.label}': unknown attach weld {weld_name!r}") + if int(model.eq_type[eq_id]) == int(mujoco.mjtEq.mjEQ_CONNECT): + data.eq_active[eq_id] = 1 + else: + body_a = int(model.eq_obj1id[eq_id]) + body_b = int(model.eq_obj2id[eq_id]) + activate_attachment_weld(model, data, eq_id, body_a, body_b) + for weld_name, target_xyz, target_quat in step.attach_activate_at or (): + eq_id = int(mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_EQUALITY, weld_name)) + if eq_id < 0: + raise ValueError(f"step '{step.label}': unknown attach_at weld {weld_name!r}") + if int(model.eq_type[eq_id]) == int(mujoco.mjtEq.mjEQ_CONNECT): + raise ValueError( + f"step '{step.label}': attach_activate_at requires a WELD " + f"equality, got CONNECT for {weld_name!r}" + ) + body_a = int(model.eq_obj1id[eq_id]) + body_b = int(model.eq_obj2id[eq_id]) + activate_attachment_weld( + model, + data, + eq_id, + body_a, + body_b, + target_world_pose=(target_xyz, target_quat), + ) + for weld_name in step.attach_deactivate: + eq_id = int(mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_EQUALITY, weld_name)) + if eq_id < 0: + raise ValueError(f"step '{step.label}': unknown attach weld {weld_name!r}") + deactivate_weld(data, eq_id) + + alpha = min(1.0, st.t / max(duration, 1e-3)) + alpha_s = 0.5 - 0.5 * math.cos(math.pi * alpha) + + # Puppet mode: write joint qpos directly (no PD tracking, no + # gravity droop, no overshoot). Zero qvel so mj_step's integrator + # doesn't advance qpos away from where we put it. Mirror ctrl so + # the position actuators don't fight the qpos write with residual + # PD force from a stale ctrl value. + curr_q = (1.0 - alpha_s) * st.start_q + alpha_s * step.arm_q + data.qpos[arm.arm_qpos_idx] = curr_q + data.qvel[arm.arm_dof_idx] = 0.0 + data.ctrl[arm.act_arm_ids] = curr_q + + tgt_g = arm.gripper_open if step.gripper == "open" else arm.gripper_closed + curr_g = (1.0 - alpha_s) * st.start_g + alpha_s * tgt_g + # Gripper joint7 (index 6) and joint8 (index 7) are tendon-coupled + # mirrors; write both so mj_step's tendon constraint has nothing + # to enforce. + data.qpos[arm.qpos_idx[6]] = curr_g + data.qpos[arm.qpos_idx[7]] = -curr_g + data.qvel[arm.dof_idx[6]] = 0.0 + data.qvel[arm.dof_idx[7]] = 0.0 + data.ctrl[arm.act_gripper_id] = curr_g + + # Scene-owned auxiliary actuators (e.g. lift). Multiple arms may + # write the same aux on overlapping steps; last write wins per tick + # — scenes are expected to keep their targets consistent. + if step.aux_ctrl: + for aux_name, aux_target in step.aux_ctrl.items(): + aid = aux_name_to_id[aux_name] + start = st.start_aux.get(aux_name, float(data.qpos[aux_qposadr[aux_name]])) + curr_aux = (1.0 - alpha_s) * start + alpha_s * aux_target + data.qpos[aux_qposadr[aux_name]] = curr_aux + data.qvel[aux_dofadr[aux_name]] = 0.0 + data.ctrl[aid] = curr_aux + + label = step.label + if alpha >= 1.0: + st.start_q = step.arm_q.copy() + st.start_g = tgt_g + if step.aux_ctrl: + for aux_name, aux_target in step.aux_ctrl.items(): + st.start_aux[aux_name] = aux_target + st.step += 1 + st.t = 0.0 + return f"{st.step}/{len(script)} {label} ✓" + return f"{st.step + 1}/{len(script)} {label}" + + def all_done() -> bool: + if task_plan is None: + return False + return all(per_arm[side].step >= len(task_plan[side]) for side in arm_sides) + + print(f"Viser on {args.host}:{args.port} ({len(handles)} geoms)") + print( + f"If remote: `ssh -L {args.port}:localhost:{args.port} user@host` " + f"then open http://localhost:{args.port}" + ) + if task_plan is not None: + parts = ", ".join(f"{side}={len(task_plan[side])}" for side in arm_sides) + print(f"Timeline: {parts} steps (run in parallel)") + + next_tick = time.perf_counter() + sim_t = 0.0 + + try: + while True: + if control["reset_requested"]: + restart() + sim_t = 0.0 + control["reset_requested"] = False + + if control["playing"]: + if task_plan is not None: + for side in arm_sides: + per_arm_gui[side].value = advance_arm(side, render_dt) + if all_done(): + gui_state.value = "done — press reset" + control["playing"] = False + else: + gui_state.value = "running" + elif has_free_play: + scene.step_free_play(sim_t, model, data) + gui_state.value = "running" + else: + gui_state.value = "idle" + else: + gui_state.value = "paused" if not all_done() else "done — press reset" + + for _ in range(phys_steps_per_frame): + mujoco.mj_step(model, data) + sim_t += render_dt + + update_viser(server, model, data, handles) + if frustum_handles: + update_frustum_widgets(server, data, frustum_handles) + + if not args.max_rate: + next_tick += render_dt + sleep = next_tick - time.perf_counter() + if sleep > 0: + time.sleep(sleep) + else: + next_tick = time.perf_counter() + except KeyboardInterrupt: + print("stopped") + + +if __name__ == "__main__": + main() diff --git a/experiments/bimanual_sim/scene_base.py b/experiments/bimanual_sim/scene_base.py new file mode 100644 index 0000000..2d19b1f --- /dev/null +++ b/experiments/bimanual_sim/scene_base.py @@ -0,0 +1,140 @@ +"""Scene-module conventions. + +Every file in `scenes/` is expected to expose, at module level: + + NAME: str # display name used in the Viser GUI + ARM_PREFIXES: tuple[str, ...] # prefix strings identifying arms in the model + # (empty tuple for scenes with no articulated arm) + N_CUBES: int # number of grippable objects (0 if no grasp) + + def build_spec() -> mujoco.MjSpec: + '''Construct the uncompiled MJCF spec.''' + + def apply_initial_state( + model: mujoco.MjModel, + data: mujoco.MjData, + arms: dict[ArmSide, ArmHandles], + cube_body_ids: list[int], + ) -> None: + '''Set qpos/ctrl/eq_active to their demo-start values.''' + +One of these two, depending on whether the scene is scripted or free-play: + + def make_task_plan( + model: mujoco.MjModel, + data: mujoco.MjData, + arms: dict[ArmSide, ArmHandles], + cube_body_ids: list[int], + ) -> dict[ArmSide, list[Step]]: + '''Return a per-arm list of waypoint steps.''' + + def step_free_play( + t: float, + model: mujoco.MjModel, + data: mujoco.MjData, + ) -> None: + '''Called each frame; set data.ctrl however you like.''' + +The runner introspects the module, so either (or both) can be absent. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal, NewType + +import numpy as np +from jaxtyping import Float + +# --------------------------------------------------------------------------- +# Array shape aliases (jaxtyping). +# These are purely annotation aliases — jaxtyping carries no runtime cost +# unless combined with a checker. They exist so IK, welds, and scene helpers +# can encode the expected shape (`(3,)`, `(4,)`, `(6,)`) in the signature +# instead of passing bare `np.ndarray`. +# --------------------------------------------------------------------------- +Position3 = Float[np.ndarray, "3"] +QuatWxyz = Float[np.ndarray, "4"] +JointConfig = Float[np.ndarray, "6"] # Piper 6-DoF arm qpos + +# The set of gripper command states is finite and project-wide; using a Literal +# lets ty/mypy catch a typo at the call site instead of blowing up at runtime. +GripperState = Literal["open", "closed"] + +# Bounded index into a scene's grippable-object list. Construct via +# `make_cube_id` so the bound check lives in one place; downstream code +# (runner, Step) accepts it without re-checking. +CubeID = NewType("CubeID", int) + + +def make_cube_id(index: int, n_cubes: int) -> CubeID: + """Build a bounds-checked CubeID; raises if the index is out of range.""" + if not 0 <= index < n_cubes: + raise IndexError(f"cube index {index} out of range for scene with n_cubes={n_cubes}") + return CubeID(index) + + +@dataclass +class Step: + """A single waypoint in a per-arm timeline.""" + + label: str + arm_q: JointConfig # shape (6,) — target joint configuration for the arm + gripper: GripperState + duration: float # seconds at seep 1.0 + + # Grasp welds (arm.link6 ↔ cube). Addressed by a CubeID (bounds-checked int + # index into cube_body_ids). `weld_activate` teleports the cube to the TCP; + # used for the "free cube in sink" case. `weld_deactivate` just clears the + # flag. + weld_activate: CubeID | None = None + weld_deactivate: CubeID | None = None + + # Generic attachment welds (body ↔ body), addressed by the weld's MJCF + # name. Used when the two bodies are already in their desired relative + # pose (cable connector seated in a port, server slotted in a rack, arm + # closing on an already-positioned connector). `attach_activate` freezes + # the current relative pose; `attach_deactivate` clears the flag. + # + # Scenes with a closed set of attachment welds should define a scene-local + # `StrEnum` (e.g. `class AttachmentWeldName(StrEnum): ...`) and pass its + # members here — `StrEnum` values are `str` subclasses, so the tuple type + # below accepts them while downstream code still benefits from enum-level + # typo protection at the call site. + attach_activate: tuple[str, ...] = () + attach_deactivate: tuple[str, ...] = () + + # Same as `attach_activate` but each entry pairs a weld name with an + # explicit target world pose `((x, y, z), (qw, qx, qy, qz))`. The + # weld activates such that body_b lands at exactly that pose, + # *regardless of where it currently is* — used for deterministic + # placements (server-on-shelf, new-server-in-rack) where capturing + # the runtime position would propagate arm-tracking offsets into + # the final object pose. + attach_activate_at: ( + tuple[ + tuple[ + str, + tuple[float, float, float], + tuple[float, float, float, float], + ], + ..., + ] + | None + ) = None + + # Scene-owned actuator targets (e.g. a lift prismatic joint). Keys are + # actuator names the scene declares via AUX_ACTUATOR_NAMES; values are + # target ctrl values. Runner interpolates to these alongside arm joints. + # Same StrEnum-as-str convention as attach_activate: scenes may pass + # enum members and still satisfy the `str` key type. + aux_ctrl: dict[str, float] | None = None + + def __post_init__(self) -> None: + # arm_q is the one field the type system can't fully express (shape + # invariant at runtime), so we keep this check. `gripper` is handled + # by Literal at type-check time; `weld_*` is bounds-checked by + # `make_cube_id`. + self.arm_q = np.asarray(self.arm_q, dtype=float) + if self.arm_q.shape != (6,): + raise ValueError(f"arm_q must be length 6, got shape {self.arm_q.shape}") diff --git a/experiments/bimanual_sim/scene_check.py b/experiments/bimanual_sim/scene_check.py new file mode 100644 index 0000000..4107ffe --- /dev/null +++ b/experiments/bimanual_sim/scene_check.py @@ -0,0 +1,534 @@ +"""Compile-time sanity checks and schematic printer for MJCF scenes. + +`check_scene` runs a pass of cheap invariants right after the scene is +compiled and `apply_initial_state` has been called, before any physics or +viser startup. Every geometry bug surfaces as a structured +`SceneCheckViolation` with a human-readable detail string; violations are +aggregated and raised together via `SceneCheckError` so the dev sees the +full list, not just the first failure. + +`print_schematic` writes a human-readable tree of bodies, world positions, +axis-aligned bounding boxes, and flagged overlaps — the `--inspect` CLI +mode hits this to eyeball scene layout without running physics. + +The module is intentionally scene-agnostic: inputs are a compiled +`mj_forward`-ready `MjModel` / `MjData` pair plus a few scene-declared +descriptors (grippable names, allow-listed overlapping geom pairs, the +attachment-constraint registry). Scene modules (`scenes/data_center.py`) +expose these via attributes the runner reads with `getattr`. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal + +import mujoco +import numpy as np + +from arm_handles import ArmHandles, ArmSide + +ViolationKind = Literal[ + "static_overlap", + "tcp_clip", + "unreachable", + "missing_body", + "eq_type_mismatch", + "connect_anchor_oob", + "cable_short", +] + + +@dataclass(frozen=True) +class SceneCheckViolation: + kind: ViolationKind + detail: str + + +class SceneCheckError(RuntimeError): + """Raised when `check_scene` finds one or more violations. + + Carries the structured `violations` list so callers can inspect the + individual failures; `str(err)` renders the whole sorted list. + """ + + def __init__(self, violations: list[SceneCheckViolation]) -> None: + self.violations = violations + lines = [f"scene_check found {len(violations)} violation(s):"] + for v in violations: + lines.append(f" [{v.kind}] {v.detail}") + super().__init__("\n".join(lines)) + + +# ----------------------------------------------------------------------------- +# Attachment-constraint descriptor (scene-agnostic) +# ----------------------------------------------------------------------------- +# Scenes with mjEQ_WELD / mjEQ_CONNECT equalities can expose a tuple of these +# so `check_scene` can validate each entry (body refs resolve, compiled eq +# type matches, CONNECT anchors lie inside body_a). The scene's own +# `_AttachmentWeldSpec` maps trivially to this shape; the runner adapts. + + +@dataclass(frozen=True) +class AttachmentConstraint: + name: str + body_a: str + body_b: str + kind: Literal["weld", "connect"] + connect_anchor_in_a: tuple[float, float, float] = (0.0, 0.0, 0.0) + + +# ----------------------------------------------------------------------------- +# AABB helpers +# ----------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class _GeomBox: + """Tight-as-cheap world-space AABB for one geom.""" + + geom_id: int + name: str + body_id: int + body_name: str + min_xyz: np.ndarray # shape (3,) + max_xyz: np.ndarray # shape (3,) + + +def _geom_world_aabb(model: mujoco.MjModel, data: mujoco.MjData, geom_id: int) -> _GeomBox: + """Compute a conservative world-space AABB for one geom. + + Primitives (box/sphere/cylinder/capsule/ellipsoid) use `model.geom_size` + as the half-extent along each local axis; we project those to world via + `data.geom_xmat`, so the box is axis-aligned in world even after the + geom has been rotated. Meshes fall back to `geom_rbound` (a conservative + sphere bound) — loose but correct, and our scenes have at most one mesh. + """ + xpos = np.asarray(data.geom_xpos[geom_id], dtype=float) + xmat = np.asarray(data.geom_xmat[geom_id], dtype=float).reshape(3, 3) + gtype = int(model.geom_type[geom_id]) + size = np.asarray(model.geom_size[geom_id], dtype=float) + + if gtype == mujoco.mjtGeom.mjGEOM_MESH: + # rbound is the radius of the enclosing sphere — conservative AABB. + r = float(model.geom_rbound[geom_id]) + half_world = np.array([r, r, r]) + else: + # For primitives, half-extents along local axes; for capsule/cylinder + # the full half-length is size[1] along local z (extended from base + # radius size[0]). size already carries the right per-axis half for + # box/ellipsoid; for sphere/cylinder/capsule we use max(size) as a + # cheap conservative bound in any orientation. + if gtype == mujoco.mjtGeom.mjGEOM_BOX or gtype == mujoco.mjtGeom.mjGEOM_ELLIPSOID: + local_half = size.copy() + elif gtype == mujoco.mjtGeom.mjGEOM_SPHERE: + r = float(size[0]) + local_half = np.array([r, r, r]) + elif gtype == mujoco.mjtGeom.mjGEOM_CYLINDER or gtype == mujoco.mjtGeom.mjGEOM_CAPSULE: + r = float(size[0]) + half_len = float(size[1]) + local_half = np.array([r, r, half_len + r]) + else: # PLANE or other — treat as a point (harmless in overlap checks) + local_half = np.zeros(3) + # Project the local half-extents onto world axes via |xmat| · local_half. + # This gives the axis-aligned world bound of the (rotated) local box. + half_world = np.abs(xmat) @ local_half + + name = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_GEOM, geom_id) or f"g{geom_id}" + body_id = int(model.geom_bodyid[geom_id]) + body_name = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_BODY, body_id) or f"b{body_id}" + return _GeomBox( + geom_id=geom_id, + name=name, + body_id=body_id, + body_name=body_name, + min_xyz=xpos - half_world, + max_xyz=xpos + half_world, + ) + + +def _aabb_overlap(a: _GeomBox, b: _GeomBox, eps: float = 1e-6) -> bool: + """True iff a's and b's axis-aligned boxes intersect (strict, with eps).""" + return bool(np.all(a.min_xyz < b.max_xyz - eps) and np.all(b.min_xyz < a.max_xyz - eps)) + + +def _point_in_aabb(p: np.ndarray, box: _GeomBox, eps: float = 1e-6) -> bool: + return bool(np.all(p > box.min_xyz + eps) and np.all(p < box.max_xyz - eps)) + + +def _body_is_static(model: mujoco.MjModel, body_id: int) -> bool: + """A body is static iff its weld-equivalence root is the worldbody.""" + return int(model.body_weldid[body_id]) == 0 + + +def _collect_static_geom_boxes(model: mujoco.MjModel, data: mujoco.MjData) -> list[_GeomBox]: + boxes: list[_GeomBox] = [] + for g in range(model.ngeom): + body_id = int(model.geom_bodyid[g]) + if not _body_is_static(model, body_id): + continue + if int(model.geom_type[g]) == mujoco.mjtGeom.mjGEOM_PLANE: + continue # planes have infinite extent; skip + boxes.append(_geom_world_aabb(model, data, g)) + return boxes + + +def _body_local_aabb(model: mujoco.MjModel, body_id: int) -> tuple[np.ndarray, np.ndarray]: + """Union of all child-geom AABBs in `body_id`'s local frame. + + Used by the CONNECT anchor check — the anchor is given in body_a's local + frame and should fall inside the physical extent of body_a's geoms. An + anchor outside this local AABB pins body_b's origin to empty space. + """ + mins = np.full(3, np.inf) + maxs = np.full(3, -np.inf) + for g in range(model.ngeom): + if int(model.geom_bodyid[g]) != body_id: + continue + gtype = int(model.geom_type[g]) + if gtype == mujoco.mjtGeom.mjGEOM_PLANE: + continue + pos = np.asarray(model.geom_pos[g], dtype=float) + size = np.asarray(model.geom_size[g], dtype=float) + if gtype == mujoco.mjtGeom.mjGEOM_BOX or gtype == mujoco.mjtGeom.mjGEOM_ELLIPSOID: + half = size.copy() + elif gtype == mujoco.mjtGeom.mjGEOM_SPHERE: + r = float(size[0]) + half = np.array([r, r, r]) + elif gtype == mujoco.mjtGeom.mjGEOM_CYLINDER or gtype == mujoco.mjtGeom.mjGEOM_CAPSULE: + r = float(size[0]) + hl = float(size[1]) + half = np.array([r, r, hl + r]) + elif gtype == mujoco.mjtGeom.mjGEOM_MESH: + r = float(model.geom_rbound[g]) + half = np.array([r, r, r]) + else: + half = np.zeros(3) + mins = np.minimum(mins, pos - half) + maxs = np.maximum(maxs, pos + half) + if not np.all(np.isfinite(mins)): + # Body carries no geoms (TIAGo links often have none); return a tiny + # box at origin so the anchor check becomes trivially "anchor at origin + # is fine, anywhere else is out of bounds" — intentional: connect + # anchors should live on a body with actual geometry. + return np.zeros(3), np.zeros(3) + return mins, maxs + + +# ----------------------------------------------------------------------------- +# Reach-envelope pre-filter +# ----------------------------------------------------------------------------- + +_PIPER_REACH_RADIUS_M = 0.75 +"""Loose reach pre-filter. A Piper's strict reach envelope is ~0.55-0.65 m to +the gripper centre, but grippable bodies are measured to their body *centre* +while the arm actually reaches for a handle offset from that centre — so a +grippable with centre at 0.67 m might still be reachable via a handle at +0.60 m. The real unreachable-target guard is `_snap_factory`'s 2 cm IK- +residual abort; this radius just catches obviously-misplaced bodies (rack +sitting 2 m away) before we bother with IK at all.""" + + +def _arm_base_world_pos( + model: mujoco.MjModel, data: mujoco.MjData, side: ArmSide +) -> np.ndarray | None: + """World position of `{side}base_link`, or None if the body isn't present.""" + bid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, f"{side}base_link") + if bid < 0: + return None + return np.asarray(data.xpos[bid], dtype=float) + + +# ----------------------------------------------------------------------------- +# Entry points +# ----------------------------------------------------------------------------- + + +@dataclass +class _CheckContext: + """Things we compute once and reuse across multiple checks.""" + + static_boxes: list[_GeomBox] = field(default_factory=list) + + +def check_scene( + model: mujoco.MjModel, + data: mujoco.MjData, + *, + arms: dict[ArmSide, ArmHandles], + grippable_names: tuple[str, ...] = (), + allowed_static_overlaps: tuple[tuple[str, str], ...] = (), + attachment_constraints: tuple[AttachmentConstraint, ...] = (), +) -> None: + """Run every invariant; raise `SceneCheckError` if any failed. + + Intended to be called once from the runner after `apply_initial_state` + and before `make_task_plan` / viser. All inputs are already resolved by + the runner — no scene-specific logic lives in this module. + """ + violations: list[SceneCheckViolation] = [] + ctx = _CheckContext(static_boxes=_collect_static_geom_boxes(model, data)) + + violations.extend(_check_static_overlaps(ctx, allowed_static_overlaps)) + violations.extend(_check_tcp_not_in_static_geom(data, arms, ctx)) + violations.extend(_check_grippables_reachable(model, data, grippable_names, arms)) + violations.extend(_check_attachment_constraints(model, data, attachment_constraints)) + + if violations: + raise SceneCheckError(violations) + + +def _check_static_overlaps( + ctx: _CheckContext, + allowed: tuple[tuple[str, str], ...], +) -> list[SceneCheckViolation]: + # Canonicalise allow-list pairs so (a, b) and (b, a) both match. + allow_set: set[tuple[str, str]] = set() + for a, b in allowed: + allow_set.add((a, b)) + allow_set.add((b, a)) + + out: list[SceneCheckViolation] = [] + boxes = ctx.static_boxes + for i, a in enumerate(boxes): + for b in boxes[i + 1 :]: + # Skip pairs within the same body — the body's own geoms are + # allowed to share edges by design (rack panels meet at seams). + if a.body_id == b.body_id: + continue + if not _aabb_overlap(a, b): + continue + if (a.name, b.name) in allow_set or (a.body_name, b.body_name) in allow_set: + continue + out.append( + SceneCheckViolation( + kind="static_overlap", + detail=( + f"{a.name!r} (body {a.body_name!r}) overlaps " + f"{b.name!r} (body {b.body_name!r}); " + f"add ({a.name!r}, {b.name!r}) or " + f"({a.body_name!r}, {b.body_name!r}) to " + f"ALLOWED_STATIC_OVERLAPS if intentional" + ), + ) + ) + return out + + +def _check_tcp_not_in_static_geom( + data: mujoco.MjData, + arms: dict[ArmSide, ArmHandles], + ctx: _CheckContext, +) -> list[SceneCheckViolation]: + out: list[SceneCheckViolation] = [] + for side, arm in arms.items(): + tcp = np.asarray(data.site_xpos[arm.tcp_site_id], dtype=float) + for box in ctx.static_boxes: + if _point_in_aabb(tcp, box): + out.append( + SceneCheckViolation( + kind="tcp_clip", + detail=( + f"{side}tcp at ({tcp[0]:.3f}, {tcp[1]:.3f}, " + f"{tcp[2]:.3f}) is inside static geom {box.name!r} " + f"(body {box.body_name!r})" + ), + ) + ) + return out + + +def _check_grippables_reachable( + model: mujoco.MjModel, + data: mujoco.MjData, + grippable_names: tuple[str, ...], + arms: dict[ArmSide, ArmHandles], +) -> list[SceneCheckViolation]: + out: list[SceneCheckViolation] = [] + arm_bases: list[tuple[ArmSide, np.ndarray]] = [] + for side in arms: + bp = _arm_base_world_pos(model, data, side) + if bp is not None: + arm_bases.append((side, bp)) + if not arm_bases: + return out # no arms declared — nothing to reach-check + + for name in grippable_names: + bid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, name) + if bid < 0: + out.append( + SceneCheckViolation( + kind="missing_body", + detail=f"grippable {name!r} not found in compiled model", + ) + ) + continue + gpos = np.asarray(data.xpos[bid], dtype=float) + dists = [(side, float(np.linalg.norm(gpos - bp))) for side, bp in arm_bases] + if all(d > _PIPER_REACH_RADIUS_M for _, d in dists): + reach_str = ", ".join(f"{s} {d:.2f} m" for s, d in dists) + out.append( + SceneCheckViolation( + kind="unreachable", + detail=( + f"grippable {name!r} at ({gpos[0]:.3f}, {gpos[1]:.3f}, " + f"{gpos[2]:.3f}) is farther than {_PIPER_REACH_RADIUS_M} m " + f"from every arm base ({reach_str})" + ), + ) + ) + return out + + +def _check_attachment_constraints( + model: mujoco.MjModel, + data: mujoco.MjData, + constraints: tuple[AttachmentConstraint, ...], +) -> list[SceneCheckViolation]: + out: list[SceneCheckViolation] = [] + for c in constraints: + eq_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_EQUALITY, c.name) + if eq_id < 0: + out.append( + SceneCheckViolation( + kind="missing_body", + detail=f"attachment equality {c.name!r} not found in compiled model", + ) + ) + continue + body_a_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, c.body_a) + body_b_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, c.body_b) + if body_a_id < 0: + out.append( + SceneCheckViolation( + kind="missing_body", + detail=f"attachment {c.name!r} body_a={c.body_a!r} not found", + ) + ) + if body_b_id < 0: + out.append( + SceneCheckViolation( + kind="missing_body", + detail=f"attachment {c.name!r} body_b={c.body_b!r} not found", + ) + ) + # Verify compiled equality type matches the declared kind. + compiled_type = int(model.eq_type[eq_id]) + expected = mujoco.mjtEq.mjEQ_WELD if c.kind == "weld" else mujoco.mjtEq.mjEQ_CONNECT + if compiled_type != int(expected): + out.append( + SceneCheckViolation( + kind="eq_type_mismatch", + detail=( + f"attachment {c.name!r} declared kind={c.kind!r} " + f"but compiled eq type={compiled_type} " + f"(expected {int(expected)})" + ), + ) + ) + continue + # For CONNECT entries, the anchor must lie inside body_a's local AABB. + if c.kind == "connect" and body_a_id >= 0: + mins, maxs = _body_local_aabb(model, body_a_id) + ax = np.asarray(c.connect_anchor_in_a, dtype=float) + # Use <= / >= rather than strict to accept boundary anchors (ports + # sit exactly on the server's front face by design). + if not bool(np.all(ax >= mins - 1e-6) and np.all(ax <= maxs + 1e-6)): + out.append( + SceneCheckViolation( + kind="connect_anchor_oob", + detail=( + f"attachment {c.name!r} anchor ({ax[0]:.3f}, " + f"{ax[1]:.3f}, {ax[2]:.3f}) in body_a={c.body_a!r} " + f"local frame is outside body AABB " + f"[{mins[0]:.3f},{maxs[0]:.3f}] × " + f"[{mins[1]:.3f},{maxs[1]:.3f}] × " + f"[{mins[2]:.3f},{maxs[2]:.3f}]" + ), + ) + ) + return out + + +# ----------------------------------------------------------------------------- +# Schematic printer (--inspect) +# ----------------------------------------------------------------------------- + + +def print_schematic( + model: mujoco.MjModel, + data: mujoco.MjData, + *, + arms: dict[ArmSide, ArmHandles], + grippable_names: tuple[str, ...] = (), + attachment_constraints: tuple[AttachmentConstraint, ...] = (), +) -> None: + """Print a body/geom/attachment schematic to stdout. Never raises; meant + to be paired with `check_scene` (which does) on the `--inspect` path.""" + print( + f"Scene: nbody={model.nbody} njnt={model.njnt} nu={model.nu} " + f"neq={model.neq} ngeom={model.ngeom}" + ) + static_boxes = _collect_static_geom_boxes(model, data) + print() + print(f"Static bodies (weldid=0): {len(static_boxes)} geoms") + # Group by body for readability. + by_body: dict[int, list[_GeomBox]] = {} + for b in static_boxes: + by_body.setdefault(b.body_id, []).append(b) + for body_id in sorted(by_body): + name = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_BODY, body_id) or f"b{body_id}" + body_pos = np.asarray(data.xpos[body_id], dtype=float) + geoms = by_body[body_id] + body_min = np.min([g.min_xyz for g in geoms], axis=0) + body_max = np.max([g.max_xyz for g in geoms], axis=0) + print( + f" {name:<22} pos=({body_pos[0]:+.3f}, {body_pos[1]:+.3f}, {body_pos[2]:+.3f})" + f" AABB=[{body_min[0]:+.3f},{body_max[0]:+.3f}]" + f"×[{body_min[1]:+.3f},{body_max[1]:+.3f}]" + f"×[{body_min[2]:+.3f},{body_max[2]:+.3f}]" + f" geoms={len(geoms)}" + ) + + print() + print("Arms at home pose:") + for side, arm in arms.items(): + base = _arm_base_world_pos(model, data, side) + tcp = np.asarray(data.site_xpos[arm.tcp_site_id], dtype=float) + if base is not None: + print( + f" [{side}] base=({base[0]:+.3f}, {base[1]:+.3f}, {base[2]:+.3f})" + f" tcp=({tcp[0]:+.3f}, {tcp[1]:+.3f}, {tcp[2]:+.3f})" + ) + + if grippable_names: + print() + print(f"Grippables ({len(grippable_names)}):") + for name in grippable_names: + bid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, name) + if bid < 0: + print(f" {name:<22} ⚠ MISSING") + continue + gpos = np.asarray(data.xpos[bid], dtype=float) + dists = [] + for side in arms: + bp = _arm_base_world_pos(model, data, side) + if bp is not None: + dists.append(f"{side}{np.linalg.norm(gpos - bp):.2f}m") + print( + f" {name:<22} pos=({gpos[0]:+.3f}, {gpos[1]:+.3f}, {gpos[2]:+.3f})" + f" reach=[{', '.join(dists)}]" + ) + + if attachment_constraints: + print() + weld_count = sum(1 for c in attachment_constraints if c.kind == "weld") + connect_count = sum(1 for c in attachment_constraints if c.kind == "connect") + print( + f"Attachment constraints ({len(attachment_constraints)}): " + f"{connect_count}× CONNECT, {weld_count}× WELD" + ) + for c in attachment_constraints: + eq_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_EQUALITY, c.name) + status = "✓" if eq_id >= 0 else "✗ MISSING" + print(f" {c.name:<28} {c.kind.upper():<8} {c.body_a!r} ↔ {c.body_b!r} {status}") diff --git a/experiments/bimanual_sim/scenes/__init__.py b/experiments/bimanual_sim/scenes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/experiments/bimanual_sim/scenes/data_center.py b/experiments/bimanual_sim/scenes/data_center.py new file mode 100644 index 0000000..111bcee --- /dev/null +++ b/experiments/bimanual_sim/scenes/data_center.py @@ -0,0 +1,1557 @@ +"""Data-center server-swap scene. + +A bimanual Piper pair rides on a lift carriage attached to a (static) mobile +base parked in front of a rack. The task plan runs through a full server swap: + + home -> lift to cables + -> unplug cable 1 (left arm) + -> unplug cable 2 (right arm) + -> unplug cable 3 (left arm) + -> both arms grab server handle rails, pull server out of rack + -> stow server in lower compartment (old_bin) + -> both arms grab replacement from upper compartment (new_bin) + -> slide new server into rack + -> replug cables 1, 2, 3 + -> retract lift, home both arms + +Novel in this scene compared to sink_bimanual: + * Pipers attached inside a nested body (lift_carriage) so a slide joint + raises/lowers arms + onboard compartments together. + * Scene-owned actuator ("lift") controlled via Step.aux_ctrl; IK freezes it + via `locked_joint_names=("lift",)` so the solver uses only the arm chain. + * Cable objects: 7-segment ball-jointed chains anchored to the rack, + terminating in a grippable connector block. Grasp the head to pull out. + * Three port shapes + colors (power/net/fiber) for visual distinction. + * Three cameras (top + 2 wrist) declared in MJCF for future EGL capture. + +Type-safety conventions: + * Attachment welds live in one registry (`ATTACHMENTS`) iterated by + `build_spec`, `_resolve_scene_ids`, and `apply_initial_state` — no more + three-way duplication of the same name list with the same active flags. + * Weld names are `AttachmentWeldName` (StrEnum) so typos are caught by ty. + * Aux actuator keys are `DataCenterAux` (StrEnum) for the same reason. + * Grippable objects are addressed by `CubeID` constructed through + `grippable_id("cable1_connector")` — the bounds check and the name-to-index + translation happen in one place, instead of scattered `_index_of(f"...")` + string-building at every call site. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import StrEnum + +import mujoco +import numpy as np + +from arm_handles import ArmHandles, ArmSide +from cameras import CameraRole +from ik import PositionOnly, solve_ik +from paths import D405_MESH_STL, D435I_XML +from robots.piper import attach_piper +from robots.tiago import load_tiago +from scene_base import CubeID, Position3, Step, make_cube_id +from scenes.data_center_layout import HOME_ARM_Q, IK_SEED_Q, LAYOUT +from welds import activate_attachment_weld + +NAME = "data_center" +ARM_PREFIXES: tuple[ArmSide, ...] = (ArmSide.LEFT, ArmSide.RIGHT) +# Grippable objects addressable via Step.weld_activate / weld_deactivate. +# Index order matters: the runner uses it as an int index into this list. +GRIPPABLES: tuple[str, ...] = ( + "cable1_connector", + "cable2_connector", + "cable3_connector", + "server", + "new_server", +) +N_CUBES = len(GRIPPABLES) # runner uses this to size ArmHandles.weld_ids + + +class DataCenterAux(StrEnum): + """Scene-owned (non-arm) actuators addressable via Step.aux_ctrl. + + `LIFT` maps directly to PAL TIAGo's upstream `torso_lift_joint` name so the + scene's aux-ctrl dispatch reaches the joint Menagerie already declares in + tiago.xml (we add only a position actuator on top). + """ + + LIFT = "torso_lift_joint" + + +AUX_ACTUATOR_NAMES: tuple[str, ...] = tuple(m.value for m in DataCenterAux) + +CAMERAS: tuple[tuple[str, CameraRole], ...] = ( + # Realsense D435i mounted on top of the torso lift, pointing forward-down. + ("top_d435i_cam", CameraRole.TOP), + # Realsense D405 on each arm's link6, along the gripper axis. + ("left_wrist_d405_cam", CameraRole.WRIST), + ("right_wrist_d405_cam", CameraRole.WRIST), +) + + +# ----------------------------------------------------------------------------- +# Scene dimensions +# ----------------------------------------------------------------------------- +# Every dimension, offset, and derived anchor lives in `DataCenterLayout` +# (scenes/data_center_layout.py). `LAYOUT` is the module-level default +# instance; read positions via `LAYOUT.rack.front_face_x`, +# `LAYOUT.server_world_pos_in_rack`, `LAYOUT.port_world_pos(i)`, +# `LAYOUT.cable_anchor_world(i)`, etc. `HOME_ARM_Q` / `IK_SEED_Q` are +# re-exported from that module for call-site convenience. +# +# World frame: origin at the robot's base centre on the floor, +x toward +# rack, +y to the robot's right, +z up. + + +# ----------------------------------------------------------------------------- +# Static-overlap allow-list for `scene_check.check_scene` +# ----------------------------------------------------------------------------- +# Pairs of geom or body names whose AABBs intentionally overlap in the +# compiled scene. `check_scene` skips same-body geom pairs automatically; +# this allow-list captures the cross-body overlaps that are by-design +# (panels meeting at cabinet seams, a bracket flush against a side wall). +# Seeded after the first `--inspect` run surfaced the legitimate overlaps. +ALLOWED_STATIC_OVERLAPS: tuple[tuple[str, str], ...] = ( + # Rack panels share cabinet-seam edges where they meet. + ("rack_rear", "rack_side_L"), + ("rack_rear", "rack_side_R"), + ("rack_rear", "rack_top"), + ("rack_rear", "rack_bottom"), + ("rack_side_L", "rack_top"), + ("rack_side_L", "rack_bottom"), + ("rack_side_R", "rack_top"), + ("rack_side_R", "rack_bottom"), + ("rack_top", "rack_bottom"), + # Rack shelves span the whole interior between the surrounding panels. + ("rack_rear", "rack_shelf"), + ("rack_side_L", "rack_shelf"), + ("rack_side_R", "rack_shelf"), + ("rack_rear", "rack_lower_shelf"), + ("rack_side_L", "rack_lower_shelf"), + ("rack_side_R", "rack_lower_shelf"), + # Cable bracket is a decorative bar bolted flush against the +y side. + ("cable_bracket", "rack_side_R"), + # Each cable anchor body sits inside the bracket (that's how the cable + # emerges from the fixture), and the composite's first-segment body + # (`cable{i}_B_first`) is at the anchor origin — both overlap the + # bracket and each other by design. + ("cable_bracket", "cable1_anchor"), + ("cable_bracket", "cable2_anchor"), + ("cable_bracket", "cable3_anchor"), + ("cable_bracket", "cable1_B_first"), + ("cable_bracket", "cable2_B_first"), + ("cable_bracket", "cable3_B_first"), + ("cable1_anchor", "cable1_B_first"), + ("cable2_anchor", "cable2_B_first"), + ("cable3_anchor", "cable3_B_first"), + # The cable composite's first body also crosses the rack side panel + # (the bracket sits outside +y; the first segment extends back into + # the rack toward the port). + ("rack_side_R", "cable1_B_first"), + ("rack_side_R", "cable2_B_first"), + ("rack_side_R", "cable3_B_first"), + # Top camera sits directly on top of the camera pole — the pole-top + # and mesh-bottom AABBs touch by design. The camera-mesh body + # contains ~9 sub-geoms so a body-pair allow covers the lot. + ("world", "top_d435i"), + # TIAGo's base mesh uses a conservative sphere bound in scene_check + # (rbound ≈ 0.30 m at mesh origin z≈0.16), so after pulling the rack + # closer to center_x=0.58 the rbound sphere grazes the rack walls + # even though the actual mesh geometry is 25+ cm away. Body-pair + # allow since the base mesh fuses into the `world` body. + ("world", "rack_side_L"), + ("world", "rack_side_R"), + ("world", "rack_bottom"), + ("world", "rack_lower_shelf"), +) + + +# ----------------------------------------------------------------------------- +# Body-pair equalities — single registry drives build_spec / id resolution / +# reset. Two constraint kinds are used: +# +# "connect" — port↔cable-connector. `mjEQ_CONNECT` pins a point on body1 +# (in body1's local frame) to body2's origin. No orientation constraint, so +# the cable's last body can rotate freely in its socket — which is what a +# real plug does. This replaces the old weld-with-captured-relpose approach +# that forced the cable tip to hold the arbitrary orientation the composite +# happened to have at init (cable running from a side bracket, so tip +x +# pointed along -y world) — the connector geom then sat crooked next to +# the port, not seated in it. User: "the cables aren't plugged into the +# server properly". +# +# "weld" — server↔rack, server↔bin, new_server↔rack. `mjEQ_WELD` with +# relpose captured at activation time (`welds.activate_attachment_weld`) +# so the body stays where it is when the weld flips on rather than +# snapping to body1's origin. +# ----------------------------------------------------------------------------- + + +class AttachmentWeldName(StrEnum): + """Named body-pair equalities used by Step.attach_activate/deactivate. + + The "weld_" prefix is historical — PORT1..3 entries are now `connect` + equalities rather than welds, but renaming would break downstream Step + references with no functional benefit. + """ + + PORT1_OLD = "weld_port1_old" + PORT2_OLD = "weld_port2_old" + PORT3_OLD = "weld_port3_old" + PORT1_NEW = "weld_port1_new" + PORT2_NEW = "weld_port2_new" + PORT3_NEW = "weld_port3_new" + SERVER_IN_RACK = "weld_server_in_rack" + SERVER_ON_LOWER_SHELF = "weld_server_on_lower_shelf" + NEW_IN_BIN = "weld_new_in_bin" + NEW_IN_RACK = "weld_new_in_rack" + + +class ConstraintKind(StrEnum): + """Which MuJoCo equality type backs an entry in `ATTACHMENTS`.""" + + WELD = "weld" # mjEQ_WELD — pins full pose (position + orientation) + CONNECT = "connect" # mjEQ_CONNECT — pins only a point (like a ball joint) + + +@dataclass(frozen=True) +class _AttachmentWeldSpec: + """One scene body-pair equality. The registry below is the single source + of truth for the name, the two bodies, the initial active flag, and the + constraint kind — consumed by `build_spec` (create the equality), + `_resolve_scene_ids` (collect the eq id), and `apply_initial_state` (reset + to the spec default). + """ + + name: AttachmentWeldName + body_a: str + body_b: str + initially_active: bool + kind: ConstraintKind = ConstraintKind.WELD + # Only meaningful when kind == CONNECT: the anchor point in body_a's + # local frame that body_b's origin should be pinned to. For port + # connects this is the port geom's position on the server body. + connect_anchor_in_a: tuple[float, float, float] = (0.0, 0.0, 0.0) + + +# Port anchor in server's local frame. Server and new_server share identical +# port layouts, so both reuse this tuple when a CONNECT equality is built. +# Delegated to `LAYOUT.port_anchor_in_server_frame` so the port geom position +# and the connect anchor stay in lockstep. +def _port_anchor_in_server_frame(port_idx: int) -> tuple[float, float, float]: + return LAYOUT.port_anchor_in_server_frame(port_idx) + + +ATTACHMENTS: tuple[_AttachmentWeldSpec, ...] = ( + # Port connects — body_a is the SERVER (anchor is in server's local + # frame); body_b is the cable connector whose origin gets pinned. + _AttachmentWeldSpec( + AttachmentWeldName.PORT1_OLD, + "server", + "cable1_connector", + True, + kind=ConstraintKind.CONNECT, + connect_anchor_in_a=_port_anchor_in_server_frame(0), + ), + _AttachmentWeldSpec( + AttachmentWeldName.PORT2_OLD, + "server", + "cable2_connector", + True, + kind=ConstraintKind.CONNECT, + connect_anchor_in_a=_port_anchor_in_server_frame(1), + ), + _AttachmentWeldSpec( + AttachmentWeldName.PORT3_OLD, + "server", + "cable3_connector", + True, + kind=ConstraintKind.CONNECT, + connect_anchor_in_a=_port_anchor_in_server_frame(2), + ), + _AttachmentWeldSpec( + AttachmentWeldName.PORT1_NEW, + "new_server", + "cable1_connector", + False, + kind=ConstraintKind.CONNECT, + connect_anchor_in_a=_port_anchor_in_server_frame(0), + ), + _AttachmentWeldSpec( + AttachmentWeldName.PORT2_NEW, + "new_server", + "cable2_connector", + False, + kind=ConstraintKind.CONNECT, + connect_anchor_in_a=_port_anchor_in_server_frame(1), + ), + _AttachmentWeldSpec( + AttachmentWeldName.PORT3_NEW, + "new_server", + "cable3_connector", + False, + kind=ConstraintKind.CONNECT, + connect_anchor_in_a=_port_anchor_in_server_frame(2), + ), + # Full-pose welds — these need orientation pinning so a stowed server + # keeps its rack-aligned orientation and doesn't roll around. + _AttachmentWeldSpec(AttachmentWeldName.SERVER_IN_RACK, "server", "rack_frame", True), + # Old server gets staged on the rack's lower shelf (not a torso bin) — + # the robot's cart only carries the NEW server at init. Weld to + # rack_frame (static) so the stowed old server stays put when the + # robot lifts. + _AttachmentWeldSpec(AttachmentWeldName.SERVER_ON_LOWER_SHELF, "server", "rack_frame", False), + _AttachmentWeldSpec(AttachmentWeldName.NEW_IN_BIN, "new_server", "torso_lift_link", True), + _AttachmentWeldSpec(AttachmentWeldName.NEW_IN_RACK, "new_server", "rack_frame", False), +) + + +# ----------------------------------------------------------------------------- +# Grippable addressing +# ----------------------------------------------------------------------------- + + +def grippable_id(name: str) -> CubeID: + """Resolve a grippable-object name to its bounds-checked CubeID. + + Centralising the name→index translation means the scene never builds + `f"cable{i+1}_connector"` strings next to a bare `_index_of(...)` call; + `grippable_id` is the only place that knows GRIPPABLES is a list. + """ + try: + index = GRIPPABLES.index(name) + except ValueError as exc: + raise KeyError(f"unknown grippable {name!r}; known: {GRIPPABLES}") from exc + return make_cube_id(index, N_CUBES) + + +def grasp_weld(side: ArmSide, cube_id: CubeID) -> str: + """Name of the per-arm grasp weld for a given cube. Matches the + `{prefix}grasp_cube{i}` convention used when the arm handles are built + in `arm_handles.get_arm_handles`.""" + return f"{side}grasp_cube{cube_id}" + + +# ----------------------------------------------------------------------------- +# Spec construction +# ----------------------------------------------------------------------------- + + +def _static_box(parent, name: str, pos, half, rgba): + body = parent.add_body(name=name, pos=list(pos)) + body.add_geom(type=mujoco.mjtGeom.mjGEOM_BOX, size=list(half), rgba=list(rgba)) + return body + + +def _add_bin(parent, name_prefix: str, local_pos) -> None: + """Open shelf — a single floor plate. + + The bin used to be a U of walls (back + 2 sides + floor) but the back + was visually wider than the torso shell (protruded out the robot's + sides) and the side walls sat in the path of the piper shoulder/elbow + on cable-reach poses — compile-time contacts made the arm physically + unable to track its IK-planned q, so TCP settled ~28 cm above the + planned height. Server stow is a weld, not contact-based, so none of + those walls buy physical correctness; only the floor remains for the + visual of a shelf holding a server. + """ + x, y, z = local_pos + bx, by, bz = LAYOUT.bins.half + wall_t = LAYOUT.bins.wall_thickness + rgba = (0.72, 0.72, 0.75, 1.0) + _static_box(parent, f"{name_prefix}_floor", (x, y, z - bz - wall_t), (bx, by, wall_t), rgba) + + +def _quat_align_x_to(direction: np.ndarray) -> np.ndarray: + """Unit wxyz quaternion that rotates +x̂ onto the given direction vector. + + Used to orient the cable composite (which lays bodies along local +x by + default) toward an arbitrary target in the parent frame. + """ + v = np.asarray(direction, dtype=float) + v = v / np.linalg.norm(v) + cross = np.array([0.0, -v[2], v[1]]) # x̂ × v + sin_a = float(np.linalg.norm(cross)) + if sin_a < 1e-9: + # v ≈ ±x̂: identity for +x, 180° about ŷ for -x. + return np.array([1.0, 0.0, 0.0, 0.0]) if v[0] > 0 else np.array([0.0, 0.0, 1.0, 0.0]) + axis = cross / sin_a + half = float(np.arccos(np.clip(v[0], -1.0, 1.0)) / 2.0) + sin_h = float(np.sin(half)) + return np.array([float(np.cos(half)), axis[0] * sin_h, axis[1] * sin_h, axis[2] * sin_h]) + + +def _add_cable(spec: mujoco.MjSpec, rack_body, cable_idx: int, port_y: float, rgba) -> None: + """Build a cable via MuJoCo's native composite + elasticity plugin. + + Previously hand-rolled as a 7-segment ball-jointed capsule chain. The + native `composite type="cable"` with the `mujoco.elasticity.cable` + plugin implements Discrete Elastic Rods — proper bend/twist stiffness + in SI units (Pa) and a maintained first-party plugin rather than hand- + tuned ball damping. `composite type="rope"` was removed in MuJoCo 3.x; + the cable plugin is the canonical replacement. + + The composite tip body inside the sub-spec is renamed to `connector` + before attach so, with prefix `cable{i+1}_`, it resolves as + `cable{i+1}_connector` in the compiled model — matching the name the + grasp welds and `ATTACHMENTS` registry reference. The per-port distinct + connector geom (box / flat rectangle / cylinder) is added to that same + body so it moves with the last segment. + + The attach frame's `quat` rotates the composite's default +x layout to + point from the shared side-mounted bracket toward the port. Per-cable + stagger along rack +x comes from `LAYOUT.cable_anchor_world`. + """ + cables_cfg = LAYOUT.cables + rack = LAYOUT.rack + world_anchor = LAYOUT.cable_anchor_world(cable_idx) + rack_origin = np.array([rack.center_x, 0.0, rack.center_z]) + local_anchor = world_anchor - rack_origin + + # Connector tip needs to land at the port geom's world position so the + # port connect equality anchors the connector seated inside the socket. + world_port = LAYOUT.port_world_pos(list(LAYOUT.ports.y_offsets).index(port_y)) + direction = world_port - world_anchor + direction_len = float(np.linalg.norm(direction)) + # MuJoCo's composite cable with count=N creates N-1 actual bodies spanning + # `size * (N-2)/(N-1)` along its +x axis (the first slot is consumed by + # the parent anchor body, and the last body lands one segment short of + # `size`). Empirically, count=11 → 10 bodies → last body at 0.9 × size. + # To make the tip (B_last) land AT the port, inflate `cable_len` by 10/9. + # Without this, the tip sits 4–5 cm short of the port at init and the + # connect equality has to yank the cable on every reset, which makes + # "plugged in" look like a cable straining toward the socket. + count = cables_cfg.n_seg + 1 + composite_span_fraction = (count - 2) / (count - 1) # 9/10 for count=11 + nominal_cable_len = direction_len / composite_span_fraction + cable_len = min(nominal_cable_len, LAYOUT.cable_max_len) + attach_quat = _quat_align_x_to(direction) + r, g, b, a = rgba + cable_xml = f""" + + + + + + + + + + + + + + + + + + + """ + + cable_spec = mujoco.MjSpec.from_string(cable_xml) + # Rename the composite tip so it resolves as cable{i+1}_connector after + # the attach prefix; ATTACHMENTS + grasp welds reference that exact name. + tip = cable_spec.body("B_last") + tip.name = "connector" + + # Spherical connector head centred on the tip body's origin. A sphere is + # orientation-invariant, so it sits cleanly inside the port socket geom + # regardless of which direction the cable routes in from — no matter + # whether the tip body's +x ends up pointing along world -y (bracket on + # the side), +x (frontal), or anywhere else, the rendered plug stays + # seated. Previously each cable had a directional box/rectangle/cylinder + # offset along tip +x by CONN_LEN/2, which made the plug appear crooked + # next to its port when the cable direction didn't align with the + # server's +x axis. Port visual distinction is still carried by the + # socket geoms on the server (box / flat box / cylinder) + matching + # colour on the sphere. + tip.add_geom( + type=mujoco.mjtGeom.mjGEOM_SPHERE, + pos=[0.0, 0.0, 0.0], + size=[0.014], + rgba=list(rgba), + mass=0.02, + ) + + attach_frame = rack_body.add_frame(pos=list(local_anchor), quat=list(attach_quat)) + spec.attach(cable_spec, prefix=f"cable{cable_idx + 1}_", frame=attach_frame) + + +def build_spec() -> mujoco.MjSpec: + # Start from PAL TIAGo (wheels + torso_lift_link). `robots.tiago.load_tiago` + # strips the upstream single arm + head, removes the `reference` freejoint + # (required for mink IK — see that module's docstring), and prunes + # now-orphan contact excludes. All customizations are declared in its + # `TiagoConfig`; defaults are what the data-center scene needs. + spec = load_tiago() + spec.option.integrator = mujoco.mjtIntegrator.mjINT_IMPLICITFAST + spec.option.cone = mujoco.mjtCone.mjCONE_ELLIPTIC + spec.option.impratio = 10.0 + spec.option.timestep = 0.002 + # Puppet mode: gravity = 0. The arms are qpos-driven (no PD tracking, + # no need for gravity to load the actuators), and the freejoint + # bodies (servers, cable connectors) are always pinned by some weld + # — gravity would just be solver noise. Removing it eliminates the + # entire class of "server drifted while a weld was being captured" + # bugs and lets the cable composite settle to its rest pose without + # arm-yank instability. + spec.option.gravity = [0.0, 0.0, 0.0] + + # Default class for visual-only geoms: no contact, zero mass. Geoms that + # pass `default=visual` inherit these flags, so we don't repeat the + # contype=0 / conaffinity=0 / mass=0.0 trio at every call site (shoulder + # plate, camera pole, D405 wrist meshes, rack panels). Using MJCF's + # native `` mechanism via MjSpec instead of a Python-level kwargs + # shim keeps the defaults visible in `spec.to_xml()` output for anyone + # inspecting the compiled model. `add_geom(default=...)` is the MjSpec + # Python binding for ``. + visual = spec.add_default("visual", spec.default) + visual.geom.contype = 0 + visual.geom.conaffinity = 0 + visual.geom.mass = 0.0 + + wb = spec.worldbody + + # Floor (TIAGo's upstream MJCF has no floor — the per-variant scene.xml adds it) + wb.add_geom( + type=mujoco.mjtGeom.mjGEOM_PLANE, + size=[5.0, 5.0, 0.1], + rgba=[0.78, 0.78, 0.80, 1.0], + ) + + # Reference to the moving lift body — everything arm-mount-and-bin-related + # hangs off this so it rides the torso slide joint. + torso = spec.body("torso_lift_link") + + # ---------------------- Onboard bin (carries new_server) ---------------- + # One compartment on the torso, holding the new_server at init. Old + # server gets staged on the rack's lower shelf — not another torso bin + # — so the robot stays visually simpler and the demo's "robot brings + # the new server, leaves the old on the rack" story is readable at a + # glance. + _add_bin(torso, "new_bin", (LAYOUT.bins.local_x, 0.0, LAYOUT.bins.new_local_z)) + + # ---------------------- Piper arms on the torso -------------------------- + # Arms sit: + # - well forward of the torso column so the compact home pose keeps TCP + # clear of the rack front face at startup and the arm base is close + # enough to the rack that cable/server targets stay inside Piper's + # ~0.55 m reach envelope + # - outboard of the bins (|y| > bin half-width) so the piper base link + # doesn't intersect the bin side walls + # - below torso origin so TCP ends up at rack-slot height and the arms + # visibly occupy the space BETWEEN the chest and waist bins + # All three offsets live in `LAYOUT.arm_mount`. + arm_mount = LAYOUT.arm_mount + lf = torso.add_frame( + pos=[arm_mount.x, -arm_mount.y_abs, arm_mount.z], quat=[1.0, 0.0, 0.0, 0.0] + ) + attach_piper(spec, prefix=ArmSide.LEFT, frame=lf) + rf = torso.add_frame(pos=[arm_mount.x, arm_mount.y_abs, arm_mount.z], quat=[1.0, 0.0, 0.0, 0.0]) + attach_piper(spec, prefix=ArmSide.RIGHT, frame=rf) + + # Torso cladding — one chunky visual box enclosing the piper base links + # and the back of the two bins. Depth is fixed ±0.10 m around the torso + # column (20 cm front-to-back) so it reads as a proportionate humanoid + # torso rather than ballooning whenever arm_mount.x moves; width is + # 6 cm inboard of arm_mount.y_abs so the piper base is still visible + # on the outside of the cladding (arms stick out of the side). + # Vertical extent spans the bin bottoms and tops so the bins look + # integrated with the body rather than floating above/below it. + _clad_x_min, _clad_x_max = -0.10, 0.10 + # Cladding top tracks the (only) bin's top; bottom is a fixed torso- + # local offset below the arm mount so the cladding reads as "torso + # reaches all the way down to the wheel platform" rather than dangling + # above empty space once we dropped the old_bin. + _clad_z_min = arm_mount.z - 0.20 + _clad_z_max = LAYOUT.bins.new_local_z + LAYOUT.bins.half[2] + torso.add_geom( + default=visual, + name="torso_cladding", + type=mujoco.mjtGeom.mjGEOM_BOX, + pos=[ + (_clad_x_min + _clad_x_max) * 0.5, + 0.0, + (_clad_z_min + _clad_z_max) * 0.5, + ], + size=[ + (_clad_x_max - _clad_x_min) * 0.5, + arm_mount.y_abs - 0.06, + (_clad_z_max - _clad_z_min) * 0.5, + ], + rgba=[0.78, 0.78, 0.82, 1.0], + ) + + # TCP sites on each arm's link6 (required by ik.solve_ik / welds). + for side in ARM_PREFIXES: + link6 = spec.body(f"{side}link6") + link6.add_site(name=f"{side}tcp", pos=[0.0, 0.0, 0.14], size=[0.006, 0.006, 0.006]) + + # ---------------------- Top camera pole (static, above the arms) -------- + # TIAGo's head is stripped in `load_tiago_without_arm` (user asked to + # remove it — we don't actuate it, and the head geoms were competing with + # the rack for vertical space). We replace it with a tall static pole + # rigidly welded to `base_link` (not the moving `torso_lift_link`), so + # the top camera view stays stable regardless of the torso lift qpos. + # The D435i mounts at the top and points at `rack_frame` via TARGETBODY, + # so the optical axis always aims at the rack irrespective of how the + # base is parked. + # + # `TOP_POLE_X = -0.20` places the pole behind the onboard bins (which + # span world x ∈ [-0.012, 0.388] via their torso-local +x mount). Running + # the pole forward of that would visibly clip through the new/old_bin + # compartments and through whichever server they hold (user report: "the + # tall stand... should be moved further back into the robotic body so + # that it doesn't clip into the server compartments"). + TOP_POLE_X = -0.20 + base = spec.body("base_link") + # Pole top at z = 1.48 (half = 0.74, pos = 0.74, so span 0 → 1.48). + # Camera body attaches at z = 1.485 with the 90°-x rotation below + # making the mesh ~2.6 cm tall vertically — so mesh-bottom (~1.472) + # sits ~0.8 cm *inside* the pole top, reading as "camera mounted on + # the pole" rather than floating above a gap. The overlap is + # allowlisted (see ALLOWED_STATIC_OVERLAPS). + base.add_geom( + default=visual, + name="top_camera_pole", + type=mujoco.mjtGeom.mjGEOM_BOX, + pos=[TOP_POLE_X, 0.0, 0.74], + size=[0.025, 0.025, 0.74], + rgba=[0.32, 0.32, 0.35, 1.0], + ) + # D435i's native mesh frame has local +z as the long (~9.2 cm) camera- + # body axis and local +x as the lens direction. At identity orientation + # the camera stood on end like a pencil — visually wrong. 90° about +x + # (quat = [cos 45°, sin 45°, 0, 0]) rotates local +z → world -y, so the + # long axis is horizontal; +x (lens) stays pointing world +x (at the + # rack, which is at world +x from the pole). After rotation the mesh + # is ~2.6 cm tall vertically, so attach z = 1.485 puts the mesh bottom + # flush with the pole top instead of floating 3 cm above it. + top_cam_frame = base.add_frame( + pos=[TOP_POLE_X, 0.0, 1.485], + quat=[0.7071067811865476, 0.7071067811865476, 0.0, 0.0], + ) + spec.attach( + mujoco.MjSpec.from_file(str(D435I_XML)), + prefix="top_", + frame=top_cam_frame, + ) + spec.body("top_d435i").add_camera( + name="top_d435i_cam", + pos=[0.0, 0.0, 0.0], + mode=mujoco.mjtCamLight.mjCAMLIGHT_TARGETBODY, + targetbody="rack_frame", + fovy=69.0, # D435i colour-sensor horizontal FOV ≈ 69° + ) + + # ---------------------- Wrist cameras: Realsense D405 -------------------- + # Load the D405 mesh once as a shared asset; reference it on each link6. + # Camera uses FIXED mode with a 180°-about-x quat so its optical axis + # (MuJoCo cam -z) aligns with link6's +z (the tool axis — TCP sits along + # link6 +z at [0, 0, 0.14]). Switched away from TARGETBODY targetting + # `link8`: link8 is one finger (asymmetric w.r.t. link6 +z), so both + # wrist cams pointed the same way in world regardless of side — not + # useful for a bimanual scene. FIXED along +z is symmetric and always + # aimed at the grasp point. + spec.add_mesh(name="d405", file=str(D405_MESH_STL)) + # Wrist D405: mesh was 8 cm along link6 +z with a 180°-about-x quat on + # the geom. That put the mesh forward of the gripper fingertips and + # flipped its orientation (user report: "wrist cameras floating out + # of the grippers"). The D405 STL's frame has +z as the back mount + # face and +y as the lens axis, so placing the mesh at the link6 + # body's outer surface (z ≈ 0.03) with identity orientation keeps + # the mount side against link6 and the lens pointing sideways, which + # is how the real wrist cam sits on an Aloha-style Piper. + # + # The CAMERA sensor stays at the TCP site's axis with a 180°-x quat + # — MuJoCo cam -z maps to link6 +z so the sensor looks *out* along + # the gripper axis toward whatever the arm is reaching for, which is + # what the scene's task plan expects. Mesh visualisation and camera + # sensor direction are independent here. + WRIST_MESH_OFFSET = [0.0, 0.0, 0.03] + for side, cam_name in ( + (ArmSide.LEFT, "left_wrist_d405"), + (ArmSide.RIGHT, "right_wrist_d405"), + ): + link6 = spec.body(f"{side}link6") + link6.add_geom( + default=visual, + type=mujoco.mjtGeom.mjGEOM_MESH, + meshname="d405", + pos=WRIST_MESH_OFFSET, + rgba=[0.12, 0.12, 0.14, 1.0], + ) + link6.add_camera( + name=f"{cam_name}_cam", + pos=[0.0, 0.0, 0.08], # optical centre halfway to TCP along gripper axis + quat=[0.0, 1.0, 0.0, 0.0], # 180°x: cam -z aligned with link6 +z + mode=mujoco.mjtCamLight.mjCAMLIGHT_FIXED, + fovy=87.0, # D405 FOV + ) + + # ---------------------- Rack (static, open front) ------------------------ + # A 19" cabinet built as 5 panels (rear + 2 sides + top + bottom) with the + # FRONT FULLY OPEN so arms can reach in. Previously a solid box — looked + # like a closed-door cabinet. We also add a cosmetic shelf at server + # height and a hinged-open door tucked to the side for flavor. + rack_cfg = LAYOUT.rack + rack = wb.add_body(name="rack_frame", pos=[rack_cfg.center_x, 0.0, rack_cfg.center_z]) + rack_wall_t = rack_cfg.wall_thickness + panel_rgba = (0.10, 0.10, 0.13, 1.0) + rhx, rhy, rhz = rack_cfg.half + + def _rack_panel(name: str, pos, half) -> None: + b = rack.add_body(name=name, pos=list(pos)) + # Visual-only (class "visual"): the server is weld-attached so it + # doesn't need a shelf to rest on, and arms reach through the open + # front. Letting panels collide just creates solver noise around the + # server body. + b.add_geom( + default=visual, + type=mujoco.mjtGeom.mjGEOM_BOX, + size=list(half), + rgba=list(panel_rgba), + ) + + _rack_panel("rack_rear", (rhx - rack_wall_t, 0.0, 0.0), (rack_wall_t, rhy, rhz)) + _rack_panel("rack_side_L", (0.0, -rhy + rack_wall_t, 0.0), (rhx, rack_wall_t, rhz)) + _rack_panel("rack_side_R", (0.0, rhy - rack_wall_t, 0.0), (rhx, rack_wall_t, rhz)) + _rack_panel("rack_top", (0.0, 0.0, rhz - rack_wall_t), (rhx, rhy, rack_wall_t)) + _rack_panel("rack_bottom", (0.0, 0.0, -rhz + rack_wall_t), (rhx, rhy, rack_wall_t)) + # Cosmetic shelf just below the server slot. + server_cfg = LAYOUT.server + server_local_z = server_cfg.slot_z - rack_cfg.center_z + _rack_panel( + "rack_shelf", + (0.0, 0.0, server_local_z - server_cfg.half[2] - rack_wall_t), + (rhx - rack_wall_t, rhy - rack_wall_t, rack_wall_t), + ) + # Lower staging shelf — fully inside the rack (same footprint as the + # cosmetic rack_shelf above). Earlier iteration extended 20 cm forward + # of the rack front to bring the stow target into Piper reach; user + # read that as "shelf protruding too much". The arm now stows the + # server at the upper-slot x (rack interior) instead — see the stow + # target retargeting in make_task_plan. + lower_shelf_local_z = rack_cfg.lower_shelf_z_world - rack_cfg.center_z + _rack_panel( + "rack_lower_shelf", + (0.0, 0.0, lower_shelf_local_z - server_cfg.half[2] - rack_wall_t), + (rhx - rack_wall_t, rhy - rack_wall_t, rack_wall_t), + ) + # Cable management bracket — a horizontal bar bolted to the rack's +y + # (right) outside wall. All three cables anchor to this bar (spread + # along rack +x via `LAYOUT.cable_anchor_world`). + bracket_center = LAYOUT.cable_bracket_center + cable_bracket_local_x = bracket_center[0] - rack_cfg.center_x + cable_bracket_local_z = bracket_center[2] - rack_cfg.center_z + cable_bracket_local_y = rhy + 0.010 # just outside the side panel + _rack_panel( + "cable_bracket", + (cable_bracket_local_x, cable_bracket_local_y, cable_bracket_local_z), + (0.06, 0.010, 0.020), # 12 cm long (x) × 2 cm deep (y) × 4 cm tall (z) + ) + # (No door — the rack is open-front. An earlier cosmetic hinged-open door + # was clipping into the robot's workspace; not worth the geometry fiddle + # for a purely visual element. Many real 19" racks run doorless anyway.) + + # Server: freejoint bodies must be direct children of worldbody. + port_local_x = LAYOUT.port_local_x_on_server + port_y_offsets = LAYOUT.ports.y_offsets + port_colors = LAYOUT.ports.colors + # contype=0 conaffinity=0: the server is entirely weld-driven (to the + # rack, to the lower staging shelf, or to the arm's grasp weld) — it + # never needs contact-based physics. Leaving default contacts on + # produced a QACC blowup at ~t=44 s because the inserted server's AABB + # grazed the rack's lower shelf + rack_shelf + rack_bottom, and the + # constraint solver couldn't simultaneously hold the weld AND resolve + # the contacts. + server = wb.add_body(name="server", pos=list(LAYOUT.server_world_pos_in_rack)) + server.add_freejoint() + server.add_geom( + type=mujoco.mjtGeom.mjGEOM_BOX, + size=list(server_cfg.half), + rgba=[0.16, 0.17, 0.19, 1.0], + mass=server_cfg.mass, + contype=0, + conaffinity=0, + ) + for _side, y_off in (("l", -0.15), ("r", +0.15)): + server.add_geom( + type=mujoco.mjtGeom.mjGEOM_BOX, + pos=[-server_cfg.half[0] - 0.015, y_off, 0.0], + size=[0.015, 0.015, 0.025], + rgba=[0.85, 0.85, 0.85, 1.0], + ) + _shapes = ("box", "box", "cylinder") + for i, (y, rgba, shape) in enumerate(zip(port_y_offsets, port_colors, _shapes, strict=True)): + if shape == "box": + # i == 0: power (square); else: net (flatter rectangle). + size = [0.010, 0.018, 0.018] if i == 0 else [0.010, 0.012, 0.007] + server.add_geom( + type=mujoco.mjtGeom.mjGEOM_BOX, + pos=[port_local_x, y, 0.0], + size=size, + rgba=list(rgba), + ) + else: # cylinder — fiber (fromto only; MuJoCo forbids pos+fromto) + server.add_geom( + type=mujoco.mjtGeom.mjGEOM_CYLINDER, + fromto=[port_local_x - 0.010, y, 0.0, port_local_x + 0.005, y, 0.0], + size=[0.010], + rgba=list(rgba), + ) + + # Cables (anchored to rack, terminating in connector bodies). + for i, (y, rgba) in enumerate(zip(port_y_offsets, port_colors, strict=True)): + _add_cable(spec, rack, i, y, rgba) + + # ---------------------- Replacement server ------------------------------- + # Place the freejoint body resting on the new_bin floor at initial lift + # height, so the initial NEW_IN_BIN weld captures a realistic relative + # pose. `LAYOUT.new_server_initial_world_pos` computes this from the + # TIAGo torso pose, the upper-bin offset, and the server's own half-extent. + new_server = wb.add_body(name="new_server", pos=list(LAYOUT.new_server_initial_world_pos)) + new_server.add_freejoint() + new_server.add_geom( + type=mujoco.mjtGeom.mjGEOM_BOX, + size=list(server_cfg.half), + rgba=[0.24, 0.26, 0.30, 1.0], + mass=server_cfg.mass, + contype=0, + conaffinity=0, + ) + for _side, y_off in (("l", -0.15), ("r", +0.15)): + new_server.add_geom( + type=mujoco.mjtGeom.mjGEOM_BOX, + pos=[-server_cfg.half[0] - 0.015, y_off, 0.0], + size=[0.015, 0.015, 0.025], + rgba=[0.85, 0.85, 0.85, 1.0], + ) + for i, (y, rgba, shape) in enumerate(zip(port_y_offsets, port_colors, _shapes, strict=True)): + if shape == "box": + size = [0.010, 0.018, 0.018] if i == 0 else [0.010, 0.012, 0.007] + new_server.add_geom( + type=mujoco.mjtGeom.mjGEOM_BOX, + pos=[port_local_x, y, 0.0], + size=size, + rgba=list(rgba), + ) + else: + new_server.add_geom( + type=mujoco.mjtGeom.mjGEOM_CYLINDER, + fromto=[port_local_x - 0.010, y, 0.0, port_local_x + 0.005, y, 0.0], + size=[0.010], + rgba=list(rgba), + ) + + # ---------------------- Actuators ---------------------------------------- + # Position actuator on TIAGo's existing torso_lift_joint. TIAGo's upstream + # MJCF ships only the joint (no actuator); we add the position servo here. + # kp=80000 / kv=800 holds a ~5 kg arm-and-bin payload with <1 mm static + # droop. Earlier kp=5000 produced ~4 cm of sag under the same payload + # (force / kp), which landed IK-planned targets above the cable ports + # by enough to miss the connectors entirely at grip time. + spec.add_actuator( + name=DataCenterAux.LIFT, # -> "torso_lift_joint" + target=DataCenterAux.LIFT, + trntype=mujoco.mjtTrn.mjTRN_JOINT, + gaintype=mujoco.mjtGain.mjGAIN_FIXED, + biastype=mujoco.mjtBias.mjBIAS_AFFINE, + gainprm=[80000.0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + biasprm=[0.0, -80000.0, -800.0, 0, 0, 0, 0, 0, 0, 0], + ctrllimited=True, + ctrlrange=[0.0, LAYOUT.tiago.lift_range], + ) + + # ---------------------- Welds (grasp + attachment) ------------------------ + # Grasp welds: (arm, grippable_object). All inactive at start. Names must + # match the `{prefix}grasp_cube{i}` convention that `get_arm_handles` + # expects when it builds `ArmHandles.weld_ids`. + for side in ARM_PREFIXES: + hand = f"{side}link6" + for i, obj_name in enumerate(GRIPPABLES): + spec.add_equality( + type=mujoco.mjtEq.mjEQ_WELD, + name=f"{side}grasp_cube{i}", + name1=hand, + name2=obj_name, + objtype=mujoco.mjtObj.mjOBJ_BODY, + active=False, + data=[0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1], + ) + + # Attachment equalities — single iteration over ATTACHMENTS registry. + # Welds pin full pose; connects pin only body_b's origin to a point in + # body_a's frame (used for port↔cable connections so the cable can + # swivel naturally at the plug). + for attachment in ATTACHMENTS: + if attachment.kind is ConstraintKind.CONNECT: + ax, ay, az = attachment.connect_anchor_in_a + spec.add_equality( + type=mujoco.mjtEq.mjEQ_CONNECT, + name=attachment.name, + name1=attachment.body_a, + name2=attachment.body_b, + objtype=mujoco.mjtObj.mjOBJ_BODY, + active=attachment.initially_active, + # eq_data for mjEQ_CONNECT: [anchor_xyz (in body1 frame), pad...]. + data=[ax, ay, az, 0, 0, 0, 0, 0, 0, 0, 0], + # Tight solver reference so the plug stays seated instead of + # drifting under cable weight. Default (0.02, 1.0) lets the + # connector sag 2 cm under gravity; 2 ms time-constant makes + # it effectively rigid for our 2 ms physics step. + solref=[0.002, 1.0], + solimp=[0.99, 0.999, 1e-6, 0.5, 2.0], + ) + else: # WELD + spec.add_equality( + type=mujoco.mjtEq.mjEQ_WELD, + name=attachment.name, + name1=attachment.body_a, + name2=attachment.body_b, + objtype=mujoco.mjtObj.mjOBJ_BODY, + active=attachment.initially_active, + data=[0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1], + ) + + # Exclude fragile contacts that would otherwise thrash. + # Piper bases sit on the torso; without exclude the shared contact noise + # produces oscillation around the arm-mount point. + spec.add_exclude(bodyname1="torso_lift_link", bodyname2="left_base_link") + spec.add_exclude(bodyname1="torso_lift_link", bodyname2="right_base_link") + # new_server starts inside the upper bin on the torso — weld-held but the + # bin walls would otherwise contact it on every integrator step. + spec.add_exclude(bodyname1="torso_lift_link", bodyname2="new_server") + spec.add_exclude(bodyname1="rack_frame", bodyname2="server") + + return spec + + +# ----------------------------------------------------------------------------- +# Scene handles (resolved once at runtime by apply_initial_state / task plan) +# ----------------------------------------------------------------------------- + + +@dataclass +class _SceneIds: + lift_actuator_id: int + server_body_id: int + new_server_body_id: int + rack_body_id: int + # The moving TIAGo body the bins hang off (renamed from `carriage_body_id` + # now that the mobile embodiment is TIAGo rather than our hand-built + # `lift_carriage`). + torso_body_id: int + cable_connector_body_ids: list[int] + # Attachment welds by enum member — derived from ATTACHMENTS, so it always + # matches the spec in build_spec. + attachment_eq: dict[AttachmentWeldName, int] = field(default_factory=dict) + + +def _resolve_scene_ids(model: mujoco.MjModel) -> _SceneIds: + def body(name: str) -> int: + return mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, name) + + def eq(name: str) -> int: + return mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_EQUALITY, name) + + return _SceneIds( + lift_actuator_id=mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_ACTUATOR, DataCenterAux.LIFT), + server_body_id=body("server"), + new_server_body_id=body("new_server"), + rack_body_id=body("rack_frame"), + torso_body_id=body("torso_lift_link"), + cable_connector_body_ids=[body(f"cable{i + 1}_connector") for i in range(3)], + attachment_eq={a.name: eq(a.name) for a in ATTACHMENTS}, + ) + + +# ----------------------------------------------------------------------------- +# Initial state +# ----------------------------------------------------------------------------- + + +def apply_initial_state( + model: mujoco.MjModel, + data: mujoco.MjData, + arms: dict[ArmSide, ArmHandles], + cube_body_ids: list[int], +) -> None: + """Reset to home arm pose, lift at LAYOUT.lift.home, welds at spec defaults.""" + mujoco.mj_resetData(model, data) + ids = _resolve_scene_ids(model) + for arm in arms.values(): + for i, idx in enumerate(arm.arm_qpos_idx): + data.qpos[idx] = HOME_ARM_Q[i] + data.qpos[arm.qpos_idx[6]] = arm.gripper_open + data.qpos[arm.qpos_idx[7]] = -arm.gripper_open + data.ctrl[arm.act_arm_ids] = HOME_ARM_Q + data.ctrl[arm.act_gripper_id] = arm.gripper_open + # All grasp welds start inactive. + for eq_id in arm.weld_ids: + data.eq_active[eq_id] = 0 + # Lift at home + data.ctrl[ids.lift_actuator_id] = LAYOUT.lift.home + lift_jnt = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, DataCenterAux.LIFT) + data.qpos[model.jnt_qposadr[lift_jnt]] = LAYOUT.lift.home + + # Propagate qpos to xpos/xmat so the attachment welds below see the + # actual initial body poses (not stale values from the previous tick). + mujoco.mj_forward(model, data) + + # Attachment equalities: WELDs need their relpose seeded from current + # body poses so they pin at compile-time positions (without this, each + # weld's default identity relpose would drag body_b to body_a's origin + # — server to rack centre, new_server to torso centre, etc.). CONNECTs + # already carry their anchor in eq_data from build_spec — the anchor + # is in body_a's local frame and doesn't depend on runtime pose, so + # they just flip on/off via eq_active. + for attachment in ATTACHMENTS: + eq_id = ids.attachment_eq[attachment.name] + if not attachment.initially_active: + data.eq_active[eq_id] = 0 + continue + if attachment.kind is ConstraintKind.CONNECT: + data.eq_active[eq_id] = 1 + else: # WELD + activate_attachment_weld( + model, + data, + eq_id, + int(model.eq_obj1id[eq_id]), + int(model.eq_obj2id[eq_id]), + ) + mujoco.mj_forward(model, data) + + +# ----------------------------------------------------------------------------- +# Task plan +# ----------------------------------------------------------------------------- + + +# Pre-flight IK tolerance: any `snap` call whose residual exceeds this +# aborts `make_task_plan` before viser starts. 2 cm is tight enough to +# catch near-misses (and force every target within fingertip distance) but +# loose enough not to fight currently-passing waypoints. Override by +# editing this constant if a scene genuinely needs looser limits. +_IK_POSITION_TOL_M = 0.02 + + +def _snap_factory(model, data, arm): + """Position-only IK closure, locking the lift so the solver can't use it. + + The returned `snap(target)` raises `RuntimeError` when the residual + exceeds `_IK_POSITION_TOL_M` — so an unreachable waypoint aborts plan + construction instead of quietly shipping a 50 cm approach error into + the runtime. Error message includes the arm side, an in-closure call + counter (so "snap #7 on left_" pinpoints the call site), and the XYZ + target, which together name the failing IK attempt uniquely. + """ + q_seed = {"current": IK_SEED_Q.copy()} + call_count = {"n": 0} + + def snap(target_pos: Position3) -> tuple[np.ndarray, float]: + call_count["n"] += 1 + q, err = solve_ik( + model, + data, + arm, + np.asarray(target_pos, dtype=float), + orientation=PositionOnly(), + seed_q=q_seed["current"], + locked_joint_names=(DataCenterAux.LIFT,), + # DAQP over ProxQP: ProxQP raises NoSolutionFound on some of the + # long-reach TIAGo-mounted targets — DAQP finds a feasible solve + # (sub-mm error) in the same cases. + solver="daqp", + ) + if err > _IK_POSITION_TOL_M: + target = np.asarray(target_pos, dtype=float).tolist() + raise RuntimeError( + f"IK unreachable on {arm.side} (snap #{call_count['n']}): " + f"err={err:.3f} m target={target} " + f"(tol={_IK_POSITION_TOL_M} m). Adjust LAYOUT or move the " + f"waypoint — do NOT raise the tolerance unless you have " + f"a physics reason." + ) + q_seed["current"] = q.copy() + return q, err + + return snap + + +def _port_world_pos(port_idx: int) -> Position3: + """World position of port geom `port_idx` on the rack-mounted server. + Thin wrapper around `LAYOUT.port_world_pos` kept for legacy call sites.""" + return LAYOUT.port_world_pos(port_idx) + + +def _torso_world_z(lift_qpos: float) -> float: + """World z of torso_lift_link at a given lift qpos. Mirrors TIAGo's + upstream `` plus the + slide displacement.""" + return LAYOUT.tiago.torso_world_z(lift_qpos) + + +def _other_side(side: ArmSide) -> ArmSide: + return ArmSide.RIGHT if side == ArmSide.LEFT else ArmSide.LEFT + + +def make_task_plan( + model: mujoco.MjModel, + data: mujoco.MjData, + arms: dict[ArmSide, ArmHandles], + cube_body_ids: list[int], +) -> dict[ArmSide, list[Step]]: + """Scripted data-center server swap. + + Weld choreography: + * Cable unplug — "grip connector" step attach_activates the per-arm + grasp weld AND attach_deactivates the port-old weld simultaneously, + so the cable connector is never double-pinned. + * Server extract — "grip server" step activates each arm's grasp weld + in place (no teleport). Left arm also deactivates the server-in-rack + weld. + * Server stow — "seat in bin" step activates server-in-old-bin weld; + the following "release" step deactivates both grasp welds. + * New-server grab/install — mirror. + * Cable replug — "seat in port" step activates port-new weld; the + following step releases the grasp. + """ + scripts: dict[ArmSide, list[Step]] = {side: [] for side in ARM_PREFIXES} + lift_jnt = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, DataCenterAux.LIFT) + + def push_both(label: str, duration: float, aux: dict[str, float] | None = None) -> None: + for side in ARM_PREFIXES: + scripts[side].append( + Step( + label=label, + arm_q=HOME_ARM_Q.copy(), + gripper="open", + duration=duration, + aux_ctrl=aux, + ) + ) + + def idle( + other_side: ArmSide, + label_prefix: str, + durations: list[float], + aux: dict[str, float] | None = None, + ) -> None: + """Append matching idle-at-home steps on the other arm's timeline.""" + for i, d in enumerate(durations): + scripts[other_side].append( + Step( + label=f"{label_prefix}.{i + 1}", + arm_q=HOME_ARM_Q.copy(), + gripper="open", + duration=d, + aux_ctrl=aux, + ) + ) + + def seed_at_lift(lift_qpos: float) -> tuple: + """Set carriage to lift_qpos and return fresh snap closures.""" + apply_initial_state(model, data, arms, cube_body_ids) + data.qpos[model.jnt_qposadr[lift_jnt]] = lift_qpos + mujoco.mj_forward(model, data) + return ( + _snap_factory(model, data, arms[ArmSide.LEFT]), + _snap_factory(model, data, arms[ArmSide.RIGHT]), + ) + + # 0) Open: both at home, lift down. Then raise lift to cable height and + # hold the target for a beat so the TIAGo torso PD has time to track the + # step before the IK-planned approach starts. User report: "when it + # starts up the telescopic lift pushes the whole platform up which makes + # the robot arms not aligned with the cable" — the IK was planned with + # lift qpos = LIFT_CABLES, but the commanded ctrl was still settling + # when the first approach step began, so the arm landed above the actual + # cable position. Bumping the ramp duration to 3 s plus a 1 s hold gives + # the physical lift time to catch up with ctrl before arms start moving. + push_both("home", 1.0, aux={DataCenterAux.LIFT: LAYOUT.lift.home}) + push_both("lift to cables", 3.0, aux={DataCenterAux.LIFT: LAYOUT.lift.cables}) + push_both("settle at cables", 1.0, aux={DataCenterAux.LIFT: LAYOUT.lift.cables}) + + # Port → its "*_old" and "*_new" attachment welds. Indexed by port_idx. + _PORT_OLD = ( + AttachmentWeldName.PORT1_OLD, + AttachmentWeldName.PORT2_OLD, + AttachmentWeldName.PORT3_OLD, + ) + _PORT_NEW = ( + AttachmentWeldName.PORT1_NEW, + AttachmentWeldName.PORT2_NEW, + AttachmentWeldName.PORT3_NEW, + ) + + # 1) Unplug cables sequentially (L1, R2, L3). Other arm idles at home. + def plan_unplug(port_idx: int, active_side: ArmSide) -> None: + cable_id = grippable_id(f"cable{port_idx + 1}_connector") + cable_grasp_weld = grasp_weld(active_side, cable_id) + port_weld = _PORT_OLD[port_idx] + + snap_l, snap_r = seed_at_lift(LAYOUT.lift.cables) + snap = snap_l if active_side == ArmSide.LEFT else snap_r + + # Read the connector body's world pose AFTER seed_at_lift so the IK + # target actually lands on the connector — not SERVER_FRONT_X minus a + # few cm, which was the MVP approximation and left TCP 2–3 cm short + # of the connector geom (user report: "arms never actually touch the + # cable heads"). The connector is pinned to the server by the port + # weld at this point, so its world pose is deterministic. + connector_bid = mujoco.mj_name2id( + model, mujoco.mjtObj.mjOBJ_BODY, f"cable{port_idx + 1}_connector" + ) + connector_pos = np.asarray(data.xpos[connector_bid], dtype=float).copy() + + approach = connector_pos + np.array([-0.10, 0.0, 0.0]) + at_conn = connector_pos + pulled = connector_pos + np.array([-0.14, 0.0, 0.03]) + side_y = -0.28 if active_side == ArmSide.LEFT else +0.28 + dangle = np.array([LAYOUT.server_front_x - 0.28, side_y, LAYOUT.server.slot_z - 0.10]) + + q_approach, _ = snap(approach) + q_at, _ = snap(at_conn) + q_pulled, _ = snap(pulled) + q_dangle, _ = snap(dangle) + + aux = {DataCenterAux.LIFT: LAYOUT.lift.cables} + # Step durations include dedicated settle time before grip: the + # piper PD with its stock ±100 N·m forcerange takes ~1.5 s to + # converge from a 1-rad-magnitude step change against the arm's + # own gravity, and the grasp weld freezes whatever relative pose + # link6 has at grip time — so an under-settled approach step + # would weld the connector 3–5 cm off-centre and then drag it + # around at that offset for the rest of the sequence. + active_steps = [ + Step(f"approach c{port_idx + 1}", q_approach, "open", 2.0, aux_ctrl=aux), + Step(f"at connector c{port_idx + 1}", q_at, "open", 1.6, aux_ctrl=aux), + Step( + f"grip + unplug c{port_idx + 1}", + q_at, + "closed", + 0.5, + aux_ctrl=aux, + attach_activate=(cable_grasp_weld,), + attach_deactivate=(port_weld,), + ), + Step(f"pull c{port_idx + 1} free", q_pulled, "closed", 0.9, aux_ctrl=aux), + Step(f"park c{port_idx + 1}", q_dangle, "closed", 1.0, aux_ctrl=aux), + Step( + f"release c{port_idx + 1}", + q_dangle, + "open", + 0.4, + aux_ctrl=aux, + attach_deactivate=(cable_grasp_weld,), + ), + ] + scripts[active_side].extend(active_steps) + idle( + _other_side(active_side), + f"wait c{port_idx + 1}", + [s.duration for s in active_steps], + aux=aux, + ) + + plan_unplug(0, ArmSide.LEFT) + plan_unplug(1, ArmSide.RIGHT) + plan_unplug(2, ArmSide.LEFT) + + # 2) Server extraction — both arms together. After pull-out the server + # is lowered onto the rack's lower staging shelf (not a torso bin); + # the lift drops to put the arms at shelf height so the drop-off is + # the same short downward motion it used to be for the bin stow. + snap_l, snap_r = seed_at_lift(LAYOUT.lift.server) + aux_server = {DataCenterAux.LIFT: LAYOUT.lift.server} + aux_stow = {DataCenterAux.LIFT: LAYOUT.lift.stow} + + # Handle bar world x = server_body_center_x - (server.half[0] + 0.015) + # = LAYOUT.server_front_x - 0.015. Targets aim for the handle bar centre + # so the gripper fingers span the bar when TCP seats on it. + handle_x = LAYOUT.server_front_x - 0.015 + handle_L = np.array([handle_x, -0.15, LAYOUT.server.slot_z]) + handle_R = np.array([handle_x, +0.15, LAYOUT.server.slot_z]) + approach_L = handle_L + np.array([-0.07, 0.0, 0.0]) + approach_R = handle_R + np.array([-0.07, 0.0, 0.0]) + pulled_L = handle_L + np.array([-0.32, 0.0, 0.0]) + pulled_R = handle_R + np.array([-0.32, 0.0, 0.0]) + # Lower-shelf stow: target the rack's upper-slot x so the server + # ends up INSIDE the rack on the lower shelf (same depth as the + # live slot above), rather than dangling 20 cm in front. The arm's + # 0.36 m forward reach at LIFT.stow (arm base world z≈0.79, target + # z≈0.64, 0.15 m y inboard) fits inside Piper's ~0.55 m envelope. + stow_z_world = LAYOUT.rack.lower_shelf_z_world + LAYOUT.server.half[2] + 0.015 + # 6 cm forward of the upper-slot handle so the reach stays inside + # Piper's envelope at the lower stow height (target z 0.64 vs arm + # base 0.79 — the 15 cm vertical drop eats into horizontal reach). + stow_handle_x = handle_x - 0.06 + stow_L = np.array([stow_handle_x, -0.15, stow_z_world]) + stow_R = np.array([stow_handle_x, +0.15, stow_z_world]) + + qL_app, _ = snap_l(approach_L) + qL_at, _ = snap_l(handle_L) + qL_out, _ = snap_l(pulled_L) + qR_app, _ = snap_r(approach_R) + qR_at, _ = snap_r(handle_R) + qR_out, _ = snap_r(pulled_R) + # Stow targets are reached at LIFT.stow (lift_qpos=0.05), not + # LIFT.server (0.28) where the rest of the extraction targets are + # solved. Re-seed snap at LIFT.stow before solving the stow IK so + # the IK's notion of arm-base z matches the runtime's at the moment + # the arm reaches the stow waypoint. Without this, qL_stow's TCP + # ends up 23 cm below the planned target (the lift drops between + # IK time and runtime, dragging the arm down with it). + snap_l_stow, snap_r_stow = seed_at_lift(LAYOUT.lift.stow) + qL_stow, _ = snap_l_stow(stow_L) + qR_stow, _ = snap_r_stow(stow_R) + + # SERVER GRASPING POLICY: only the LEFT arm welds to the server. The right + # arm tracks the opposite handle kinematically for visual bimanual effect, + # but activating two grasp welds (one per arm) over-constrains the server + # body — any tiny mismatch in the two arms' IK solutions yanks the + # server around as both welds pull in different directions (user + # report: "the whole replacing the server phase is so messy with the + # server flying around"). One rigid weld → server follows exactly one + # arm, smooth carry. Durations on the pull / lower / carry / insert + # phases bumped ~1.5× so the motion is visibly slow and deliberate. + server_id = grippable_id("server") + grasp_L = grasp_weld(ArmSide.LEFT, server_id) + + scripts[ArmSide.LEFT].extend( + [ + Step("to server handle L", qL_app, "open", 1.8, aux_ctrl=aux_server), + Step("at handle L", qL_at, "open", 0.8, aux_ctrl=aux_server), + Step( + "grip + extract L", + qL_at, + "closed", + 0.6, + aux_ctrl=aux_server, + attach_activate=(grasp_L,), + attach_deactivate=(AttachmentWeldName.SERVER_IN_RACK,), + ), + Step("pull server L", qL_out, "closed", 2.5, aux_ctrl=aux_server), + Step("lower to shelf L", qL_stow, "closed", 2.5, aux_ctrl=aux_stow), + Step( + "seat + release L", + qL_stow, + "open", + 0.8, + aux_ctrl=aux_stow, + # Pin server at the rack's interior on the lower shelf + # (centered, just resting on the shelf top). Explicit + # pose so arm-tracking offsets at this moment don't + # propagate into the server's final pose. + attach_activate_at=( + ( + AttachmentWeldName.SERVER_ON_LOWER_SHELF, + ( + float(LAYOUT.server_center_x_in_rack), + 0.0, + float(LAYOUT.rack.lower_shelf_z_world) + float(LAYOUT.server.half[2]), + ), + (1.0, 0.0, 0.0, 0.0), + ), + ), + attach_deactivate=(grasp_L,), + ), + ] + ) + scripts[ArmSide.RIGHT].extend( + [ + Step("to server handle R", qR_app, "open", 1.8, aux_ctrl=aux_server), + Step("at handle R", qR_at, "open", 0.8, aux_ctrl=aux_server), + # Right gripper stays OPEN through the whole carry: the left + # arm owns the grasp weld, so any force the right fingers + # would apply to the server body just fights that weld. A + # closed-gripper right hand contacting a welded body produced + # QACC blowups around t=44s during the install phase. + Step("hold handle R", qR_at, "open", 0.6, aux_ctrl=aux_server), + Step("pull server R", qR_out, "open", 2.5, aux_ctrl=aux_server), + Step("lower to shelf R", qR_stow, "open", 2.5, aux_ctrl=aux_stow), + Step("release R", qR_stow, "open", 0.8, aux_ctrl=aux_stow), + ] + ) + + # 3) New server pickup from new_bin + install in rack + snap_l, snap_r = seed_at_lift(LAYOUT.lift.pick_new) + aux_pick = {DataCenterAux.LIFT: LAYOUT.lift.pick_new} + + new_handle_z = _torso_world_z(LAYOUT.lift.pick_new) + LAYOUT.bins.new_local_z + 0.06 + new_handle_L = np.array([0.14, -0.15, new_handle_z]) + new_handle_R = np.array([0.14, +0.15, new_handle_z]) + new_approach_L = new_handle_L + np.array([-0.06, 0.0, 0.0]) + new_approach_R = new_handle_R + np.array([-0.06, 0.0, 0.0]) + + qL_napp, _ = snap_l(new_approach_L) + qL_ngrip, _ = snap_l(new_handle_L) + qR_napp, _ = snap_r(new_approach_R) + qR_ngrip, _ = snap_r(new_handle_R) + + snap_l, snap_r = seed_at_lift(LAYOUT.lift.server) + # Rack-slot target aligns with the handle bar of the new-server (same + # geometry as the old server), so arms end the carry with the server + # seated in the slot rather than 2 cm in front of it. + slot_L = np.array([handle_x, -0.15, LAYOUT.server.slot_z]) + slot_R = np.array([handle_x, +0.15, LAYOUT.server.slot_z]) + pre_slot_L = slot_L + np.array([-0.12, 0.0, 0.0]) + pre_slot_R = slot_R + np.array([-0.12, 0.0, 0.0]) + + qL_pre, _ = snap_l(pre_slot_L) + qL_in, _ = snap_l(slot_L) + qR_pre, _ = snap_r(pre_slot_R) + qR_in, _ = snap_r(slot_R) + + # Same single-arm policy as server extraction: only the LEFT arm welds + # to the new_server; the right arm shadow-carries for visual effect. + new_server_id = grippable_id("new_server") + grasp_new_L = grasp_weld(ArmSide.LEFT, new_server_id) + + scripts[ArmSide.LEFT].extend( + [ + Step("to new_bin L", qL_napp, "open", 1.8, aux_ctrl=aux_pick), + Step("at new_bin L", qL_ngrip, "open", 0.8, aux_ctrl=aux_pick), + Step( + "grip new + release bin L", + qL_ngrip, + "closed", + 0.6, + aux_ctrl=aux_pick, + attach_activate=(grasp_new_L,), + attach_deactivate=(AttachmentWeldName.NEW_IN_BIN,), + ), + Step("raise new L", qL_pre, "closed", 2.0, aux_ctrl=aux_server), + Step("insert L", qL_in, "closed", 1.8, aux_ctrl=aux_server), + Step( + "seat in rack + release L", + qL_in, + "open", + 0.8, + aux_ctrl=aux_server, + # Pin new_server in the rack's upper slot at exactly the + # canonical world pose, not wherever the arm happens to + # be at this instant. The grasp_offset captured at + # pickup was relative to where the arm was in the bin; + # without an explicit final pose that offset propagates + # straight into the install pose and the new server + # ends up several cm off-slot. + attach_activate_at=( + ( + AttachmentWeldName.NEW_IN_RACK, + ( + float(LAYOUT.server_world_pos_in_rack[0]), + float(LAYOUT.server_world_pos_in_rack[1]), + float(LAYOUT.server_world_pos_in_rack[2]), + ), + (1.0, 0.0, 0.0, 0.0), + ), + ), + attach_deactivate=(grasp_new_L,), + ), + ] + ) + scripts[ArmSide.RIGHT].extend( + [ + Step("to new_bin R", qR_napp, "open", 1.8, aux_ctrl=aux_pick), + Step("at new_bin R", qR_ngrip, "open", 0.8, aux_ctrl=aux_pick), + # Right gripper stays OPEN through the new-server carry for + # the same reason as server extraction — see the "hold + # handle R" comment above. Closed-right-gripper vs. welded + # new_server was the direct cause of the t=44s QACC blowup + # during `insert R`. + Step("hold new R", qR_ngrip, "open", 0.6, aux_ctrl=aux_pick), + Step("raise new R", qR_pre, "open", 2.0, aux_ctrl=aux_server), + Step("insert R", qR_in, "open", 1.8, aux_ctrl=aux_server), + Step("release R", qR_in, "open", 0.8, aux_ctrl=aux_server), + ] + ) + + # 4) Replug cables sequentially into NEW server (active side rotates). + def plan_replug(port_idx: int, active_side: ArmSide) -> None: + cable_id = grippable_id(f"cable{port_idx + 1}_connector") + cable_grasp_weld = grasp_weld(active_side, cable_id) + port_weld_new = _PORT_NEW[port_idx] + + snap_l, snap_r = seed_at_lift(LAYOUT.lift.cables) + snap = snap_l if active_side == ArmSide.LEFT else snap_r + + side_y = -0.28 if active_side == ArmSide.LEFT else +0.28 + dangle = np.array([LAYOUT.server_front_x - 0.28, side_y, LAYOUT.server.slot_z - 0.10]) + port_pos = _port_world_pos(port_idx) + approach = port_pos + np.array([-0.08, 0.0, 0.0]) + seated = port_pos + np.array([-0.005, 0.0, 0.0]) + + q_d, _ = snap(dangle) + q_a, _ = snap(approach) + q_s, _ = snap(seated) + + aux = {DataCenterAux.LIFT: LAYOUT.lift.cables} + active_steps = [ + Step(f"to c{port_idx + 1} dangle", q_d, "open", 1.2, aux_ctrl=aux), + Step( + f"grip c{port_idx + 1}", + q_d, + "closed", + 0.5, + aux_ctrl=aux, + attach_activate=(cable_grasp_weld,), + ), + Step(f"approach port {port_idx + 1}", q_a, "closed", 1.0, aux_ctrl=aux), + Step( + f"plug port {port_idx + 1}", + q_s, + "closed", + 0.7, + aux_ctrl=aux, + attach_activate=(port_weld_new,), + ), + Step( + f"release c{port_idx + 1}", + q_s, + "open", + 0.4, + aux_ctrl=aux, + attach_deactivate=(cable_grasp_weld,), + ), + ] + scripts[active_side].extend(active_steps) + idle( + _other_side(active_side), + f"wait replug{port_idx + 1}", + [s.duration for s in active_steps], + aux=aux, + ) + + # Replug phase — re-enabled now that puppet mode eliminates the + # earlier QACC blowup at ~t=44 s. Under direct-qpos arm motion + + # gravity=0, the cable composite stays where it's left between + # phases (no gravity drag), and the arm doesn't overshoot when + # picking the connector back up (no PD lag). The original `attach_activate` + # of grasp_weld captures whatever offset separates arm and connector, + # but with gravity=0 the offset is small (whatever the unplug pose + # left it at). + plan_replug(0, ArmSide.LEFT) + plan_replug(1, ArmSide.RIGHT) + plan_replug(2, ArmSide.LEFT) + + # 5) Return home + push_both("return home", 1.6, aux={DataCenterAux.LIFT: LAYOUT.lift.home}) + + for side in ARM_PREFIXES: + print(f" [{side}] {len(scripts[side])} steps planned") + + apply_initial_state(model, data, arms, cube_body_ids) + return scripts diff --git a/experiments/bimanual_sim/scenes/data_center_layout.py b/experiments/bimanual_sim/scenes/data_center_layout.py new file mode 100644 index 0000000..0730d83 --- /dev/null +++ b/experiments/bimanual_sim/scenes/data_center_layout.py @@ -0,0 +1,371 @@ +"""Declarative geometry for the data-center scene. + +Every dimension, offset, and derived anchor used by `scenes/data_center.py` +lives here as a nested frozen dataclass tree. The scene module imports a +single `LAYOUT = DataCenterLayout()` instance and reads positions via +`LAYOUT.rack.front_face_x`, `LAYOUT.server_world_pos_in_rack`, etc. — so +every spatial relationship is an attribute access that `grep` can find, +and changing one number (e.g. `LAYOUT.rack.center_x`) propagates through +every derived value automatically. + +`__post_init__` on the sub-dataclasses and on `DataCenterLayout` asserts +the cross-component invariants the scene depends on (rack rests on the +floor, server fits inside the rack slot, lift targets within the TIAGo +joint range). Violations raise at import time — you never get a broken +scene into MuJoCo compilation. + +No MuJoCo or numpy-side imports here beyond `np.array` for pose literals; +the module is cheap to import and unit-testable without a model in hand. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field + +import numpy as np + +from robots.tiago import torso_world_pos_at_zero as tiago_torso_world_pos_at_zero +from scene_base import JointConfig, Position3 + +Half3 = tuple[float, float, float] +Rgba = tuple[float, float, float, float] + + +# ----------------------------------------------------------------------------- +# Sub-component layouts +# ----------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class _Tiago: + """PAL TIAGo base + torso lift. `torso_world_pos_at_zero` is read from the + upstream MJCF so the scene can't silently drift against a hardcoded copy.""" + + torso_world_pos_at_zero: tuple[float, float, float] = field( + default_factory=tiago_torso_world_pos_at_zero + ) + lift_range: float = 0.35 # TIAGo's upstream torso_lift_joint range + + def torso_world_z(self, lift_qpos: float) -> float: + """World z of torso_lift_link at a given lift qpos.""" + return self.torso_world_pos_at_zero[2] + lift_qpos + + +@dataclass(frozen=True) +class _ArmMount: + """Piper arm attachment points in torso-local frame. + + `x = 0.0` puts the shoulder pivot at the torso centreline (middle + of the cladding depth-wise), so the piper base link is fully inside + the robot body silhouette and the first arm link is what extends + outboard (user ask: "arms should stick out of the sides"). Any + further back would start to clip into the TIAGo torso shell mesh + and push the reach budget past Piper's ~0.55 m envelope; see + `_Rack.center_x` docstring for the coupling. + """ + + x: float = 0.0 + y_abs: float = 0.30 # mounts at ±y_abs from torso centreline + z: float = -0.15 + + +@dataclass(frozen=True) +class _Bins: + """Two onboard compartments on the torso (chest + waist). + + `half` is noticeably larger than `_Server.half` (24×28×8 cm vs + 18×22×4.5 cm) so servers sit with visible clearance inside a + proper drawer-like compartment instead of hugging the walls (user + ask: "bins can be bigger than servers themselves"). Server stow is + a weld, not contact-based, so the oversized bin doesn't introduce + physics instability even though the server would rattle inside a + real drawer of this clearance. + """ + + half: Half3 = (0.24, 0.28, 0.08) + local_x: float = 0.16 # both bins protrude forward of the torso column + new_local_z: float = 0.13 # upper bin (chest) + old_local_z: float = -0.28 # lower bin (waist) + wall_thickness: float = 0.01 + + +@dataclass(frozen=True) +class _Rack: + """Static 19" cabinet parked in front of the robot. + + `center_x` is coupled to `_ArmMount.x` — with arm base at torso-local + x=0 (world ≈ -0.06), scene_check's 0.75 m pre-filter from arm base to + the server *centre* (= rack.front + server.half[0]) forces + rack.center_x ≤ ~0.62. `0.58` puts the server centre at x=0.56, + leaving ~4 cm of scene_check margin and ~50 cm arm-base-to-cable + reach (well inside Piper's ~0.55 m envelope). If arm_mount.x moves, + this must too. + """ + + center_x: float = 0.58 + half: Half3 = (0.20, 0.32, 0.65) + center_z: float = 0.65 + wall_thickness: float = 0.012 + # World z of the rack's *lower* staging shelf (below the live-server + # slot). Holds the old server after extraction so the robot's single + # torso bin stays free for carrying the new server. + lower_shelf_z_world: float = 0.58 + + @property + def front_face_x(self) -> float: + return self.center_x - self.half[0] + + @property + def back_face_x(self) -> float: + return self.center_x + self.half[0] + + @property + def bottom_z(self) -> float: + return self.center_z - self.half[2] + + @property + def top_z(self) -> float: + return self.center_z + self.half[2] + + @property + def side_y_pos(self) -> float: + return self.half[1] + + @property + def side_y_neg(self) -> float: + return -self.half[1] + + def __post_init__(self) -> None: + # Rack must rest on the floor (z = 0). A floating rack was the visible + # symptom that prompted the user's "make it tall enough" report; this + # guard fails at import rather than after viser renders. + if abs(self.bottom_z) > 1e-6: + raise ValueError( + f"rack bottom at z={self.bottom_z:.4f}, must rest on floor (z=0): " + f"set center_z = half[2] ({self.half[2]})" + ) + + +@dataclass(frozen=True) +class _Server: + """Server chassis geometry (shared by `server` and `new_server` bodies).""" + + half: Half3 = (0.18, 0.22, 0.045) + slot_z: float = 0.88 # world z of server centre when installed in rack + mass: float = 0.5 + + +@dataclass(frozen=True) +class _Ports: + """Three ports across the server front face (power / network / fiber).""" + + local_x_depth: float = 0.01 # how far the port geom protrudes from server front + y_offsets: tuple[float, float, float] = (-0.12, 0.0, +0.12) + colors: tuple[Rgba, Rgba, Rgba] = ( + (0.90, 0.22, 0.22, 1.0), # red — power + (0.20, 0.78, 0.30, 1.0), # green — network + (0.22, 0.42, 0.90, 1.0), # blue — fiber + ) + + +@dataclass(frozen=True) +class _Cables: + """Composite-cable dimensions + side-of-rack bracket placement.""" + + n_seg: int = 10 + seg_len: float = 0.06 + seg_radius: float = 0.005 + conn_len: float = 0.02 + anchor_forward_inset: float = 0.04 # along rack +x from front face + anchor_side_offset: float = 0.020 # outside the +y side panel + anchor_spread_x: float = 0.04 # stagger between cables along rack +x + + +@dataclass(frozen=True) +class _Arm: + """Piper arm rest pose + IK seed. + + `home_q` is the visual rest pose shown before the task begins; `ik_seed_q` + is what the differential-IK solver starts from. They're decoupled because + DAQP fails to find a feasible solution when seeded from the compact home + pose for forward-extending cable/server targets (user report: "IK refused + to solve cable 1") but converges cleanly from the Menagerie forward- + horizontal keyframe. The runner linearly interpolates from `home_q` to the + first IK-solved `q` so the transition stays smooth. + """ + + home_q: JointConfig = field( + default_factory=lambda: np.array([0.0, 0.0, -1.5708, 0.0, 1.0, 0.0]) + ) + ik_seed_q: JointConfig = field( + default_factory=lambda: np.array([0.0, 1.57, -1.3485, 0.0, 0.2, 0.0]) + ) + + +@dataclass(frozen=True) +class _LiftTargets: + """Task-plan lift qpos targets. All must lie in [0, tiago.lift_range].""" + + home: float = 0.05 + cables: float = 0.30 # rack cable-port height + server: float = 0.28 # server-slot height (just below cables) + stow: float = 0.05 # lower bin (old-server stow) + pick_new: float = 0.15 # upper bin (new-server pickup) + + +# ----------------------------------------------------------------------------- +# Top-level composition +# ----------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class DataCenterLayout: + """Single source of truth for `scenes/data_center.py`'s geometry. + + Cross-component derivations (anything that depends on more than one + sub-component, e.g. a server's world position pulling from both + `rack.front_face_x` and `server.half[0]`) live as `@property` on this + top-level class — the sub-components stay ignorant of each other. + """ + + tiago: _Tiago = field(default_factory=_Tiago) + arm_mount: _ArmMount = field(default_factory=_ArmMount) + bins: _Bins = field(default_factory=_Bins) + rack: _Rack = field(default_factory=_Rack) + server: _Server = field(default_factory=_Server) + ports: _Ports = field(default_factory=_Ports) + cables: _Cables = field(default_factory=_Cables) + arm: _Arm = field(default_factory=_Arm) + lift: _LiftTargets = field(default_factory=_LiftTargets) + + # ---- Rack / server cross-derivations ---- + + @property + def server_front_x(self) -> float: + """World x of the server's front face when installed in the rack. + Equivalent to `rack.front_face_x`; kept as a named property because + scene code reads "server-front" intent more clearly than "rack-front" + at the call site.""" + return self.rack.front_face_x + + @property + def server_center_x_in_rack(self) -> float: + """World x of the server body centre when installed in the rack.""" + return self.rack.front_face_x + self.server.half[0] + + @property + def server_world_pos_in_rack(self) -> Position3: + return np.array([self.server_center_x_in_rack, 0.0, self.server.slot_z]) + + @property + def port_local_x_on_server(self) -> float: + """Port geom's x offset in the server body's local frame (recessed).""" + return -self.server.half[0] + self.ports.local_x_depth + + def port_world_pos(self, i: int) -> Position3: + """World position of port `i`'s geom centre on the rack-mounted server.""" + return np.array( + [ + self.server_center_x_in_rack + self.port_local_x_on_server, + self.ports.y_offsets[i], + self.server.slot_z, + ] + ) + + def port_anchor_in_server_frame(self, i: int) -> tuple[float, float, float]: + """Port anchor point in the server body's local frame, for the + mjEQ_CONNECT `data` field of a port↔cable-connector equality.""" + return (self.port_local_x_on_server, self.ports.y_offsets[i], 0.0) + + # ---- Cable-bracket derivations ---- + + @property + def cable_bracket_center(self) -> Position3: + """World position of the shared side-of-rack cable bracket.""" + return np.array( + [ + self.rack.front_face_x + self.cables.anchor_forward_inset, + self.rack.side_y_pos + self.cables.anchor_side_offset, + self.server.slot_z, + ] + ) + + def cable_anchor_world(self, cable_idx: int) -> Position3: + """World anchor for cable `cable_idx` (0..2), staggered along rack +x + so the three grommets are visually distinct.""" + offset = (cable_idx - 1) * self.cables.anchor_spread_x + return self.cable_bracket_center + np.array([offset, 0.0, 0.0]) + + @property + def cable_max_len(self) -> float: + """Cap on composite cable length (beyond the direct anchor→port run).""" + return self.cables.n_seg * self.cables.seg_len + self.cables.conn_len + + # ---- New-server initial pose (resting in the upper bin at LIFT_HOME) ---- + + @property + def new_server_initial_world_pos(self) -> Position3: + """World pose the replacement server is spawned at: resting on the + upper bin's floor at torso `LIFT_HOME`. Server centre sits + `SERVER_HALF[2]` above the bin floor's inner surface.""" + tz = self.tiago.torso_world_z(self.lift.home) + bin_floor_inner_z = tz + self.bins.new_local_z - self.bins.half[2] + return np.array( + [ + self.tiago.torso_world_pos_at_zero[0] + self.bins.local_x, + 0.0, + bin_floor_inner_z + self.server.half[2], + ] + ) + + # ---- Invariants ---- + + def __post_init__(self) -> None: + # Server must fit inside the rack slot. + if self.server.half[0] > self.rack.half[0] - 0.02: + raise ValueError( + f"server too deep for rack slot: " + f"server.half[0]={self.server.half[0]} vs rack.half[0]-margin=" + f"{self.rack.half[0] - 0.02}" + ) + if self.server.half[1] > self.rack.half[1] - 0.02: + raise ValueError( + f"server too wide for rack slot: " + f"server.half[1]={self.server.half[1]} vs rack.half[1]-margin=" + f"{self.rack.half[1] - 0.02}" + ) + if not self.rack.bottom_z <= self.server.slot_z <= self.rack.top_z: + raise ValueError( + f"server slot z={self.server.slot_z} outside rack vertical span " + f"[{self.rack.bottom_z:.3f}, {self.rack.top_z:.3f}]" + ) + # Every lift target within TIAGo's actual joint range. + for name in ("home", "cables", "server", "stow", "pick_new"): + v = getattr(self.lift, name) + if not 0.0 <= v <= self.tiago.lift_range: + raise ValueError( + f"lift.{name}={v} outside TIAGo joint range [0, {self.tiago.lift_range}]" + ) + # Port y offsets must fit within the server body's half-width. + for i, y in enumerate(self.ports.y_offsets): + if abs(y) > self.server.half[1]: + raise ValueError( + f"port {i} y_offset={y} outside server half-width ±{self.server.half[1]}" + ) + + +LAYOUT = DataCenterLayout() + + +# ----------------------------------------------------------------------------- +# Convenience re-exports for scene code that prefers flat names +# ----------------------------------------------------------------------------- +# These keep `scenes/data_center.py` readable: instead of +# `LAYOUT.arm.home_q` spelled out at every call site, the scene module can +# import these at the top. + +HOME_ARM_Q = LAYOUT.arm.home_q +IK_SEED_Q = LAYOUT.arm.ik_seed_q + +_PORT_WORLD_POS: Callable[[int], Position3] = LAYOUT.port_world_pos diff --git a/experiments/bimanual_sim/serve.sh b/experiments/bimanual_sim/serve.sh new file mode 100755 index 0000000..df3caa5 --- /dev/null +++ b/experiments/bimanual_sim/serve.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# Lifecycle helper for the runner. Runs wherever the file lives — no hardcoded +# paths. Usage: +# ./serve.sh start [scene] # scene defaults to data_center +# ./serve.sh stop +# ./serve.sh status +# ./serve.sh logs [N] +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" +export PATH="$HOME/.local/bin:$PATH" + +LOG="$SCRIPT_DIR/runner.log" +PIDFILE="$SCRIPT_DIR/runner.pid" + +case "${1:-status}" in + start) + SCENE="${2:-data_center}" + if [ -f "$PIDFILE" ] && kill -0 "$(cat $PIDFILE)" 2>/dev/null; then + echo "already running (pid $(cat $PIDFILE))"; exit 0 + fi + pkill -f 'runner.py' 2>/dev/null || true + sleep 1 + nohup uv run python "$SCRIPT_DIR/runner.py" \ + --scene "$SCENE" --host 127.0.0.1 --port 8080 \ + > "$LOG" 2>&1 < /dev/null & + echo $! > "$PIDFILE" + sleep 6 + if kill -0 "$(cat $PIDFILE)" 2>/dev/null; then + echo "started pid=$(cat $PIDFILE), scene=$SCENE, log=$LOG" + tail -8 "$LOG" + else + echo "failed to start; see $LOG"; tail -20 "$LOG"; exit 1 + fi + ;; + stop) + if [ -f "$PIDFILE" ]; then kill "$(cat $PIDFILE)" 2>/dev/null || true; fi + pkill -f 'runner.py' 2>/dev/null || true + rm -f "$PIDFILE" + echo stopped + ;; + status) + if pgrep -af runner.py >/dev/null; then + echo running:; pgrep -af runner.py + ss -tlnp 2>/dev/null | grep :8080 || true + else + echo stopped + fi + ;; + logs) + tail -n "${2:-50}" "$LOG" + ;; + *) + echo "usage: serve.sh {start [scene]|stop|status|logs [N]}"; exit 2 + ;; +esac diff --git a/experiments/bimanual_sim/tools/__init__.py b/experiments/bimanual_sim/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/experiments/bimanual_sim/tools/_runtime.py b/experiments/bimanual_sim/tools/_runtime.py new file mode 100644 index 0000000..08e3ba5 --- /dev/null +++ b/experiments/bimanual_sim/tools/_runtime.py @@ -0,0 +1,343 @@ +"""Shared helpers for the `tools/` debug CLIs. + +Every debug tool needs some subset of: "import a scene module by name", +"build + compile the spec", "advance the runner's task-plan timeline to +time t", "render from a camera or free-cam". This module owns that +plumbing so each tool stays a thin CLI wrapper focused on its own job +(render one frame, stitch video, grid-compose cameras, diff two PNGs, +sweep IK feasibility, print the task-plan timeline). + +Importing from `tools._runtime` inside other tools keeps a single source +of truth for step interpretation: whatever the real runner does at a +step boundary (weld activate, attach deactivate, ctrl interpolation) is +replicated here once. +""" + +from __future__ import annotations + +import importlib +import math +import os +import sys +from dataclasses import dataclass +from pathlib import Path +from types import ModuleType +from typing import Literal, NewType + +# Set EGL before mujoco import so headless Linux rendering works without +# an X server. Tool invocations set this via env or we default it here. +os.environ.setdefault("MUJOCO_GL", "egl") + +# Add the scene root (parent of tools/) to sys.path so tool scripts can +# import `arm_handles`, `scenes`, etc. regardless of cwd. +_SCENE_ROOT = Path(__file__).resolve().parent.parent +if str(_SCENE_ROOT) not in sys.path: + sys.path.insert(0, str(_SCENE_ROOT)) + +import mujoco # noqa: E402 +import numpy as np # noqa: E402 + +from arm_handles import ArmHandles, ArmSide, get_arm_handles # noqa: E402 +from scene_base import Step # noqa: E402 +from welds import ( # noqa: E402 + activate_attachment_weld, + activate_grasp_weld, + deactivate_grasp_weld, +) + +SceneName = NewType("SceneName", str) +Seconds = NewType("Seconds", float) +AzimuthDeg = NewType("AzimuthDeg", float) +ElevationDeg = NewType("ElevationDeg", float) +Metres = NewType("Metres", float) +WorldPoint = tuple[float, float, float] + + +def load_scene(name: SceneName | str) -> ModuleType: + """Import `scenes.` — matches runner.py's convention so a + --scene argument behaves identically between runner and tools.""" + return importlib.import_module(f"scenes.{name}") + + +def parse_world_point(raw: str, *, field_name: str) -> WorldPoint: + """Parse `'x,y,z'` into a 3-tuple; raise with a useful message on + anything else. CLI boundary parsing — downstream code gets a refined + tuple and never re-validates.""" + parts = [p.strip() for p in raw.split(",")] + if len(parts) != 3: + raise ValueError( + f"--{field_name} must be 'x,y,z' (three comma-separated numbers); got {raw!r}" + ) + try: + vals = [float(p) for p in parts] + except ValueError as err: + raise ValueError(f"--{field_name}: every component must be numeric; got {raw!r}") from err + return (vals[0], vals[1], vals[2]) + + +@dataclass(frozen=True) +class FreeCameraPose: + """Orbit-camera pose in the convention the viser viewer uses.""" + + azimuth_deg: AzimuthDeg + elevation_deg: ElevationDeg + distance_m: Metres + lookat: WorldPoint + + +def build_free_cam(pose: FreeCameraPose) -> mujoco.MjvCamera: + """Materialise a `FreeCameraPose` into MuJoCo's `MjvCamera` type.""" + cam = mujoco.MjvCamera() + cam.type = mujoco.mjtCamera.mjCAMERA_FREE + cam.azimuth = float(pose.azimuth_deg) + cam.elevation = float(pose.elevation_deg) + cam.distance = float(pose.distance_m) + cam.lookat[:] = pose.lookat + return cam + + +CameraSpec = mujoco.MjvCamera | str | None +"""Union the tools pass to `render_frame`: + - MjvCamera: a free-cam pose + - str: a named scene camera (e.g. 'top_d435i_cam') + - None: MuJoCo's default free camera +""" + + +def render_frame( + model: mujoco.MjModel, + data: mujoco.MjData, + *, + camera: CameraSpec = None, + width: int = 640, + height: int = 480, +) -> np.ndarray: + """Render a single frame. Returns an (H, W, 3) uint8 array. + + We deliberately don't call `renderer.close()` — MuJoCo 3.7's + Renderer.__del__ calls close() at GC, and an explicit close() + followed by __del__'s call raises `_mjr_context` AttributeError on + the second pass. Letting GC handle cleanup avoids the noise without + leaking resources (Python drops the reference when the function + returns). + """ + renderer = mujoco.Renderer(model, height=height, width=width) + if isinstance(camera, str): + cam_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_CAMERA, camera) + if cam_id < 0: + available = [ + mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_CAMERA, i) or f"cam{i}" + for i in range(model.ncam) + ] + raise ValueError(f"unknown camera {camera!r}; available: {available}") + renderer.update_scene(data, camera=cam_id) + elif isinstance(camera, mujoco.MjvCamera): + renderer.update_scene(data, camera=camera) + else: + renderer.update_scene(data) + return renderer.render() + + +@dataclass +class _ArmTimelineState: + step: int + t: float + start_q: np.ndarray + start_g: float + start_aux: dict[str, float] + + +def _advance_one_arm( + model: mujoco.MjModel, + data: mujoco.MjData, + arm: ArmHandles, + script: list[Step], + st: _ArmTimelineState, + sim_dt: float, + aux_name_to_id: dict[str, int], +) -> None: + """Mirror of `runner.advance_arm` but stripped to headless + replay — no speed slider, no GUI updates, just step ctrl and fire + the weld/attach side effects at step boundaries.""" + if st.step >= len(script): + return + step = script[st.step] + first_tick = st.t == 0.0 + st.t += sim_dt + + if first_tick: + if step.weld_activate is not None: + activate_grasp_weld( + model, + data, + int(arm.weld_ids[step.weld_activate]), + arm.link6_id, + arm.link6_id, + arm.tcp_site_id, + ) + if step.weld_deactivate is not None: + deactivate_grasp_weld(data, int(arm.weld_ids[step.weld_deactivate])) + for weld_name in step.attach_activate: + eq_id = int(mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_EQUALITY, weld_name)) + if int(model.eq_type[eq_id]) == int(mujoco.mjtEq.mjEQ_CONNECT): + data.eq_active[eq_id] = 1 + else: + activate_attachment_weld( + model, + data, + eq_id, + int(model.eq_obj1id[eq_id]), + int(model.eq_obj2id[eq_id]), + ) + for weld_name, target_xyz, target_quat in step.attach_activate_at or (): + eq_id = int(mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_EQUALITY, weld_name)) + activate_attachment_weld( + model, + data, + eq_id, + int(model.eq_obj1id[eq_id]), + int(model.eq_obj2id[eq_id]), + target_world_pose=(target_xyz, target_quat), + ) + for weld_name in step.attach_deactivate: + eq_id = int(mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_EQUALITY, weld_name)) + data.eq_active[eq_id] = 0 + + alpha = min(1.0, st.t / max(step.duration, 1e-3)) + alpha_s = 0.5 - 0.5 * math.cos(math.pi * alpha) + + # Puppet mode: write qpos directly (mirrors runner.advance_arm). + curr_q = (1.0 - alpha_s) * st.start_q + alpha_s * step.arm_q + data.qpos[arm.arm_qpos_idx] = curr_q + data.qvel[arm.arm_dof_idx] = 0.0 + data.ctrl[arm.act_arm_ids] = curr_q + + target_gripper = arm.gripper_open if step.gripper == "open" else arm.gripper_closed + curr_g = (1.0 - alpha_s) * st.start_g + alpha_s * target_gripper + data.qpos[arm.qpos_idx[6]] = curr_g + data.qpos[arm.qpos_idx[7]] = -curr_g + data.qvel[arm.dof_idx[6]] = 0.0 + data.qvel[arm.dof_idx[7]] = 0.0 + data.ctrl[arm.act_gripper_id] = curr_g + + if step.aux_ctrl: + for aux_name, aux_target in step.aux_ctrl.items(): + aid = aux_name_to_id[aux_name] + jnt_id = int(model.actuator_trnid[aid][0]) + qadr = int(model.jnt_qposadr[jnt_id]) + dadr = int(model.jnt_dofadr[jnt_id]) + start = st.start_aux.get(aux_name, float(data.qpos[qadr])) + curr_aux = (1.0 - alpha_s) * start + alpha_s * aux_target + data.qpos[qadr] = curr_aux + data.qvel[dadr] = 0.0 + data.ctrl[aid] = curr_aux + + if alpha >= 1.0: + st.start_q = step.arm_q.copy() + st.start_g = target_gripper + if step.aux_ctrl: + for aux_name, aux_target in step.aux_ctrl.items(): + st.start_aux[aux_name] = aux_target + st.step += 1 + st.t = 0.0 + + +def advance_timeline( + model: mujoco.MjModel, + data: mujoco.MjData, + arms: dict[ArmSide, ArmHandles], + task_plan: dict[ArmSide, list[Step]], + aux_name_to_id: dict[str, int], + sim_dt: float, + until_s: Seconds, +) -> None: + """Replay the task plan's per-arm step loop until sim time reaches + `until_s`. Every `sim_dt` runs one interpolation pass per arm then + one `mj_step`. Mutates `data` in place.""" + state = { + side: _ArmTimelineState( + step=0, + t=0.0, + start_q=np.array([data.qpos[i] for i in arms[side].arm_qpos_idx]), + start_g=float(data.ctrl[arms[side].act_gripper_id]), + start_aux={}, + ) + for side in arms + } + n_steps = int(float(until_s) / sim_dt) + for _ in range(n_steps): + for side, arm in arms.items(): + _advance_one_arm(model, data, arm, task_plan[side], state[side], sim_dt, aux_name_to_id) + mujoco.mj_step(model, data) + + +@dataclass +class SceneContext: + """Handle returned by `build_scene_and_advance` so every tool + reaches the same (model, data, arms, task_plan) quadruple without + copy-pasting scene-initialisation code across six CLIs.""" + + model: mujoco.MjModel + data: mujoco.MjData + arms: dict[ArmSide, ArmHandles] + task_plan: dict[ArmSide, list[Step]] | None + scene_module: ModuleType + + +def build_scene_and_advance(scene_name: SceneName | str, t: Seconds | float = 0.0) -> SceneContext: + """Load + compile the scene, apply initial state, advance task plan + to `t`. Every tool calls this instead of reimplementing the six-step + init dance. Passing `t=0` skips task-plan construction entirely, so + tools that only care about the home-pose spec (e.g. `tree.py`) don't + pay the IK cost.""" + scene = load_scene(scene_name) + spec = scene.build_spec() + model = spec.compile() + data = mujoco.MjData(model) + + arm_sides: tuple[ArmSide, ...] = getattr(scene, "ARM_PREFIXES", ()) + n_cubes: int = getattr(scene, "N_CUBES", 0) + cube_body_ids = [ + mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, name) + for name in getattr(scene, "GRIPPABLES", ()) + ] + arms: dict[ArmSide, ArmHandles] = { + side: get_arm_handles(model, side, n_cubes) for side in arm_sides + } + aux_name_to_id = { + name: mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_ACTUATOR, name) + for name in getattr(scene, "AUX_ACTUATOR_NAMES", ()) + } + scene.apply_initial_state(model, data, arms, cube_body_ids) + + task_plan: dict[ArmSide, list[Step]] | None = None + if hasattr(scene, "make_task_plan"): + # Build the plan unconditionally (the IK solve is cheap relative + # to waiting for callers to ask for it and then rebuilding from + # scratch). Re-apply initial state after the plan's `snap` calls + # so t=0 renders show the home pose, not the IK seeding state. + task_plan = scene.make_task_plan(model, data, arms, cube_body_ids) + scene.apply_initial_state(model, data, arms, cube_body_ids) + if task_plan is not None and float(t) > 0: + sim_dt = float(model.opt.timestep) + advance_timeline(model, data, arms, task_plan, aux_name_to_id, sim_dt, Seconds(float(t))) + elif float(t) > 0: + raise RuntimeError(f"scene {scene_name!r} has no make_task_plan, can't advance past t=0") + + mujoco.mj_forward(model, data) + return SceneContext(model=model, data=data, arms=arms, task_plan=task_plan, scene_module=scene) + + +# Video encoder discrimination — the one place we translate a CLI +# filename suffix into the parsed output format. Downstream video- +# writing code takes `VideoFormat` and doesn't re-inspect the path. +VideoFormat = Literal["mp4", "gif"] + + +def parse_video_format(out_path: Path) -> VideoFormat: + suffix = out_path.suffix.lower().lstrip(".") + if suffix == "mp4": + return "mp4" + if suffix == "gif": + return "gif" + raise ValueError(f"unsupported video suffix {out_path.suffix!r}; use .mp4 or .gif") diff --git a/experiments/bimanual_sim/tools/mj.py b/experiments/bimanual_sim/tools/mj.py new file mode 100644 index 0000000..4ed5fc0 --- /dev/null +++ b/experiments/bimanual_sim/tools/mj.py @@ -0,0 +1,710 @@ +"""Unified debug CLI for the bimanual sim. + +One entrypoint, six subcommands. Replaces the per-tool scripts: + + uv run python tools/mj.py snapshot --out /tmp/home.png + uv run python tools/mj.py snapshot --t 22 --camera top_d435i_cam --out /tmp/cable.png + uv run python tools/mj.py snapshot --t 30 --every 0.5 --out-prefix /tmp/run_ + uv run python tools/mj.py video --prefix /tmp/run_ --fps 20 --out /tmp/run.mp4 + uv run python tools/mj.py grid --t 22 --out /tmp/grid.png + uv run python tools/mj.py plan + uv run python tools/mj.py diff --a /tmp/a.png --b /tmp/b.png --out /tmp/d.png + uv run python tools/mj.py ik + +Run `... tools/mj.py --help` or `... tools/mj.py --help` for +the full option list. + +All subcommands default to `--scene data_center`. Rendering goes through +MuJoCo's native `mujoco.Renderer` (EGL backend on headless Linux; the +GL driver already offloads to the GPU on hosts that expose one, no +explicit switch needed). +""" + +from __future__ import annotations + +import math +import os +import sys +from pathlib import Path +from typing import Annotated + +# Force EGL before any `import mujoco` so headless rendering works on +# servers without an X display. `os.environ.setdefault` means a caller +# that set MUJOCO_GL=osmesa (or similar) still wins, but an unset env +# no longer falls through to GLFW — which silently half-inits the +# Renderer and later crashes in __del__ with AttributeError on +# `_mjr_context`. Must be before the mujoco import below; `_runtime` +# already does this but mj.py imports mujoco first. +os.environ.setdefault("MUJOCO_GL", "egl") + +# Bootstrap the project root onto sys.path before importing the sibling +# `tools._runtime` module — running `python tools/mj.py` only puts +# `tools/` on the path, so the project-root import we need for +# scene modules must be added by hand. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import imageio.v3 as iio # noqa: E402 +import mujoco # noqa: E402 +import numpy as np # noqa: E402 +import typer # noqa: E402 + +from tools._runtime import ( # noqa: E402 + AzimuthDeg, + CameraSpec, + ElevationDeg, + FreeCameraPose, + Metres, + Seconds, + advance_timeline, + build_free_cam, + build_scene_and_advance, + parse_video_format, + parse_world_point, + render_frame, +) + +app = typer.Typer( + help=__doc__, + no_args_is_help=True, + rich_markup_mode="rich", + context_settings={"help_option_names": ["-h", "--help"]}, +) + + +# ---- shared option aliases --------------------------------------------------- + + +SceneOpt = Annotated[str, typer.Option(help="scene module under scenes/")] +TOpt = Annotated[float, typer.Option("--t", help="sim time to advance to (seconds)")] +WidthOpt = Annotated[int, typer.Option(help="render width in px")] +HeightOpt = Annotated[int, typer.Option(help="render height in px")] +AzOpt = Annotated[float, typer.Option("--az", help="free-cam azimuth (deg)")] +ElOpt = Annotated[float, typer.Option("--el", help="free-cam elevation (deg)")] +DistOpt = Annotated[float, typer.Option("--dist", help="free-cam distance (m)")] +LookatOpt = Annotated[str, typer.Option("--lookat", help="free-cam lookat 'x,y,z'")] + + +def _resolve_camera( + camera: str | None, + az: float, + el: float, + dist: float, + lookat: str, +) -> CameraSpec: + """`--camera` takes precedence; otherwise construct a free-cam pose + from the orbit knobs. Mutually exclusive by construction.""" + if camera is not None: + return camera + pose = FreeCameraPose( + azimuth_deg=AzimuthDeg(az), + elevation_deg=ElevationDeg(el), + distance_m=Metres(dist), + lookat=parse_world_point(lookat, field_name="lookat"), + ) + return build_free_cam(pose) + + +# ---- snapshot ---------------------------------------------------------------- + + +@app.command() +def snapshot( + out: Annotated[ + Path | None, + typer.Option(help="single-frame output path (.png); omit with --out-prefix"), + ] = None, + out_prefix: Annotated[ + str | None, + typer.Option( + "--out-prefix", + help="sequence-mode prefix; frames named {prefix}{sec:06.2f}.png", + ), + ] = None, + scene: SceneOpt = "data_center", + t: TOpt = 0.0, + every: Annotated[ + float, + typer.Option(help="sequence mode: emit one frame per N seconds up to --t"), + ] = 0.0, + camera: Annotated[ + str | None, + typer.Option(help="named scene camera (unset = free-cam with --az/--el/…)"), + ] = None, + width: WidthOpt = 640, + height: HeightOpt = 480, + az: AzOpt = 135.0, + el: ElOpt = -20.0, + dist: DistOpt = 2.5, + lookat: LookatOpt = "0.30,0.0,0.9", +) -> None: + """Render a scene to PNG — single frame or time-lapse sequence.""" + camera_spec = _resolve_camera(camera, az, el, dist, lookat) + sequence_mode = every > 0.0 + + if sequence_mode and out_prefix is None: + raise typer.BadParameter("--every requires --out-prefix") + if sequence_mode and t <= 0: + raise typer.BadParameter("--every requires --t > 0") + if not sequence_mode and out is None: + raise typer.BadParameter("single-frame mode requires --out PATH") + + if not sequence_mode: + ctx = build_scene_and_advance(scene, Seconds(t)) + frame = render_frame(ctx.model, ctx.data, camera=camera_spec, width=width, height=height) + assert out is not None # narrowed by the typer.BadParameter check above + iio.imwrite(out, frame) + typer.echo(f"rendered → {out}") + return + + ctx = build_scene_and_advance(scene, Seconds(0.0)) + if ctx.task_plan is None: + raise typer.BadParameter( + f"scene {scene!r} has no make_task_plan — sequence mode needs a timeline" + ) + aux_name_to_id = { + name: mujoco.mj_name2id(ctx.model, mujoco.mjtObj.mjOBJ_ACTUATOR, name) + for name in getattr(ctx.scene_module, "AUX_ACTUATOR_NAMES", ()) + } + sim_dt = float(ctx.model.opt.timestep) + prev_t = 0.0 + assert out_prefix is not None + for target_t in np.arange(0.0, t + 1e-9, every): + dt = float(target_t) - prev_t + if dt > 0: + advance_timeline( + ctx.model, + ctx.data, + ctx.arms, + ctx.task_plan, + aux_name_to_id, + sim_dt, + Seconds(dt), + ) + mujoco.mj_forward(ctx.model, ctx.data) + frame = render_frame(ctx.model, ctx.data, camera=camera_spec, width=width, height=height) + path = Path(f"{out_prefix}{float(target_t):06.2f}.png") + iio.imwrite(path, frame) + typer.echo(f"{float(target_t):5.2f}s → {path}") + prev_t = float(target_t) + + +# ---- video ------------------------------------------------------------------- + + +@app.command() +def video( + prefix: Annotated[str, typer.Option(help="path prefix; stitches {prefix}*.png")], + out: Annotated[Path, typer.Option(help="output file (.mp4 or .gif)")], + fps: Annotated[float, typer.Option(help="playback frames per second")] = 20.0, +) -> None: + """Stitch a sequence of PNGs into an mp4 or gif.""" + prefix_path = Path(prefix) + search_dir = prefix_path.parent if str(prefix_path.parent) else Path(".") + stem = prefix_path.name + frames = sorted(search_dir.glob(f"{stem}*.png")) + if not frames: + raise typer.Exit(code=1) + fmt = parse_video_format(out) + typer.echo(f"stitching {len(frames)} frames → {out} ({fmt})") + images = [iio.imread(frame) for frame in frames] + if fmt == "gif": + iio.imwrite(out, images, duration=int(1000 / fps), loop=0) + else: + iio.imwrite(out, images, fps=int(fps), codec="libx264") + typer.echo(f"wrote {out}") + + +# ---- grid -------------------------------------------------------------------- + + +_GRID_FONT: dict[str, tuple[str, ...]] = { + "a": (" ### ", "# #", "# #", "#####", "# #", "# #", "# #"), + "b": ("#### ", "# #", "#### ", "# #", "# #", "# #", "#### "), + "c": (" ####", "# ", "# ", "# ", "# ", "# ", " ####"), + "d": ("#### ", "# #", "# #", "# #", "# #", "# #", "#### "), + "e": ("#####", "# ", "#### ", "# ", "# ", "# ", "#####"), + "f": ("#####", "# ", "#### ", "# ", "# ", "# ", "# "), + "g": (" ####", "# ", "# ", "# ##", "# #", "# #", " ####"), + "h": ("# #", "# #", "#####", "# #", "# #", "# #", "# #"), + "i": ("#####", " # ", " # ", " # ", " # ", " # ", "#####"), + "j": (" #", " #", " #", " #", " #", "# #", " ### "), + "k": ("# #", "# # ", "# # ", "## ", "# # ", "# # ", "# #"), + "l": ("# ", "# ", "# ", "# ", "# ", "# ", "#####"), + "m": ("# #", "## ##", "# # #", "# #", "# #", "# #", "# #"), + "n": ("# #", "## #", "# # #", "# # #", "# ##", "# #", "# #"), + "o": (" ### ", "# #", "# #", "# #", "# #", "# #", " ### "), + "p": ("#### ", "# #", "# #", "#### ", "# ", "# ", "# "), + "q": (" ### ", "# #", "# #", "# #", "# # #", "# # ", " ## #"), + "r": ("#### ", "# #", "# #", "#### ", "# # ", "# # ", "# #"), + "s": (" ####", "# ", "# ", " ### ", " #", " #", "#### "), + "t": ("#####", " # ", " # ", " # ", " # ", " # ", " # "), + "u": ("# #", "# #", "# #", "# #", "# #", "# #", " ### "), + "v": ("# #", "# #", "# #", "# #", "# #", " # # ", " # "), + "w": ("# #", "# #", "# #", "# #", "# # #", "# # #", " # # "), + "x": ("# #", "# #", " # # ", " # ", " # # ", "# #", "# #"), + "y": ("# #", "# #", "# #", " # # ", " # ", " # ", " # "), + "z": ("#####", " #", " # ", " # ", " # ", "# ", "#####"), + "0": (" ### ", "# #", "# ##", "# # #", "## #", "# #", " ### "), + "1": (" # ", " ## ", "# # ", " # ", " # ", " # ", "#####"), + "2": (" ### ", "# #", " #", " # ", " # ", " # ", "#####"), + "3": ("#### ", " #", " #", " ### ", " #", " #", "#### "), + "4": ("# #", "# #", "# #", "#####", " #", " #", " #"), + "5": ("#####", "# ", "#### ", " #", " #", "# #", " ### "), + "6": (" ### ", "# ", "# ", "#### ", "# #", "# #", " ### "), + "7": ("#####", " #", " # ", " # ", " # ", " # ", " # "), + "8": (" ### ", "# #", "# #", " ### ", "# #", "# #", " ### "), + "9": (" ### ", "# #", "# #", " ####", " #", " #", " ### "), + "_": (" ", " ", " ", " ", " ", " ", "#####"), + "-": (" ", " ", " ", "#####", " ", " ", " "), + " ": (" ", " ", " ", " ", " ", " ", " "), +} + + +def _draw_label(grid: np.ndarray, top: int, left: int, width: int, height: int, text: str) -> None: + """Paint `text` into the top strip of one grid tile. Uses an inline + 5×7 pixel font to avoid pulling PIL in just for labels.""" + glyph_w, glyph_h = 5, 7 + scale = max(1, (height - 4) // glyph_h) + cursor = left + 4 + baseline = top + (height - glyph_h * scale) // 2 + for ch in text.lower(): + glyph = _GRID_FONT.get(ch) + if glyph is None: + cursor += (glyph_w + 1) * scale + continue + for gy, row in enumerate(glyph): + for gx, cell in enumerate(row): + if cell == "#": + ry = baseline + gy * scale + rx = cursor + gx * scale + grid[ry : ry + scale, rx : rx + scale] = (230, 230, 230) + cursor += (glyph_w + 1) * scale + if cursor >= left + width - glyph_w * scale: + break + + +def _tile_grid(images: list[np.ndarray], labels: list[str], label_height: int = 22) -> np.ndarray: + """Square-ish auto layout. For fixed columns see `_tile_grid_cols`.""" + cols = math.ceil(math.sqrt(len(images))) + return _tile_grid_cols(images, labels, cols=cols, label_height=label_height) + + +def _tile_grid_cols( + images: list[np.ndarray], + labels: list[str], + *, + cols: int, + label_height: int = 22, +) -> np.ndarray: + n = len(images) + rows = math.ceil(n / cols) + tile_h, tile_w, channels = images[0].shape + full_tile_h = tile_h + label_height + grid = np.full((rows * full_tile_h, cols * tile_w, channels), 32, dtype=np.uint8) + for i, (image, label) in enumerate(zip(images, labels, strict=True)): + r, c = divmod(i, cols) + grid[ + r * full_tile_h + label_height : (r + 1) * full_tile_h, + c * tile_w : (c + 1) * tile_w, + ] = image + _draw_label(grid, r * full_tile_h, c * tile_w, tile_w, label_height, label) + return grid + + +@app.command() +def grid( + out: Annotated[Path, typer.Option(help="grid output path (.png)")], + scene: SceneOpt = "data_center", + t: TOpt = 0.0, + cams: Annotated[ + str | None, + typer.Option(help="comma-separated camera names; default = every scene camera"), + ] = None, + no_free_cam: Annotated[ + bool, typer.Option("--no-free-cam/--free-cam", help="omit the free-cam tile") + ] = False, + width: WidthOpt = 480, + height: HeightOpt = 360, + az: AzOpt = 135.0, + el: ElOpt = -20.0, + dist: DistOpt = 2.5, + lookat: LookatOpt = "0.30,0.0,0.9", +) -> None: + """Render several cameras at one sim time and tile them into a grid.""" + ctx = build_scene_and_advance(scene, Seconds(t)) + scene_cams = ( + [c.strip() for c in cams.split(",")] + if cams + else [ + mujoco.mj_id2name(ctx.model, mujoco.mjtObj.mjOBJ_CAMERA, i) or f"cam{i}" + for i in range(ctx.model.ncam) + ] + ) + cameras: list[CameraSpec] = list(scene_cams) + labels = list(scene_cams) + if not no_free_cam: + cameras.append( + build_free_cam( + FreeCameraPose( + azimuth_deg=AzimuthDeg(az), + elevation_deg=ElevationDeg(el), + distance_m=Metres(dist), + lookat=parse_world_point(lookat, field_name="lookat"), + ) + ) + ) + labels.append("free_cam") + images = [ + render_frame(ctx.model, ctx.data, camera=cam, width=width, height=height) for cam in cameras + ] + iio.imwrite(out, _tile_grid(images, labels)) + typer.echo(f"rendered {len(images)} tiles → {out}") + + +# ---- plan -------------------------------------------------------------------- + + +@app.command() +def plan( + scene: SceneOpt = "data_center", +) -> None: + """Print a scene's task plan as a timeline table.""" + ctx = build_scene_and_advance(scene, Seconds(0.0)) + scene_mod = ctx.scene_module + cube_body_ids = [ + mujoco.mj_name2id(ctx.model, mujoco.mjtObj.mjOBJ_BODY, name) + for name in getattr(scene_mod, "GRIPPABLES", ()) + ] + task_plan = scene_mod.make_task_plan(ctx.model, ctx.data, ctx.arms, cube_body_ids) + + label_w = ( + max((len(step.label) for script in task_plan.values() for step in script), default=10) + 1 + ) + widths = { + "side": 7, + "start": 7, + "dur": 5, + "grip": 6, + "label": label_w, + "aplus": 32, + "aminus": 32, + "wplus": 5, + "wminus": 5, + } + header = ( + f"{'side':<{widths['side']}} {'start':>{widths['start']}} " + f"{'dur':>{widths['dur']}} {'grip':<{widths['grip']}} " + f"{'label':<{widths['label']}} {'attach+':<{widths['aplus']}} " + f"{'attach-':<{widths['aminus']}} {'weld+':<{widths['wplus']}} " + f"{'weld-':<{widths['wminus']}}" + ) + typer.echo(header) + typer.echo("-" * len(header)) + for side, script in task_plan.items(): + elapsed = 0.0 + for step in script: + typer.echo(_format_plan_row(side, elapsed, step, widths)) + elapsed += step.duration + typer.echo("") + + +def _format_plan_row(side: str, start_s: float, step, widths: dict[str, int]) -> str: # type: ignore[no-untyped-def] + def fmt_attach(items: tuple[str, ...]) -> str: + return ",".join(str(i) for i in items) if items else "-" + + def fmt_weld(idx: int | None) -> str: + return "-" if idx is None else str(idx) + + return ( + f"{side:<{widths['side']}} " + f"{start_s:>{widths['start']}.2f} " + f"{step.duration:>{widths['dur']}.2f} " + f"{step.gripper:<{widths['grip']}} " + f"{step.label:<{widths['label']}} " + f"{fmt_attach(step.attach_activate):<{widths['aplus']}} " + f"{fmt_attach(step.attach_deactivate):<{widths['aminus']}} " + f"{fmt_weld(step.weld_activate):<{widths['wplus']}} " + f"{fmt_weld(step.weld_deactivate):<{widths['wminus']}}" + ) + + +# ---- diff -------------------------------------------------------------------- + + +@app.command() +def diff( + a: Annotated[Path, typer.Option(help="first PNG")], + b: Annotated[Path, typer.Option(help="second PNG")], + out: Annotated[Path, typer.Option(help="diff heat-map output PNG")], + threshold: Annotated[ + int, + typer.Option(help="per-channel diff threshold for 'changed' stat (0-255)"), + ] = 5, +) -> None: + """Compute a pixel-difference heat-map between two PNG renders.""" + img_a = iio.imread(a) + img_b = iio.imread(b) + if img_a.shape != img_b.shape: + typer.echo( + f"image shapes differ: {img_a.shape} vs {img_b.shape} " + "— re-render both at the same width/height before diffing", + err=True, + ) + raise typer.Exit(code=1) + + abs_diff = np.abs(img_a.astype(np.int16) - img_b.astype(np.int16)).astype(np.uint8) + heatmap = np.minimum(abs_diff.astype(np.int16) * 8, 255).astype(np.uint8) + iio.imwrite(out, heatmap) + max_diff = int(abs_diff.max()) + mean_diff = float(abs_diff.mean()) + changed = (abs_diff > threshold).any(axis=-1) + typer.echo(f"max per-channel diff: {max_diff}") + typer.echo(f"mean per-channel diff: {mean_diff:.2f}") + typer.echo(f"pixels with any-channel diff > {threshold}: {float(changed.mean()) * 100:.1f}%") + typer.echo(f"heat-map → {out}") + + +# ---- ik (feasibility sweep) -------------------------------------------------- + + +_IK_TOL_M = 0.02 +_IK_SEEDS: list[np.ndarray] = [ + np.array([0.0, 1.57, -1.3485, 0.0, 0.2, 0.0]), + np.array([0.5, 1.2, -1.0, 0.0, 0.3, 0.0]), + np.array([-0.5, 1.8, -1.5, 0.0, 0.1, 0.0]), + np.array([0.0, 2.0, -1.8, 0.5, 0.5, 0.0]), + np.array([0.0, 1.0, -0.8, -0.5, -0.2, 0.0]), +] + + +@app.command() +def ik( + scene: SceneOpt = "data_center", +) -> None: + """Probe IK feasibility across a task plan's waypoints. + + For each step's arm_q the tool computes the implied TCP target, + then re-runs IK from a spread of joint seeds. Flags waypoints that + only converge from the luckily-chosen runtime seed — those are + fragile to later layout changes. + """ + from ik import PositionOnly, solve_ik + + ctx = build_scene_and_advance(scene, Seconds(0.0)) + scene_mod = ctx.scene_module + cube_body_ids = [ + mujoco.mj_name2id(ctx.model, mujoco.mjtObj.mjOBJ_BODY, name) + for name in getattr(scene_mod, "GRIPPABLES", ()) + ] + task_plan = scene_mod.make_task_plan(ctx.model, ctx.data, ctx.arms, cube_body_ids) + + typer.echo("status side conv err(mm) tcp(world) label") + typer.echo("-" * 80) + failed = fragile = robust = 0 + for side, script in task_plan.items(): + arm = ctx.arms[side] + for step in script: + for i, idx in enumerate(arm.arm_qpos_idx): + ctx.data.qpos[idx] = step.arm_q[i] + mujoco.mj_forward(ctx.model, ctx.data) + tcp = np.asarray(ctx.data.site_xpos[arm.tcp_site_id], dtype=float).copy() + best_err = np.inf + n_conv = 0 + unreachable = False + for seed in _IK_SEEDS: + try: + _, err = solve_ik( + ctx.model, + ctx.data, + arm, + tcp, + orientation=PositionOnly(), + seed_q=seed, + locked_joint_names=("torso_lift_joint",), + solver="daqp", + ) + except RuntimeError: + unreachable = True + continue + best_err = min(best_err, err) + if err <= _IK_TOL_M: + n_conv += 1 + if best_err > _IK_TOL_M: + status = "FAIL" + failed += 1 + elif unreachable or n_conv < len(_IK_SEEDS): + status = "FRAGILE" + fragile += 1 + else: + status = "OK" + robust += 1 + typer.echo( + f"{status:<8} {side:<7} {n_conv:>2}/{len(_IK_SEEDS)} " + f"{best_err * 1000:>7.2f} ({tcp[0]:+.2f},{tcp[1]:+.2f},{tcp[2]:+.2f}) " + f"{step.label}" + ) + typer.echo("-" * 80) + typer.echo( + f"summary: {failed + fragile + robust} waypoints | " + f"{failed} fail | {fragile} fragile | {robust} robust" + ) + + +# ---- review (regression-catching keyframe grid + video) ---------------------- + + +_REVIEW_KEYFRAMES_DATA_CENTER: tuple[tuple[float, str], ...] = ( + (0.0, "home"), + (5.0, "settle at cables"), + (9.0, "grip cable 1"), + (17.0, "grip cable 2"), + (25.5, "grip cable 3"), + (30.0, "extract old server"), + (33.5, "stow on shelf"), + (38.0, "grip new server"), + (42.0, "install in rack"), + (45.0, "return home"), +) +"""Timestamps + labels of the moments a human reviewer cares about +when judging the data-center demo. Pinned to the scene's task plan; +update if step durations shift materially.""" + + +_REVIEW_ANGLES: tuple[tuple[str, FreeCameraPose], ...] = ( + ( + "3q", + FreeCameraPose( + azimuth_deg=AzimuthDeg(40.0), + elevation_deg=ElevationDeg(-15.0), + distance_m=Metres(2.2), + lookat=(0.30, 0.0, 0.9), + ), + ), + ( + "close", + FreeCameraPose( + azimuth_deg=AzimuthDeg(70.0), + elevation_deg=ElevationDeg(-10.0), + distance_m=Metres(1.2), + lookat=(0.40, -0.10, 0.88), + ), + ), +) +"""Two canonical free-cam poses: a wide 3/4 overview and a closer +gripper's-eye angle at the rack. Enough to catch "arm is too high" or +"something is protruding" without multi-window orbiting.""" + + +@app.command() +def review( + out_dir: Annotated[ + Path, typer.Option(help="directory to write review.png + review.mp4 into") + ] = Path("/tmp"), + scene: SceneOpt = "data_center", + video_fps: Annotated[ + float, typer.Option(help="video playback fps (frames sampled every 0.5 s sim-time)") + ] = 10.0, + video_end: Annotated[ + float, typer.Option(help="video covers 0..video_end seconds of sim time") + ] = 45.0, +) -> None: + """One-shot regression render: grid of keyframes from 2 angles + + a short mp4 of the whole task. + + Run after every substantive scene change to catch visual issues — + arm pose wrong, compartment clipping, shelf protruding, etc. — + before they surface when the user opens the viewer. + + Outputs: + {out_dir}/review.png — (rows = keyframes) x (cols = angles) grid + {out_dir}/review.mp4 — timelapse at `video_fps` from 0..video_end + """ + out_dir.mkdir(parents=True, exist_ok=True) + width, height = 560, 420 + + # --- keyframe grid --- + images: list[np.ndarray] = [] + labels: list[str] = [] + ctx = build_scene_and_advance(scene, Seconds(0.0)) + if ctx.task_plan is None: + raise typer.BadParameter(f"scene {scene!r} has no make_task_plan") + aux_name_to_id = { + name: mujoco.mj_name2id(ctx.model, mujoco.mjtObj.mjOBJ_ACTUATOR, name) + for name in getattr(ctx.scene_module, "AUX_ACTUATOR_NAMES", ()) + } + sim_dt = float(ctx.model.opt.timestep) + prev_t = 0.0 + + for t, label in _REVIEW_KEYFRAMES_DATA_CENTER: + dt = t - prev_t + if dt > 0: + advance_timeline( + ctx.model, + ctx.data, + ctx.arms, + ctx.task_plan, + aux_name_to_id, + sim_dt, + Seconds(dt), + ) + mujoco.mj_forward(ctx.model, ctx.data) + prev_t = t + for angle_name, pose in _REVIEW_ANGLES: + img = render_frame( + ctx.model, + ctx.data, + camera=build_free_cam(pose), + width=width, + height=height, + ) + images.append(img) + labels.append(f"t={t:05.1f} {angle_name} — {label}") + + # Fixed 2-column layout (one per angle) so the review reads + # top-to-bottom chronologically — visually easier than the + # square-ish auto-layout `_tile_grid` defaults to. + grid_img = _tile_grid_cols(images, labels, cols=len(_REVIEW_ANGLES)) + grid_path = out_dir / "review.png" + iio.imwrite(grid_path, grid_img) + typer.echo(f"grid → {grid_path} ({len(images)} tiles)") + + # --- video timelapse --- + ctx = build_scene_and_advance(scene, Seconds(0.0)) # reset sim for clean advance + if ctx.task_plan is None: + raise typer.BadParameter(f"scene {scene!r} has no make_task_plan") + video_task_plan = ctx.task_plan + prev_t = 0.0 + frames: list[np.ndarray] = [] + step_s = 1.0 / video_fps + for target_t in np.arange(0.0, video_end + 1e-9, step_s): + dt = float(target_t) - prev_t + if dt > 0: + advance_timeline( + ctx.model, + ctx.data, + ctx.arms, + video_task_plan, + aux_name_to_id, + sim_dt, + Seconds(dt), + ) + mujoco.mj_forward(ctx.model, ctx.data) + # Use the wide 3/4 angle for video — easiest for a human + # reviewer to track the arms against a fixed reference frame. + img = render_frame( + ctx.model, + ctx.data, + camera=build_free_cam(_REVIEW_ANGLES[0][1]), + width=640, + height=480, + ) + frames.append(img) + prev_t = float(target_t) + + video_path = out_dir / "review.mp4" + iio.imwrite(video_path, frames, fps=int(video_fps), codec="libx264") + typer.echo(f"video → {video_path} ({len(frames)} frames @ {video_fps} fps)") + + +if __name__ == "__main__": + app() diff --git a/experiments/bimanual_sim/uv.lock b/experiments/bimanual_sim/uv.lock new file mode 100644 index 0000000..58fec6c --- /dev/null +++ b/experiments/bimanual_sim/uv.lock @@ -0,0 +1,1654 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "absl-py" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/c7/8de93764ad66968d19329a7e0c147a2bb3c7054c554d4a119111b8f9440f/absl_py-2.4.0.tar.gz", hash = "sha256:8c6af82722b35cf71e0f4d1d47dcaebfff286e27110a99fc359349b247dfb5d4", size = 116543, upload-time = "2026-01-28T10:17:05.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/a6/907a406bb7d359e6a63f99c313846d9eec4f7e6f7437809e03aa00fa3074/absl_py-2.4.0-py3-none-any.whl", hash = "sha256:88476fd881ca8aab94ffa78b7b6c632a782ab3ba1cd19c9bd423abc4fb4cd28d", size = 135750, upload-time = "2026-01-28T10:17:04.19Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + +[[package]] +name = "cmeel" +version = "0.59.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/58/2448af92b3761a1b321014a653f79d322026681728f96ebe9f419ae0d6b8/cmeel-0.59.0.tar.gz", hash = "sha256:d9871f96ad0499c1cf8671e69622c805265a6be4383a1abfd18f20b4a33e3e3a", size = 14890, upload-time = "2026-01-19T11:48:25.431Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/c7/f7a2ea2e88cba4828c9b5bba5b8448ad6e6cbd652d782cc97bb14a54e6a6/cmeel-0.59.0-py3-none-any.whl", hash = "sha256:04a24b960e602484306721ce148610ddda4cbc83b8c5f27ef915366a86901e06", size = 20991, upload-time = "2026-01-19T11:48:24.259Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "colorlog" +version = "6.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/61/f083b5ac52e505dfc1c624eafbf8c7589a0d7f32daa398d2e7590efa5fda/colorlog-6.10.1.tar.gz", hash = "sha256:eb4ae5cb65fe7fec7773c2306061a8e63e02efc2c72eba9d27b0fa23c94f1321", size = 17162, upload-time = "2025-10-16T16:14:11.978Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/c1/e419ef3723a074172b68aaa89c9f3de486ed4c2399e2dbd8113a4fdcaf9e/colorlog-6.10.1-py3-none-any.whl", hash = "sha256:2d7e8348291948af66122cff006c9f8da6255d224e7cf8e37d8de2df3bad8c9c", size = 11743, upload-time = "2025-10-16T16:14:10.512Z" }, +] + +[[package]] +name = "daqp" +version = "0.8.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/de/02ddf33b0de61788b3d7110febeb332837332ebdc4f1b83c1bf1f405efc4/daqp-0.8.5.tar.gz", hash = "sha256:71b0df979de85874bbed01c687a94e93926f445b4dddd236dcb3cea82431ab68", size = 38347, upload-time = "2026-03-25T15:35:31.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/27/b9fb5605e43e0dc81d5cb5d550748e9e50a46cf606b5fb86fb3134e6aac7/daqp-0.8.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:27ab6c5ef287f39341e293a9d62dd2bd33905282cbb67bc266376c7f443587a7", size = 159454, upload-time = "2026-03-25T15:41:34.718Z" }, + { url = "https://files.pythonhosted.org/packages/f2/87/5cbc7f502d4eff2b8b5febea3ca4e4f1740e94c9c3c2adedfcf227896116/daqp-0.8.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9d7ee70aedaff225f54c7bc0d1cf845d4daf0cb75bfe851938e41f8ddcc6139", size = 151863, upload-time = "2026-03-25T15:41:35.68Z" }, + { url = "https://files.pythonhosted.org/packages/44/df/0fc09338f399be72a0f673b48048969d684b41f3f91f048df6439ca355e9/daqp-0.8.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ff4037b0034a9c390c702257baf63658abaefcfd4c487add1a876ec3d24c7a4f", size = 869531, upload-time = "2026-03-25T15:35:23.397Z" }, + { url = "https://files.pythonhosted.org/packages/64/e1/e454621c1508612a1b104099bf481f53d8a6e53986bc3d9d1dd4e5a38643/daqp-0.8.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5e86cd57ff5967a7c521cf6c5a0c539a363ec91519d007f29339f9b47d770ec", size = 799295, upload-time = "2026-03-25T16:29:31.797Z" }, + { url = "https://files.pythonhosted.org/packages/00/ab/97a87306f05fc7d5b8559c745d875b69b6d0224d979b8aed5302e60fb024/daqp-0.8.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7d42299e571741b5bcd7f3bd0c65776b31d2b9384bb5283b3dbc06e6a775f9ed", size = 814955, upload-time = "2026-03-25T16:29:33.132Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ed/bbe2e0e89937c52eef6850fb26d7d41f80b2611cce7668182dc00df2806a/daqp-0.8.5-cp312-cp312-win32.whl", hash = "sha256:23007128dea8e12251cb89c2ee413a1ac192411bf31da6b03536594e6c696f9b", size = 107886, upload-time = "2026-03-25T15:43:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/95/9b/5bf665ca248b28b462a2f65dc45377d6ddef7092bd0c5f4090a073f96267/daqp-0.8.5-cp312-cp312-win_amd64.whl", hash = "sha256:259453ed06e33aa16ee263bb572e4330fe85db1e42aecd161c2ec6becee523b1", size = 131016, upload-time = "2026-03-25T15:43:52.729Z" }, + { url = "https://files.pythonhosted.org/packages/48/fb/1377bef31820345feb529e2aa45f84e6fe7ce1ec576788c339958002614b/daqp-0.8.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b01691085d260a8556e40f0a82754f1c606e02d7af635fbc421b7360dcc999f5", size = 158609, upload-time = "2026-03-25T15:41:36.501Z" }, + { url = "https://files.pythonhosted.org/packages/6a/71/c42534bde5fed26e5d0d76460c5e352a3d0fed1b3e5040f594aa2c4b81fd/daqp-0.8.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b3057a6ede1b12df573154940396b92ea1f817d50be8916b97306cf08e94ec9b", size = 151174, upload-time = "2026-03-25T15:41:37.965Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c8/ef39aae02c4700bd49d37755ad3f26baacbc3566bd9112fdbfbd38f6e5bf/daqp-0.8.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:398801c4e7b4a74603ad3267bee622f6e66e9a2a0c652bc891d85629a8e35233", size = 863750, upload-time = "2026-03-25T15:35:25.017Z" }, + { url = "https://files.pythonhosted.org/packages/73/92/fd962d6bb8d58a27034fe42bf7aff20f1372e40c3f0a6d0790ea3cec78cf/daqp-0.8.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccd6dbd04ffe15bb5cc5b678d05d33b124bbfc5094628f8b2393260e83e6e4f5", size = 789928, upload-time = "2026-03-25T16:29:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c6/237304b9000da5889e92aed555d7a1d236fd5ff2ba0284f4db5318ff06f9/daqp-0.8.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b202fc244de7b83e5a44a6f3bc9eea9ff73395ad9fdb52588bb637001a8e9f2c", size = 809923, upload-time = "2026-03-25T16:29:35.384Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a1/d4e73f3305e8cab0e8d7c3a0c8c4d3d4f0ce39b1056f9c14b6036662fc99/daqp-0.8.5-cp313-cp313-win32.whl", hash = "sha256:8cb1cc6e1178f2dd2e4ea494d4b77374850ad8830181d6c7d4e03fb844f3271c", size = 107747, upload-time = "2026-03-25T15:43:53.505Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a5/3170ee3b1d1cb6091392fb485642080156890ce9b44353b8726547b85725/daqp-0.8.5-cp313-cp313-win_amd64.whl", hash = "sha256:631ddb94c8a3937aa5b6be01e787dfb9b4a837c84a60053a85262a66a0c76c04", size = 130919, upload-time = "2026-03-25T15:43:54.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/1b/1ccc8a48dfe79ca7fa83b98055166bb5b41385c53597985116d4585b5e8d/daqp-0.8.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:23191c7371ab6945fc6480bc92a8c1f9fa9f2f0372bea48436d7da27ce6b5931", size = 159538, upload-time = "2026-03-25T15:41:39.164Z" }, + { url = "https://files.pythonhosted.org/packages/d4/b2/fdd067e30e6fb6396322d82eeb56456e8e9e279f3b461c203f36840c8f71/daqp-0.8.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0dfbba68c2636e92592fa29d496faff11e9df6c08716892fb45377baa088b64e", size = 152463, upload-time = "2026-03-25T15:41:40.236Z" }, + { url = "https://files.pythonhosted.org/packages/bb/76/8e02b0deb5c307b5516bc3c6fbb1334a00810a07ddbe435f982894a7e9fd/daqp-0.8.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b1860a542a8187b0b8241d012a4ac181e221a33ad84892f061f9e26c8b8a4066", size = 854853, upload-time = "2026-03-25T15:35:26.281Z" }, + { url = "https://files.pythonhosted.org/packages/fe/b0/0fb859dbba9eb9b7fffdde2208ab15b5edc0619a3620d7341071184b2cd9/daqp-0.8.5-cp314-cp314-win32.whl", hash = "sha256:3bf678a82179addb980fbf34a5057041120b953a71321836374baa50814be0a9", size = 111265, upload-time = "2026-03-25T15:43:55.166Z" }, + { url = "https://files.pythonhosted.org/packages/9e/76/a37cb3dd1c77a3ec813f5d9a7e9be70e5f55f3ae073801f91aee6a14b81a/daqp-0.8.5-cp314-cp314-win_amd64.whl", hash = "sha256:d9d0b2f56ad6db872c45f262da63294b9eb110c993835b4317aaaf9a0582f135", size = 134427, upload-time = "2026-03-25T15:43:55.941Z" }, + { url = "https://files.pythonhosted.org/packages/ac/07/5d37549de7548b6848791bf5d806fed1e148cd716a85eccaccf59bb3389a/daqp-0.8.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8473377aaff3d66a410433862c7c7e756a78287bae5316f3a305400e19aab6f1", size = 167092, upload-time = "2026-03-25T15:41:41.36Z" }, + { url = "https://files.pythonhosted.org/packages/b2/08/d9f5ad49d0412723bf6a10702d2e2b5012a102f6bf0934b8d4f2b38bb085/daqp-0.8.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1d105c9843d7dd9d4104e09c390d48025091a00b57eec5e7f30fd875d261d8af", size = 160898, upload-time = "2026-03-25T15:41:42.555Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e9/f62e1ca2a23f03d248a0430859c3e699ff0bf7eb9abf8f546adb78f58f65/daqp-0.8.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:544f3998bb5ec650e1fe9165e1e74b18aad867cade6d6a073126839eedaedef0", size = 833015, upload-time = "2026-03-25T15:35:27.574Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d5/8c108cc91ba3d82110f31fea2c1ad7c3763e70d87f4a3e4e71329f3ab317/daqp-0.8.5-cp314-cp314t-win32.whl", hash = "sha256:37bcf3aabea4df2727b32262eb6b704b143953da1f77108194a2fcb750727e20", size = 124294, upload-time = "2026-03-25T15:43:56.987Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/c5cb882c5155ff18b891328f5376d01458e91d6aee079ce227aa2f057f5f/daqp-0.8.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d5b21e42c69d6e7639a55ecc07d10802af9c39ff69c0f22376cb4b022911c70a", size = 154159, upload-time = "2026-03-25T15:43:58.03Z" }, +] + +[[package]] +name = "embreex" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/04/b3413ba4f1c17f2374cc39b5b86404221aedc632c8b6cdb484697eeffcd8/embreex-4.4.0-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:3bb261a25c21d50bc7f046401e7256e59e43a500b229fb2a00b6393c61e5293d", size = 5097518, upload-time = "2026-04-22T19:46:51.379Z" }, + { url = "https://files.pythonhosted.org/packages/6d/78/8cc0960cc0f2d60d581869a66c8013e1bf1c73bf5bf9609bd8aa79e0f721/embreex-4.4.0-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:8e93bce7cf905365117dea2d0726d19262c88a010044d00631db6bb7dc145612", size = 13214768, upload-time = "2026-04-22T19:46:54.088Z" }, + { url = "https://files.pythonhosted.org/packages/79/b0/05a5b4d49749602b12e13d1871f8e6d1fe6db806eda75f6f57bb4f1acf6f/embreex-4.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cb0872f5bc231f465b840b122847acbaf468ac48f49bfaff127c5347ec0db94f", size = 14529899, upload-time = "2026-04-22T19:46:56.824Z" }, + { url = "https://files.pythonhosted.org/packages/b5/96/625e035f3433071c91de07e66265a261be7bb708367f785000f93d7a992a/embreex-4.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:6c092ee1adf5c48b7430c7ae3902943863745e54b4aef4327ecb3473e0a299d7", size = 13119305, upload-time = "2026-04-22T19:46:59.329Z" }, + { url = "https://files.pythonhosted.org/packages/9b/58/56b0ef72a128da86892f89f61f0c91bf60c6ea20c2ab952cb88e05c4e89b/embreex-4.4.0-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:096cffa6be78966b0214b26f09fc22cb5ebcfbd4fe9fdc27caf6400869058a51", size = 5094712, upload-time = "2026-04-22T19:47:02.112Z" }, + { url = "https://files.pythonhosted.org/packages/36/d2/9bbde8d9d520c2a7bcad892631165b9676d9dcbde381f25bfda06d0f6e42/embreex-4.4.0-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:078bae4dcebb2a64c8dde6b3c178f258f966c4514e265608620033a6c907e21b", size = 13212105, upload-time = "2026-04-22T19:47:04.511Z" }, + { url = "https://files.pythonhosted.org/packages/03/5d/3e5ca5dea1c2c5b4604f3bedb67ea4beaa465398d9c04d8124ca3d657b05/embreex-4.4.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd305875e2cd7c51004a423fe7da9881fa266fa4a50e61dd546978e10f020e66", size = 14486730, upload-time = "2026-04-22T19:47:07.229Z" }, + { url = "https://files.pythonhosted.org/packages/45/00/26741306e557129d398744601cd9bca4069d52cebe146ba99e535f9f2c65/embreex-4.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:433de9063843804d70bb3c1680619bb28bc6403b79d3d94560ec9acc3df96eda", size = 13117246, upload-time = "2026-04-22T19:47:10.08Z" }, + { url = "https://files.pythonhosted.org/packages/62/70/4ed53f33ab0f0dca32f8017b903f5dcad25df32f7068854e529902aa97da/embreex-4.4.0-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:1ef2650f1ced083d433d46389a187fd57f7bf795106ff79a2671e95f2e6a4c4c", size = 5095722, upload-time = "2026-04-22T19:47:12.774Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ac/638a6b4a6cb67125a3c2676a108fb73d75e67b3ce3813f25b6056d0b77cd/embreex-4.4.0-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:ab3c50ecd3aa6c39491928d975b00c9f5eeaa1b39b74bab87170c17e2df1bfde", size = 13212439, upload-time = "2026-04-22T19:47:15.009Z" }, + { url = "https://files.pythonhosted.org/packages/39/1c/567194e9f5bdbb5099144dae3202452821319ff348e328b50c0fbacc3828/embreex-4.4.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b0bd41b10dc2f1ad4995faef08d924d5f33a5d47afb97ba5801c820f305db6d", size = 14480771, upload-time = "2026-04-22T19:47:17.681Z" }, + { url = "https://files.pythonhosted.org/packages/d1/8d/a46fb6ca4cc898e8d06449a4b46a8c22a28971f1e6d9bb6c99459bbb96d1/embreex-4.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:80f938291832ab11dc7a604d609bce2587d574b4fe862be91d117562629f1b94", size = 13373573, upload-time = "2026-04-22T19:47:21.487Z" }, + { url = "https://files.pythonhosted.org/packages/d2/71/4e21eff598840060229cc502034737d04d2a42dee8118c7d5acf6ee6f28a/embreex-4.4.0-cp314-cp314t-macosx_13_0_arm64.whl", hash = "sha256:670470a53fdde1aeb52c4d8db600f17bc0dc7c34dc6f90233909db9c6fe1e88a", size = 5102482, upload-time = "2026-04-22T19:47:23.67Z" }, + { url = "https://files.pythonhosted.org/packages/61/5e/da7c1448c209f406a5b43a8feb07489e8652a64c48450b5642d3f34cf9e7/embreex-4.4.0-cp314-cp314t-macosx_13_0_x86_64.whl", hash = "sha256:ea332cfe60f3b242ecb557ef7b5fcbffec5edd0f3127241bed343d090ac735e2", size = 13218445, upload-time = "2026-04-22T19:47:25.847Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d4/8cb8a41b25138999a98286ae1562e5903c7579ec71becc05bfa1c10d571f/embreex-4.4.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e3481b76df80f23128173486ac0efe55e9199fc311bc74707452b4f19951eff", size = 14589303, upload-time = "2026-04-22T19:47:28.544Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/8b393b35016909143ecac7bf7bf418f79236e1f83faf3867b5c5cb50c97f/embreex-4.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a2fedc756a36729da6803425398aa4987b27d1d89e9fad403d8ef371fad5ca01", size = 13389585, upload-time = "2026-04-22T19:47:31.398Z" }, +] + +[[package]] +name = "etils" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/ce/6e067242fde898841922ac6fc82b0bb2fe35c38e995880bdffdfbe30182a/etils-1.14.0.tar.gz", hash = "sha256:8136e7f4c4173cd0af0ca5481c4475152f0b8686192951eefa60ee8711e1ede4", size = 108127, upload-time = "2026-03-04T17:41:36.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/3d/589663aeeacd59bb2f3e8596bfd3e81cf0fb18d70bb433199041f469771b/etils-1.14.0-py3-none-any.whl", hash = "sha256:b5df7341f54dbe1405a4450b2741207b4a8c279780402b45f87202b94dfc52b4", size = 172934, upload-time = "2026-03-04T17:41:35.01Z" }, +] + +[package.optional-dependencies] +epath = [ + { name = "fsspec" }, + { name = "typing-extensions" }, + { name = "zipp" }, +] + +[[package]] +name = "fsspec" +version = "2026.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/cf/b50ddf667c15276a9ab15a70ef5f257564de271957933ffea49d2cdbcdfb/fsspec-2026.3.0.tar.gz", hash = "sha256:1ee6a0e28677557f8c2f994e3eea77db6392b4de9cd1f5d7a9e87a0ae9d01b41", size = 313547, upload-time = "2026-03-27T19:11:14.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl", hash = "sha256:d2ceafaad1b3457968ed14efa28798162f1638dbb5d2a6868a2db002a5ee39a4", size = 202595, upload-time = "2026-03-27T19:11:13.595Z" }, +] + +[[package]] +name = "glfw" +version = "2.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/72/642d4f12f61816ac96777f7360d413e3977a7dd08237d196f02da681b186/glfw-2.10.0.tar.gz", hash = "sha256:801e55d8581b34df9aa2cfea43feb06ff617576e2a8cc5dac23ee75b26d10abe", size = 31475, upload-time = "2025-09-12T08:54:38.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/1f/a9ce08b1173b0ab625ee92f0c47a5278b3e76fd367699880d8ee7d56c338/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.p39.p310.p311.p312.p313-none-macosx_10_6_intel.whl", hash = "sha256:5f365a8c94bcea71ec91327e7c16e7cf739128479a18b8c1241b004b40acc412", size = 105329, upload-time = "2025-09-12T08:54:27.938Z" }, + { url = "https://files.pythonhosted.org/packages/7c/96/5a2220abcbd027eebcf8bedd28207a2de168899e51be13ba01ebdd4147a1/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.p39.p310.p311.p312.p313-none-macosx_11_0_arm64.whl", hash = "sha256:5328db1a92d07abd988730517ec02aa8390d3e6ef7ce98c8b57ecba2f43a39ba", size = 102179, upload-time = "2025-09-12T08:54:29.163Z" }, + { url = "https://files.pythonhosted.org/packages/9d/41/a5bd1d9e1808f400102bd7d328c4ac17b65fb2fc8014014ec6f23d02f662/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.p39.p310.p311.p312.p313-none-manylinux2014_aarch64.whl", hash = "sha256:312c4c1dd5509613ed6bc1e95a8dbb75a36b6dcc4120f50dc3892b40172e9053", size = 230039, upload-time = "2025-09-12T08:54:30.201Z" }, + { url = "https://files.pythonhosted.org/packages/80/aa/3b503c448609dee6cb4e7138b4109338f0e65b97be107ab85562269d378d/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.p39.p310.p311.p312.p313-none-manylinux2014_x86_64.whl", hash = "sha256:59c53387dc08c62e8bed86bbe3a8d53ab1b27161281ffa0e7f27b64284e2627c", size = 241984, upload-time = "2025-09-12T08:54:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/ac/2d/bfe39a42cad8e80b02bf5f7cae19ba67832c1810bbd3624a8e83153d74a4/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.p39.p310.p311.p312.p313-none-manylinux_2_28_aarch64.whl", hash = "sha256:c6f292fdaf3f9a99e598ede6582d21c523a6f51f8f5e66213849101a6bcdc699", size = 231052, upload-time = "2025-09-12T08:54:32.859Z" }, + { url = "https://files.pythonhosted.org/packages/f7/02/6e639e90f181dc9127046e00d0528f9f7ad12d428972e3a5378b9aefdb0b/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.p39.p310.p311.p312.p313-none-manylinux_2_28_x86_64.whl", hash = "sha256:7916034efa867927892635733a3b6af8cd95ceb10566fd7f1e0d2763c2ee8b12", size = 243525, upload-time = "2025-09-12T08:54:34.006Z" }, + { url = "https://files.pythonhosted.org/packages/84/06/cb588ca65561defe0fc48d1df4c2ac12569b81231ae4f2b52ab37007d0bd/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.p39.p310.p311.p312.p313-none-win32.whl", hash = "sha256:6c9549da71b93e367b4d71438798daae1da2592039fd14204a80a1a2348ae127", size = 552685, upload-time = "2025-09-12T08:54:35.723Z" }, + { url = "https://files.pythonhosted.org/packages/86/27/00c9c96af18ac0a5eac2ff61cbe306551a2d770d7173f396d0792ee1a59e/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.p39.p310.p311.p312.p313-none-win_amd64.whl", hash = "sha256:6292d5d6634d668cd23d337e6089491d3945a9aa4ac6e1667b0003520d7caa51", size = 559466, upload-time = "2025-09-12T08:54:37.661Z" }, + { url = "https://files.pythonhosted.org/packages/b3/87/de0b33f6f00687499ca1371f22aa73396341b85bf88f1a284f9da8842493/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.py39.py310.py311.py312.py313.py314-none-macosx_10_6_intel.whl", hash = "sha256:2aab89d2d9535635ba011fc7303390685169a1aa6731ad580d08d043524b8899", size = 105326, upload-time = "2026-01-28T05:57:56.083Z" }, + { url = "https://files.pythonhosted.org/packages/b6/a6/6ea2f73ad4474896d9e38b3ffbe6ffd5a802c738392269e99e8c6621a461/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.py39.py310.py311.py312.py313.py314-none-macosx_11_0_arm64.whl", hash = "sha256:23936202a107039b5372f0b88ae1d11080746aa1c78910a45d4a0c4cf408cfaa", size = 102180, upload-time = "2026-01-28T05:57:57.787Z" }, + { url = "https://files.pythonhosted.org/packages/58/19/d81b19e8261b9cb51b81d1402167791fef81088dfe91f0c4e9d136fdc5ca/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.py39.py310.py311.py312.py313.py314-none-manylinux2014_aarch64.whl", hash = "sha256:7be06d0838f61df67bd54cb6266a6193d54083acb3624ff3c3812a6358406fa4", size = 230038, upload-time = "2026-01-28T05:57:59.105Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/b035636cd82198b97b51a93efe9cfc4343d6b15cefbd336a3f2be871d848/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.py39.py310.py311.py312.py313.py314-none-manylinux2014_x86_64.whl", hash = "sha256:91d36b3582a766512eff8e3b5dcc2d3ffcbf10b7cf448551085a08a10f1b8244", size = 241983, upload-time = "2026-01-28T05:58:00.352Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b4/f7b6cc022dd7c68b6c702d19da5d591f978f89c958b9bd3090615db0c739/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.py39.py310.py311.py312.py313.py314-none-manylinux_2_28_aarch64.whl", hash = "sha256:27c9e9a2d5e1dc3c9e3996171d844d9df9a5a101e797cb94cce217b7afcf8fd9", size = 231053, upload-time = "2026-01-28T05:58:01.683Z" }, + { url = "https://files.pythonhosted.org/packages/5a/3f/efeb7c6801c46e11bd666a5180f0d615f74f72264212f74f39586c6fda9d/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.py39.py310.py311.py312.py313.py314-none-manylinux_2_28_x86_64.whl", hash = "sha256:ce6724bb7cb3d0543dcba17206dce909f94176e68220b8eafee72e9f92bcf542", size = 243522, upload-time = "2026-01-28T05:58:03.517Z" }, + { url = "https://files.pythonhosted.org/packages/cf/b9/b04c3aa0aad2870cfe799f32f8b59789c98e1816bbce9e83f4823c5b840b/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.py39.py310.py311.py312.py313.py314-none-win32.whl", hash = "sha256:fca724a21a372731edb290841edd28a9fb1ee490f833392752844ac807c0086a", size = 552682, upload-time = "2026-01-28T05:58:05.649Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e1/6d6816b296a529ac9b897ad228b1e084eb1f92319e96371880eebdc874a6/glfw-2.10.0-py2.py27.py3.py30.py31.py32.py33.py34.py35.py36.py37.py38.py39.py310.py311.py312.py313.py314-none-win_amd64.whl", hash = "sha256:823c0bd7770977d4b10e0ed0aef2f3682276b7c88b8b65cfc540afce5951392f", size = 559464, upload-time = "2026-01-28T05:58:07.261Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a8/d4dab8a58fc2e6981fc7a58c4e56ba9d777fb24931cec6a22152edbb3540/glfw-2.10.0-py2.py3-none-macosx_10_6_intel.whl", hash = "sha256:a0d1f29f206219cc291edfb6cace663a86da2470632551c998e3db82d48ea177", size = 105288, upload-time = "2026-03-10T17:21:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/14/61/68d35e001872a7705112418da236fa2418d4f2e5419f8b2837f9b81bb3da/glfw-2.10.0-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:d28d6f3ef217e64e35dc6fd0a7acb4cec9bfe7cd14dd9b35a7228a87002de154", size = 102139, upload-time = "2026-03-10T17:21:21.645Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e1/ca5984081aaae07c9d371cb11dc4e4ff603510678ed9b73e58b6c351fe63/glfw-2.10.0-py2.py3-none-manylinux2014_aarch64.whl", hash = "sha256:f968b522bb6a0e04aaf4dcac30a476d7229308bb2bac406a60587debb5a61e29", size = 229998, upload-time = "2026-03-10T17:21:23.549Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c4/82ac75fdcfba2896da7a573c0fc7f8ceb8f77ead6866d500d06c32f1c464/glfw-2.10.0-py2.py3-none-manylinux2014_x86_64.whl", hash = "sha256:68cf3752bdadb6f4bc0a876247c28c88c7251ac39f8af076ed938fdfd71e72dd", size = 241944, upload-time = "2026-03-10T17:21:26.102Z" }, + { url = "https://files.pythonhosted.org/packages/e3/96/9f691823cca5eb6a08f346bd0ff03b78032db9370b509a1e9c8976fb20a5/glfw-2.10.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:44d98de5dbf8f727e0cb29f9b29d29528ea7570f2e6f42f8430a69df05f12b48", size = 231009, upload-time = "2026-03-10T17:21:28.481Z" }, + { url = "https://files.pythonhosted.org/packages/3f/93/977b9e679e356871d428ae7a1139ec767dd5177bed58a6344b4d2199e00f/glfw-2.10.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:cca5158d62189e08792b1ae54f92307a282921a0e7783315b467e21b0a381c88", size = 243480, upload-time = "2026-03-10T17:21:30.538Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bd/cea9569c8f2188b0a104472951420434a3e1f5cf26f5836ef9d7227a1a30/glfw-2.10.0-py2.py3-none-win32.whl", hash = "sha256:5e024509989740e8e7b86cc4aab508195495f79879072b0e1f68bd036a2916ad", size = 552641, upload-time = "2026-03-10T17:21:32.653Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9b/4366ad3e1c0688146c70aa6143584d6a8d88583b9390f106250e25a3d5cd/glfw-2.10.0-py2.py3-none-win_amd64.whl", hash = "sha256:7f787ee8645781f10e8800438ce4357ab38c573ffb191aba380c1e72eba6311c", size = 559423, upload-time = "2026-03-10T17:21:34.766Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, +] + +[[package]] +name = "imageio" +version = "2.37.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/84/93bcd1300216ea50811cee96873b84a1bebf8d0489ffaf7f2a3756bab866/imageio-2.37.3.tar.gz", hash = "sha256:bbb37efbfc4c400fcd534b367b91fcd66d5da639aaa138034431a1c5e0a41451", size = 389673, upload-time = "2026-03-09T11:31:12.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/fa/391e437a34e55095173dca5f24070d89cbc233ff85bf1c29c93248c6588d/imageio-2.37.3-py3-none-any.whl", hash = "sha256:46f5bb8522cd421c0f5ae104d8268f569d856b29eb1a13b92829d1970f32c9f0", size = 317646, upload-time = "2026-03-09T11:31:10.771Z" }, +] + +[package.optional-dependencies] +ffmpeg = [ + { name = "imageio-ffmpeg" }, + { name = "psutil" }, +] + +[[package]] +name = "imageio-ffmpeg" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/bd/c3343c721f2a1b0c9fc71c1aebf1966a3b7f08c2eea8ed5437a2865611d6/imageio_ffmpeg-0.6.0.tar.gz", hash = "sha256:e2556bed8e005564a9f925bb7afa4002d82770d6b08825078b7697ab88ba1755", size = 25210, upload-time = "2025-01-16T21:34:32.747Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/58/87ef68ac83f4c7690961bce288fd8e382bc5f1513860fc7f90a9c1c1c6bf/imageio_ffmpeg-0.6.0-py3-none-macosx_10_9_intel.macosx_10_9_x86_64.whl", hash = "sha256:9d2baaf867088508d4a3458e61eeb30e945c4ad8016025545f66c4b5aaef0a61", size = 24932969, upload-time = "2025-01-16T21:34:20.464Z" }, + { url = "https://files.pythonhosted.org/packages/40/5c/f3d8a657d362cc93b81aab8feda487317da5b5d31c0e1fdfd5e986e55d17/imageio_ffmpeg-0.6.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b1ae3173414b5fc5f538a726c4e48ea97edc0d2cdc11f103afee655c463fa742", size = 21113891, upload-time = "2025-01-16T21:34:00.277Z" }, + { url = "https://files.pythonhosted.org/packages/33/e7/1925bfbc563c39c1d2e82501d8372734a5c725e53ac3b31b4c2d081e895b/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:1d47bebd83d2c5fc770720d211855f208af8a596c82d17730aa51e815cdee6dc", size = 25632706, upload-time = "2025-01-16T21:33:53.475Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2d/43c8522a2038e9d0e7dbdf3a61195ecc31ca576fb1527a528c877e87d973/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c7e46fcec401dd990405049d2e2f475e2b397779df2519b544b8aab515195282", size = 29498237, upload-time = "2025-01-16T21:34:13.726Z" }, + { url = "https://files.pythonhosted.org/packages/a0/13/59da54728351883c3c1d9fca1710ab8eee82c7beba585df8f25ca925f08f/imageio_ffmpeg-0.6.0-py3-none-win32.whl", hash = "sha256:196faa79366b4a82f95c0f4053191d2013f4714a715780f0ad2a68ff37483cc2", size = 19652251, upload-time = "2025-01-16T21:34:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c6/fa760e12a2483469e2bf5058c5faff664acf66cadb4df2ad6205b016a73d/imageio_ffmpeg-0.6.0-py3-none-win_amd64.whl", hash = "sha256:02fa47c83703c37df6bfe4896aab339013f62bf02c5ebf2dce6da56af04ffc0a", size = 31246824, upload-time = "2025-01-16T21:34:28.6Z" }, +] + +[[package]] +name = "jaxtyping" +version = "0.3.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wadler-lindig" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/be/00294e369938937e31b094437d5ea040e4fd1a20b998ebe572c4a1dcfa68/jaxtyping-0.3.9.tar.gz", hash = "sha256:f8c02d1b623d5f1b6665d4f3ddaec675d70004f16a792102c2fc51264190951d", size = 45857, upload-time = "2026-02-16T10:35:13.263Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/05/3e39d416fb92b2738a76e8265e6bfc5d10542f90a7c32ad1eb831eea3fa3/jaxtyping-0.3.9-py3-none-any.whl", hash = "sha256:a00557a9d616eff157491f06ed2e21ed94886fad3832399273eb912b345da378", size = 56274, upload-time = "2026-02-16T10:35:11.795Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "lxml" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006, upload-time = "2026-04-18T04:32:51.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/d4/9326838b59dc36dfae42eec9656b97520f9997eee1de47b8316aaeed169c/lxml-6.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d2f17a16cd8751e8eb233a7e41aecdf8e511712e00088bf9be455f604cd0d28d", size = 8570663, upload-time = "2026-04-18T04:27:48.253Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a4/053745ce1f8303ccbb788b86c0db3a91b973675cefc42566a188637b7c40/lxml-6.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0cea5b1d3e6e77d71bd2b9972eb2446221a69dc52bb0b9c3c6f6e5700592d93", size = 4624024, upload-time = "2026-04-18T04:27:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/90/97/a517944b20f8fd0932ad2109482bee4e29fe721416387a363306667941f6/lxml-6.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc46da94826188ed45cb53bd8e3fc076ae22675aea2087843d4735627f867c6d", size = 4930895, upload-time = "2026-04-18T04:32:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/94/7c/e08a970727d556caa040a44773c7b7e3ad0f0d73dedc863543e9a8b931f2/lxml-6.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9147d8e386ec3b82c3b15d88927f734f565b0aaadef7def562b853adca45784a", size = 5093820, upload-time = "2026-04-18T04:32:58.94Z" }, + { url = "https://files.pythonhosted.org/packages/88/ee/2a5c2aa2c32016a226ca25d3e1056a8102ea6e1fe308bf50213586635400/lxml-6.1.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5715e0e28736a070f3f34a7ccc09e2fdcba0e3060abbcf61a1a5718ff6d6b105", size = 5005790, upload-time = "2026-04-18T04:33:01.272Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/a0db9be8f38ad6043ab9429487c128dd1d30f07956ef43040402f8da49e8/lxml-6.1.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4937460dc5df0cdd2f06a86c285c28afda06aefa3af949f9477d3e8df430c485", size = 5630827, upload-time = "2026-04-18T04:33:04.036Z" }, + { url = "https://files.pythonhosted.org/packages/31/ba/3c13d3fc24b7cacf675f808a3a1baabf43a30d0cd24c98f94548e9aa58eb/lxml-6.1.0-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc783ee3147e60a25aa0445ea82b3e8aabb83b240f2b95d32cb75587ff781814", size = 5240445, upload-time = "2026-04-18T04:33:06.87Z" }, + { url = "https://files.pythonhosted.org/packages/55/ba/eeef4ccba09b2212fe239f46c1692a98db1878e0872ae320756488878a94/lxml-6.1.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:40d9189f80075f2e1f88db21ef815a2b17b28adf8e50aaf5c789bfe737027f32", size = 5350121, upload-time = "2026-04-18T04:33:09.365Z" }, + { url = "https://files.pythonhosted.org/packages/7e/01/1da87c7b587c38d0cbe77a01aae3b9c1c49ed47d76918ef3db8fc151b1ca/lxml-6.1.0-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:05b9b8787e35bec69e68daf4952b2e6dfcfb0db7ecf1a06f8cdfbbac4eb71aad", size = 4694949, upload-time = "2026-04-18T04:33:11.628Z" }, + { url = "https://files.pythonhosted.org/packages/a1/88/7db0fe66d5aaf128443ee1623dec3db1576f3e4c17751ec0ef5866468590/lxml-6.1.0-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0f08beb0182e3e9a86fae124b3c47a7b41b7b69b225e1377db983802404e54", size = 5243901, upload-time = "2026-04-18T04:33:13.95Z" }, + { url = "https://files.pythonhosted.org/packages/00/a8/1346726af7d1f6fca1f11223ba34001462b0a3660416986d37641708d57c/lxml-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73becf6d8c81d4c76b1014dbd3584cb26d904492dcf73ca85dc8bff08dcd6d2d", size = 5048054, upload-time = "2026-04-18T04:33:16.965Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b7/85057012f035d1a0c87e02f8c723ca3c3e6e0728bcf4cb62080b21b1c1e3/lxml-6.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1ae225f66e5938f4fa29d37e009a3bb3b13032ac57eb4eb42afa44f6e4054e69", size = 4777324, upload-time = "2026-04-18T04:33:19.832Z" }, + { url = "https://files.pythonhosted.org/packages/75/6c/ad2f94a91073ef570f33718040e8e160d5fb93331cf1ab3ca1323f939e2d/lxml-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:690022c7fae793b0489aa68a658822cea83e0d5933781811cabbf5ea3bcfe73d", size = 5645702, upload-time = "2026-04-18T04:33:22.436Z" }, + { url = "https://files.pythonhosted.org/packages/3b/89/0bb6c0bd549c19004c60eea9dc554dd78fd647b72314ef25d460e0d208c6/lxml-6.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63aeafc26aac0be8aff14af7871249e87ea1319be92090bfd632ec68e03b16a5", size = 5232901, upload-time = "2026-04-18T04:33:26.21Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d9/d609a11fb567da9399f525193e2b49847b5a409cdebe737f06a8b7126bdc/lxml-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:264c605ab9c0e4aa1a679636f4582c4d3313700009fac3ec9c3412ed0d8f3e1d", size = 5261333, upload-time = "2026-04-18T04:33:28.984Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3a/ac3f99ec8ac93089e7dd556f279e0d14c24de0a74a507e143a2e4b496e7c/lxml-6.1.0-cp312-cp312-win32.whl", hash = "sha256:56971379bc5ee8037c5a0f09fa88f66cdb7d37c3e38af3e45cf539f41131ac1f", size = 3596289, upload-time = "2026-04-18T04:27:42.819Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a7/0a915557538593cb1bbeedcd40e13c7a261822c26fecbbdb71dad0c2f540/lxml-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:bba078de0031c219e5dd06cf3e6bf8fb8e6e64a77819b358f53bb132e3e03366", size = 3997059, upload-time = "2026-04-18T04:27:46.764Z" }, + { url = "https://files.pythonhosted.org/packages/92/96/a5dc078cf0126fbfbc35611d77ecd5da80054b5893e28fb213a5613b9e1d/lxml-6.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3592631e652afa34999a088f98ba7dfc7d6aff0d535c410bea77a71743f3819", size = 3659552, upload-time = "2026-04-18T04:27:51.133Z" }, + { url = "https://files.pythonhosted.org/packages/08/03/69347590f1cf4a6d5a4944bb6099e6d37f334784f16062234e1f892fdb1d/lxml-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a0092f2b107b69601adf562a57c956fbb596e05e3e6651cabd3054113b007e45", size = 8559689, upload-time = "2026-04-18T04:31:57.785Z" }, + { url = "https://files.pythonhosted.org/packages/3f/58/25e00bb40b185c974cfe156c110474d9a8a8390d5f7c92a4e328189bb60e/lxml-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc7140d7a7386e6b545d41b7358f4d02b656d4053f5fa6859f92f4b9c2572c4d", size = 4617892, upload-time = "2026-04-18T04:32:01.78Z" }, + { url = "https://files.pythonhosted.org/packages/f5/54/92ad98a94ac318dc4f97aaac22ff8d1b94212b2ae8af5b6e9b354bf825f7/lxml-6.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:419c58fc92cc3a2c3fa5f78c63dbf5da70c1fa9c1b25f25727ecee89a96c7de2", size = 4923489, upload-time = "2026-04-18T04:33:31.401Z" }, + { url = "https://files.pythonhosted.org/packages/15/3b/a20aecfab42bdf4f9b390590d345857ad3ffd7c51988d1c89c53a0c73faf/lxml-6.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:37fabd1452852636cf38ecdcc9dd5ca4bba7a35d6c53fa09725deeb894a87491", size = 5082162, upload-time = "2026-04-18T04:33:34.262Z" }, + { url = "https://files.pythonhosted.org/packages/45/26/2cdb3d281ac1bd175603e290cbe4bad6eff127c0f8de90bafd6f8548f0fd/lxml-6.1.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2853c8b2170cc6cd54a6b4d50d2c1a8a7aeca201f23804b4898525c7a152cfc", size = 4993247, upload-time = "2026-04-18T04:33:36.674Z" }, + { url = "https://files.pythonhosted.org/packages/f6/05/d735aef963740022a08185c84821f689fc903acb3d50326e6b1e9886cc22/lxml-6.1.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e369cbd690e788c8d15e56222d91a09c6a417f49cbc543040cba0fe2e25a79e", size = 5613042, upload-time = "2026-04-18T04:33:39.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b8/ead7c10efff731738c72e59ed6eb5791854879fbed7ae98781a12006263a/lxml-6.1.0-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e69aa6805905807186eb00e66c6d97a935c928275182eb02ee40ba00da9623b2", size = 5228304, upload-time = "2026-04-18T04:33:41.647Z" }, + { url = "https://files.pythonhosted.org/packages/6b/10/e9842d2ec322ea65f0a7270aa0315a53abed06058b88ef1b027f620e7a5f/lxml-6.1.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:4bd1bdb8a9e0e2dd229de19b5f8aebac80e916921b4b2c6ef8a52bc131d0c1f9", size = 5341578, upload-time = "2026-04-18T04:33:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/89/54/40d9403d7c2775fa7301d3ddd3464689bfe9ba71acc17dfff777071b4fdc/lxml-6.1.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:cbd7b79cdcb4986ad78a2662625882747f09db5e4cd7b2ae178a88c9c51b3dfe", size = 4700209, upload-time = "2026-04-18T04:33:47.552Z" }, + { url = "https://files.pythonhosted.org/packages/85/b2/bbdcc2cf45dfc7dfffef4fd97e5c47b15919b6a365247d95d6f684ef5e82/lxml-6.1.0-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:43e4d297f11080ec9d64a4b1ad7ac02b4484c9f0e2179d9c4ef78e886e747b88", size = 5232365, upload-time = "2026-04-18T04:33:50.249Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/b06875665e53aaba7127611a7bed3b7b9658e20b22bc2dd217a0b7ab0091/lxml-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cc16682cc987a3da00aa56a3aa3075b08edb10d9b1e476938cfdbee8f3b67181", size = 5043654, upload-time = "2026-04-18T04:33:52.71Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9c/e71a069d09641c1a7abeb30e693f828c7c90a41cbe3d650b2d734d876f85/lxml-6.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d6d8efe71429635f0559579092bb5e60560d7b9115ee38c4adbea35632e7fa24", size = 4769326, upload-time = "2026-04-18T04:33:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/cc/06/7a9cd84b3d4ed79adf35f874750abb697dec0b4a81a836037b36e47c091a/lxml-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e39ab3a28af7784e206d8606ec0e4bcad0190f63a492bca95e94e5a4aef7f6e", size = 5635879, upload-time = "2026-04-18T04:33:58.509Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f0/9d57916befc1e54c451712c7ee48e9e74e80ae4d03bdce49914e0aee42cd/lxml-6.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9eb667bf50856c4a58145f8ca2d5e5be160191e79eb9e30855a476191b3c3495", size = 5224048, upload-time = "2026-04-18T04:34:00.943Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/90c4eefda0c08c92221fe0753db2d6699a4c628f76ff4465ec20dea84cc1/lxml-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7f4a77d6f7edf9230cee3e1f7f6764722a41604ee5681844f18db9a81ea0ec33", size = 5250241, upload-time = "2026-04-18T04:34:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/5e/73/16596f7e4e38fa33084b9ccbccc22a15f82a290a055126f2c1541236d2ff/lxml-6.1.0-cp313-cp313-win32.whl", hash = "sha256:28902146ffbe5222df411c5d19e5352490122e14447e98cd118907ee3fd6ee62", size = 3596938, upload-time = "2026-04-18T04:31:56.206Z" }, + { url = "https://files.pythonhosted.org/packages/8e/63/981401c5680c1eb30893f00a19641ac80db5d1e7086c62cb4b13ed813038/lxml-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:4a1503c56e4e2b38dc76f2f2da7bae69670c0f1933e27cfa34b2fa5876410b16", size = 3995728, upload-time = "2026-04-18T04:31:58.763Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e8/c358a38ac3e541d16a1b527e4e9cb78c0419b0506a070ace11777e5e8404/lxml-6.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:e0af85773850417d994d019741239b901b22c6680206f46a34766926e466141d", size = 3658372, upload-time = "2026-04-18T04:32:03.629Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/cee4cf203ef0bab5c52afc118da61d6b460c928f2893d40023cfa27e0b80/lxml-6.1.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ab863fd37458fed6456525f297d21239d987800c46e67da5ef04fc6b3dd93ac8", size = 8576713, upload-time = "2026-04-18T04:32:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a7/eda05babeb7e046839204eaf254cd4d7c9130ce2bbf0d9e90ea41af5654d/lxml-6.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6fd8b1df8254ff4fd93fd31da1fc15770bde23ac045be9bb1f87425702f61cc9", size = 4623874, upload-time = "2026-04-18T04:32:10.755Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e9/db5846de9b436b91890a62f29d80cd849ea17948a49bf532d5278ee69a9e/lxml-6.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:47024feaae386a92a146af0d2aeed65229bf6fff738e6a11dda6b0015fb8fd03", size = 4949535, upload-time = "2026-04-18T04:34:06.657Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ba/0d3593373dcae1d68f40dc3c41a5a92f2544e68115eb2f62319a4c2a6500/lxml-6.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3f00972f84450204cd5d93a5395965e348956aaceaadec693a22ec743f8ae3eb", size = 5086881, upload-time = "2026-04-18T04:34:09.556Z" }, + { url = "https://files.pythonhosted.org/packages/43/76/759a7484539ad1af0d125a9afe9c3fb5f82a8779fd1f5f56319d9e4ea2fd/lxml-6.1.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97faa0860e13b05b15a51fb4986421ef7a30f0b3334061c416e0981e9450ca4c", size = 5031305, upload-time = "2026-04-18T04:34:12.336Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/c1f0daf981a11e47636126901fd4ab82429e18c57aeb0fc3ad2940b42d8b/lxml-6.1.0-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:972a6451204798675407beaad97b868d0c733d9a74dafefc63120b81b8c2de28", size = 5647522, upload-time = "2026-04-18T04:34:14.89Z" }, + { url = "https://files.pythonhosted.org/packages/31/e6/1f533dcd205275363d9ba3511bcec52fa2df86abf8abe6a5f2c599f0dc31/lxml-6.1.0-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fe022f20bc4569ec66b63b3fb275a3d628d9d32da6326b2982584104db6d3086", size = 5239310, upload-time = "2026-04-18T04:34:17.652Z" }, + { url = "https://files.pythonhosted.org/packages/c3/8c/4175fb709c78a6e315ed814ed33be3defd8b8721067e70419a6cf6f971da/lxml-6.1.0-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:75c4c7c619a744f972f4451bf5adf6d0fb00992a1ffc9fd78e13b0bc817cc99f", size = 5350799, upload-time = "2026-04-18T04:34:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/6ffdebc5994975f0dde4acb59761902bd9d9bb84422b9a0bd239a7da9ca8/lxml-6.1.0-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3648f20d25102a22b6061c688beb3a805099ea4beb0a01ce62975d926944d292", size = 4697693, upload-time = "2026-04-18T04:34:23.541Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/565f36bd5c73294602d48e04d23f81ff4c8736be6ba5e1d1ec670ac9be80/lxml-6.1.0-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77b9f99b17cbf14026d1e618035077060fc7195dd940d025149f3e2e830fbfcb", size = 5250708, upload-time = "2026-04-18T04:34:26.001Z" }, + { url = "https://files.pythonhosted.org/packages/5a/11/a68ab9dd18c5c499404deb4005f4bc4e0e88e5b72cd755ad96efec81d18d/lxml-6.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32662519149fd7a9db354175aa5e417d83485a8039b8aaa62f873ceee7ea4cad", size = 5084737, upload-time = "2026-04-18T04:34:28.32Z" }, + { url = "https://files.pythonhosted.org/packages/ab/78/e8f41e2c74f4af564e6a0348aea69fb6daaefa64bc071ef469823d22cc18/lxml-6.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:73d658216fc173cf2c939e90e07b941c5e12736b0bf6a99e7af95459cfe8eabb", size = 4737817, upload-time = "2026-04-18T04:34:30.784Z" }, + { url = "https://files.pythonhosted.org/packages/06/2d/aa4e117aa2ce2f3b35d9ff246be74a2f8e853baba5d2a92c64744474603a/lxml-6.1.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ac4db068889f8772a4a698c5980ec302771bb545e10c4b095d4c8be26749616f", size = 5670753, upload-time = "2026-04-18T04:34:33.675Z" }, + { url = "https://files.pythonhosted.org/packages/08/f5/dd745d50c0409031dbfcc4881740542a01e54d6f0110bd420fa7782110b8/lxml-6.1.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:45e9dfbd1b661eb64ba0d4dbe762bd210c42d86dd1e5bd2bdf89d634231beb43", size = 5238071, upload-time = "2026-04-18T04:34:36.12Z" }, + { url = "https://files.pythonhosted.org/packages/3e/74/ad424f36d0340a904665867dab310a3f1f4c96ff4039698de83b77f44c1f/lxml-6.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:89e8d73d09ac696a5ba42ec69787913d53284f12092f651506779314f10ba585", size = 5264319, upload-time = "2026-04-18T04:34:39.035Z" }, + { url = "https://files.pythonhosted.org/packages/53/36/a15d8b3514ec889bfd6aa3609107fcb6c9189f8dc347f1c0b81eded8d87c/lxml-6.1.0-cp314-cp314-win32.whl", hash = "sha256:ebe33f4ec1b2de38ceb225a1749a2965855bffeef435ba93cd2d5d540783bf2f", size = 3657139, upload-time = "2026-04-18T04:32:20.006Z" }, + { url = "https://files.pythonhosted.org/packages/1a/a4/263ebb0710851a3c6c937180a9a86df1206fdfe53cc43005aa2237fd7736/lxml-6.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:398443df51c538bd578529aa7e5f7afc6c292644174b47961f3bf87fe5741120", size = 4064195, upload-time = "2026-04-18T04:32:23.876Z" }, + { url = "https://files.pythonhosted.org/packages/80/68/2000f29d323b6c286de077ad20b429fc52272e44eae6d295467043e56012/lxml-6.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:8c8984e1d8c4b3949e419158fda14d921ff703a9ed8a47236c6eb7a2b6cb4946", size = 3741870, upload-time = "2026-04-18T04:32:27.922Z" }, + { url = "https://files.pythonhosted.org/packages/30/e9/21383c7c8d43799f0da90224c0d7c921870d476ec9b3e01e1b2c0b8237c5/lxml-6.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1081dd10bc6fa437db2500e13993abf7cc30716d0a2f40e65abb935f02ec559c", size = 8827548, upload-time = "2026-04-18T04:32:15.094Z" }, + { url = "https://files.pythonhosted.org/packages/a5/01/c6bc11cd587030dd4f719f65c5657960649fe3e19196c844c75bf32cd0d6/lxml-6.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:dabecc48db5f42ba348d1f5d5afdc54c6c4cc758e676926c7cd327045749517d", size = 4735866, upload-time = "2026-04-18T04:32:18.924Z" }, + { url = "https://files.pythonhosted.org/packages/f3/01/757132fff5f4acf25463b5298f1a46099f3a94480b806547b29ce5e385de/lxml-6.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e3dd5fe19c9e0ac818a9c7f132a5e43c1339ec1cbbfecb1a938bd3a47875b7c9", size = 4969476, upload-time = "2026-04-18T04:34:41.889Z" }, + { url = "https://files.pythonhosted.org/packages/fd/fb/1bc8b9d27ed64be7c8903db6c89e74dc8c2cd9ec630a7462e4654316dc5b/lxml-6.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9e7b0a4ca6dcc007a4cef00a761bba2dea959de4bd2df98f926b33c92ca5dfb9", size = 5103719, upload-time = "2026-04-18T04:34:44.797Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e7/5bf82fa28133536a54601aae633b14988e89ed61d4c1eb6b899b023233aa/lxml-6.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d27bbe326c6b539c64b42638b18bc6003a8d88f76213a97ac9ed4f885efeab7", size = 5027890, upload-time = "2026-04-18T04:34:47.634Z" }, + { url = "https://files.pythonhosted.org/packages/2d/20/e048db5d4b4ea0366648aa595f26bb764b2670903fc585b87436d0a5032c/lxml-6.1.0-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4e425db0c5445ef0ad56b0eec54f89b88b2d884656e536a90b2f52aecb4ca86", size = 5596008, upload-time = "2026-04-18T04:34:51.503Z" }, + { url = "https://files.pythonhosted.org/packages/9a/c2/d10807bc8da4824b39e5bd01b5d05c077b6fd01bd91584167edf6b269d22/lxml-6.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b89b098105b8599dc57adac95d1813409ac476d3c948a498775d3d0c6124bfb", size = 5224451, upload-time = "2026-04-18T04:34:54.263Z" }, + { url = "https://files.pythonhosted.org/packages/3c/15/2ebea45bea427e7f0057e9ce7b2d62c5aba20c6b001cca89ed0aadb3ad41/lxml-6.1.0-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:c4a699432846df86cc3de502ee85f445ebad748a1c6021d445f3e514d2cd4b1c", size = 5312135, upload-time = "2026-04-18T04:34:56.818Z" }, + { url = "https://files.pythonhosted.org/packages/31/e2/87eeae151b0be2a308d49a7ec444ff3eb192b14251e62addb29d0bf3778f/lxml-6.1.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:30e7b2ed63b6c8e97cca8af048589a788ab5c9c905f36d9cf1c2bb549f450d2f", size = 4639126, upload-time = "2026-04-18T04:34:59.704Z" }, + { url = "https://files.pythonhosted.org/packages/a3/51/8a3f6a20902ad604dd746ec7b4000311b240d389dac5e9d95adefd349e0c/lxml-6.1.0-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:022981127642fe19866d2907d76241bb07ed21749601f727d5d5dd1ce5d1b773", size = 5232579, upload-time = "2026-04-18T04:35:02.658Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d2/650d619bdbe048d2c3f2c31edb00e35670a5e2d65b4fe3b61bce37b19121/lxml-6.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:23cad0cc86046d4222f7f418910e46b89971c5a45d3c8abfad0f64b7b05e4a9b", size = 5084206, upload-time = "2026-04-18T04:35:05.175Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8a/672ca1a3cbeabd1f511ca275a916c0514b747f4b85bdaae103b8fa92f307/lxml-6.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:21c3302068f50d1e8728c67c87ba92aa87043abee517aa2576cca1855326b405", size = 4758906, upload-time = "2026-04-18T04:35:08.098Z" }, + { url = "https://files.pythonhosted.org/packages/be/f1/ef4b691da85c916cb2feb1eec7414f678162798ac85e042fa164419ac05c/lxml-6.1.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:be10838781cb3be19251e276910cd508fe127e27c3242e50521521a0f3781690", size = 5620553, upload-time = "2026-04-18T04:35:11.23Z" }, + { url = "https://files.pythonhosted.org/packages/59/17/94e81def74107809755ac2782fdad4404420f1c92ca83433d117a6d5acf0/lxml-6.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2173a7bffe97667bbf0767f8a99e587740a8c56fdf3befac4b09cb29a80276fd", size = 5229458, upload-time = "2026-04-18T04:35:14.254Z" }, + { url = "https://files.pythonhosted.org/packages/21/55/c4be91b0f830a871fc1b0d730943d56013b683d4671d5198260e2eae722b/lxml-6.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c6854e9cf99c84beb004eecd7d3a3868ef1109bf2b1df92d7bc11e96a36c2180", size = 5247861, upload-time = "2026-04-18T04:35:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ca/77123e4d77df3cb1e968ade7b1f808f5d3a5c1c96b18a33895397de292c1/lxml-6.1.0-cp314-cp314t-win32.whl", hash = "sha256:00750d63ef0031a05331b9223463b1c7c02b9004cef2346a5b2877f0f9494dd2", size = 3897377, upload-time = "2026-04-18T04:32:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/64/ce/3554833989d074267c063209bae8b09815e5656456a2d332b947806b05ff/lxml-6.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:80410c3a7e3c617af04de17caa9f9f20adaa817093293d69eae7d7d0522836f5", size = 4392701, upload-time = "2026-04-18T04:32:12.113Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a0/9b916c68c0e57752c07f8f64b30138d9d4059dbeb27b90274dedbea128ff/lxml-6.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:26dd9f57ee3bd41e7d35b4c98a2ffd89ed11591649f421f0ec19f67d50ec67ac", size = 3817120, upload-time = "2026-04-18T04:32:15.803Z" }, +] + +[[package]] +name = "manifold3d" +version = "3.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/fd/4dfc246e076e3912c45a821764f4de8b6c8117fa36fc67f8e44bf9dfe59b/manifold3d-3.4.1.tar.gz", hash = "sha256:b517927e2c15dc52169fff0cd12e1949eceb4ca49f3a5b8c0568b1116a561ab1", size = 269275, upload-time = "2026-03-24T06:22:40.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/59/def4c589dd55aa32026a720f8a31d71aa2162fef8e3963b6241a7945ef4e/manifold3d-3.4.1-cp312-cp312-macosx_10_14_universal2.whl", hash = "sha256:967c89daf24ec9ff863323d593cce98e4c130abbaaa9504df6789f9d8c780d0d", size = 1752517, upload-time = "2026-03-24T06:21:55.203Z" }, + { url = "https://files.pythonhosted.org/packages/b1/a9/377800999cc8421ce8bfa40787d09570bb635e0099f44959170fee751dd7/manifold3d-3.4.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:c29db9a1bda414ecaa56dd2cd274f06bbbe740e463133c5b69943d82c3dcfb96", size = 956343, upload-time = "2026-03-24T06:21:57.134Z" }, + { url = "https://files.pythonhosted.org/packages/6a/72/7f988a0deae9b3fbed3a6b2e9285e96fd9105e95f6755f5457e3a80e103e/manifold3d-3.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2ae6855a6f652acd89e228f1981e5b710d4b10e06d7c06e5bada3b3fb31904a3", size = 840924, upload-time = "2026-03-24T06:21:58.462Z" }, + { url = "https://files.pythonhosted.org/packages/bf/46/787ad4b53a35ccf1d31fbd3d2ffe0653dab67057b1f561db51d2edc494ba/manifold3d-3.4.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a8068f85416034e290d23b424f2bea15d2f1da1c5ccb79b442bdb50ed4e1a4b6", size = 1253014, upload-time = "2026-03-24T06:21:59.734Z" }, + { url = "https://files.pythonhosted.org/packages/e2/45/29d2380ac477b11629a72483b21dd544861caccaedbc87043bf315a15a50/manifold3d-3.4.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fe9ff5ce3d949c72b21120318121eb926ddba4299eab0e8bab2c6784a9843ffb", size = 1355512, upload-time = "2026-03-24T06:22:01.518Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7f/310688a725a5a23d00e9f29e614a2b7906b399df27731b1aa6e153e4f465/manifold3d-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:1bd6fa1b20238603ec3df7f6060ddba1181cf9464104e82b746747b487d12092", size = 983293, upload-time = "2026-03-24T06:22:03.273Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d0/b066b476242dddfad98db51425a28ac41ab008a4e7697f6d6bca21a24881/manifold3d-3.4.1-cp313-cp313-macosx_10_14_universal2.whl", hash = "sha256:210ec6918870611d9e3f888c00657aad842cfa89a7967e94546a568bf8dfc2f1", size = 1752343, upload-time = "2026-03-24T06:22:04.731Z" }, + { url = "https://files.pythonhosted.org/packages/11/2c/10b5cb142b00bb7b14bb5698c584ce7722c68c3ed58ae4173693a35d2108/manifold3d-3.4.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:d4e1dd76a3c5fe935bc14eb62f98ce2361e0e505fcfca08abb2f9d8a1d01e0db", size = 956241, upload-time = "2026-03-24T06:22:06.404Z" }, + { url = "https://files.pythonhosted.org/packages/05/a7/af84e5f6e6af2d07d800355345ffb303c4e8de96dbf3194633322f3d8335/manifold3d-3.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7e1bf4857f64311fb5113ff286898c47efc0f199e4d860cfc663b5f69ce90ede", size = 840900, upload-time = "2026-03-24T06:22:07.974Z" }, + { url = "https://files.pythonhosted.org/packages/80/1c/f274d6e35652c3fb72b54c5fcafc5fc474e1a93d3fd17fb8df3c9c765873/manifold3d-3.4.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b23435456d5ed64e48a34a281869c3b626da8ccd8872e54637b77f420716f9a", size = 1252521, upload-time = "2026-03-24T06:22:09.259Z" }, + { url = "https://files.pythonhosted.org/packages/97/90/82081bcbffc68e36f9f34c36f041d6e0176cbb462e9041683d82ff17b626/manifold3d-3.4.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6871adaf9e5081303d2c9446de5a76a9af84bcf938949fa198cc5f0ae9cad19f", size = 1355366, upload-time = "2026-03-24T06:22:10.892Z" }, + { url = "https://files.pythonhosted.org/packages/0d/db/26df1d96a2c61a4d79aeb0ca2f8bfbfd4af94fdb944469dda38ced240f2f/manifold3d-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:0a93e8202cccea16c76a6c3a7d02300755cccea6536874ccfc160f8c4d8948c4", size = 983317, upload-time = "2026-03-24T06:22:12.257Z" }, + { url = "https://files.pythonhosted.org/packages/26/50/d62f9fed01bdeaea50d3dd821498573c0bf1c286e54e5a632f47cef8fffa/manifold3d-3.4.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:632525867f23a5d34ab4a7b9129dc440a6ed2b4a0444d9feddb6069361107555", size = 1752191, upload-time = "2026-03-24T06:22:13.521Z" }, + { url = "https://files.pythonhosted.org/packages/1c/2e/464f3898f8f1b727a248d4b2bedc310d60efed1a0f43f9977f95f122fcf4/manifold3d-3.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c4e755c0a1d808603185fb5a562c045c71918b4e64a498c489eba658df49692b", size = 956239, upload-time = "2026-03-24T06:22:14.864Z" }, + { url = "https://files.pythonhosted.org/packages/0e/ec/da4bdba9ae1fe9049be9e2f42336ebf700dfd839795b561fb0cee9cb03f6/manifold3d-3.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2c9c20b479e15ec5f0710951d3ae4f95635d0bbf6502f03e43ed87e310e69230", size = 840646, upload-time = "2026-03-24T06:22:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e0/c476d79d5940da31cf6d0aa9353e56d70fe709fefbadd3c99804594aeb4d/manifold3d-3.4.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:145b94b7bb73b425fa42bb795d5c8eaea5de5e45cba9731f2542c7311f3a73c3", size = 1253522, upload-time = "2026-03-24T06:22:17.734Z" }, + { url = "https://files.pythonhosted.org/packages/89/fa/2d5838248950b8cb41ba22496dfc7e95222582761ec83e473a210a0be059/manifold3d-3.4.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1093c002a5f5c012219208bfb0b49f4821974f887cbd357316b9dbab74b7fa5b", size = 1356044, upload-time = "2026-03-24T06:22:19.375Z" }, + { url = "https://files.pythonhosted.org/packages/95/a7/2b8e4b88a613b0057b871ca71342d7237289c5eccf2f75ce10afa04e080e/manifold3d-3.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:04c99b94fbb92572051288d3c6163baf9c81a647dab33d1fdce418457b0a1a44", size = 1014216, upload-time = "2026-03-24T06:22:20.977Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/c8c5e38b5f93ce2268aa0fc2e4e995a04c8722045868dc75021a7c2a5bc9/manifold3d-3.4.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:22bb8c202c88072fd5cd3fb24243715e7b200151a8aa3da78661b3611e07924f", size = 1760559, upload-time = "2026-03-24T06:22:22.701Z" }, + { url = "https://files.pythonhosted.org/packages/7e/93/5defaadef6a57bb864a51f68c824435929a7c7de1205f95e166325cba55e/manifold3d-3.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9ade2f14cf906271604ccfa5c49bb5704f0c14b08367e6d3aa0c4ed6ed56f919", size = 961249, upload-time = "2026-03-24T06:22:24.364Z" }, + { url = "https://files.pythonhosted.org/packages/fd/fa/5705986493097e268f26d87b3cfce8e966f6c62a1b8f38fa4086b704cf4d/manifold3d-3.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b4b24047cf423b024f477c09e2c44fed5991cbca4906abf34bf6ced62f37ef93", size = 846070, upload-time = "2026-03-24T06:22:25.946Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/925d96698fbd90e9990a7a4fdd9a7a8b038e166de696673cfd141bf54e85/manifold3d-3.4.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e02108ee43c6dcf1f40b208569ad49dcf1a6ceed21fd6d5fd6e8f09c02a8b60", size = 1260343, upload-time = "2026-03-24T06:22:27.266Z" }, + { url = "https://files.pythonhosted.org/packages/07/0a/f2d1c6390ebd565fa44357cddc9e6aa783fbccb0cc952996217bcbc69699/manifold3d-3.4.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b1ac2b29c6ca1412f63a1ac2c240ae067fe03f666bb12a21729012275fbbde85", size = 1361860, upload-time = "2026-03-24T06:22:28.945Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9b/5d0cf29530e31c2ef66cc9b2780031a82955002ad924b0eb23ac5e3dd90f/manifold3d-3.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4d3f795cfcaa857f4bd9bf62bd3f15061bae502fb4cea87e820f4bba67045ff8", size = 1022928, upload-time = "2026-03-24T06:22:30.285Z" }, +] + +[[package]] +name = "mapbox-earcut" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/7b/bbf6b00488662be5d2eb7a188222c264b6f713bac10dc4a77bf37a4cb4b6/mapbox_earcut-2.0.0.tar.gz", hash = "sha256:81eab6b86cf99551deb698b98e3f7502c57900e5c479df15e1bdaf1a57f0f9d6", size = 39934, upload-time = "2025-11-16T18:41:27.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/93/846804029d955c3c841d8efff77c2b0e8d9aab057d3a077dc8e3f88b5ea4/mapbox_earcut-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db55ce18e698bc9d90914ee7d4f8c3e4d23827456ece7c5d7a1ec91e90c7122b", size = 55623, upload-time = "2025-11-16T18:40:32.113Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f6/cc9ece104bc3876b350dba6fef7f34fb7b20ecc028d2cdbdbecb436b1ed1/mapbox_earcut-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01dd6099d16123baf582a11b2bd1d59ce848498cf0cdca3812fd1f8b20ff33b7", size = 52028, upload-time = "2025-11-16T18:40:33.516Z" }, + { url = "https://files.pythonhosted.org/packages/88/6e/230da4aabcc56c99e9bddb4c43ce7d4ba3609c0caf2d316fb26535d7c60c/mapbox_earcut-2.0.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d5a098aae26a52282bc981a38e7bf6b889d2ea7442f2cd1903d2ba842f4ff07", size = 56351, upload-time = "2025-11-16T18:40:35.217Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f7/5cdd3752526e91d91336c7263af7767b291d21e63c89d7190a60051f0f87/mapbox_earcut-2.0.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de35f241d0b9110ad9260f295acedd9d7cc0d7acfe30d36b1b3ee8419c2caba1", size = 59209, upload-time = "2025-11-16T18:40:36.634Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a2/b7781416cb93b37b95d0444e03f87184de8815e57ff202ce4105fa921325/mapbox_earcut-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cb63ab85e2e430c350f93e75c13f8b91cb8c8a045f3cd714c390b69a720368a", size = 152316, upload-time = "2025-11-16T18:40:38.147Z" }, + { url = "https://files.pythonhosted.org/packages/c1/74/396338e3d345e4e36fb23a0380921098b6a95ce7fb19c4777f4185a5974e/mapbox_earcut-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fb3c9f069fc3795306db87f8139f70c4f047532f897a3de05f54dc1faebc97f6", size = 157268, upload-time = "2025-11-16T18:40:39.753Z" }, + { url = "https://files.pythonhosted.org/packages/56/2c/66fd137ea86c508f6cd7247f7f6e2d1dabffc9f0e9ccf14c71406b197af1/mapbox_earcut-2.0.0-cp312-cp312-win32.whl", hash = "sha256:eb290e6676217707ed238dd55e07b0a8ca3ab928f6a27c4afefb2ff3af08d7cb", size = 51226, upload-time = "2025-11-16T18:40:41.018Z" }, + { url = "https://files.pythonhosted.org/packages/b8/84/7b78e37b0c2109243c0dad7d9ba9774b02fcee228bf61cf727a5aa1702e2/mapbox_earcut-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:5ef5b3319a43375272ad2cad9333ed16e569b5102e32a4241451358897e6f6ee", size = 56417, upload-time = "2025-11-16T18:40:42.173Z" }, + { url = "https://files.pythonhosted.org/packages/75/7f/cd7195aa27c1c8f2b9d38025a5a8663f32cd01c07b648a54b1308ab26c15/mapbox_earcut-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:a4a3706feb5cc8c782d8f68bb0110c8d551304043f680a87a54b0651a2c208c3", size = 50111, upload-time = "2025-11-16T18:40:43.334Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7c/c5dd5b255b9828ba5df729e62fdd470a322c938f07ef392ca03c0592bb3a/mapbox_earcut-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:582329a81bd36cf0f82e443c395bcb8cfdb10caddafec76acaebac7c20bf1c31", size = 55619, upload-time = "2025-11-16T18:40:44.44Z" }, + { url = "https://files.pythonhosted.org/packages/1a/3f/03f23eac9831e7d0d8da3d6993695a9a3724659c94e9997f6b7aaccc199d/mapbox_earcut-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d2ac5f610b3e44a3a0c4df06b5552d503b4f1c2c409eeca20dbe05112bd60955", size = 52023, upload-time = "2025-11-16T18:40:45.857Z" }, + { url = "https://files.pythonhosted.org/packages/39/f3/a92ccee494b3e437e4bd81ecd358e39d231dc90af010d6c43930506c10ad/mapbox_earcut-2.0.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58cc88513b87734b243d86f0d3fb87e96e0a78d9abd8fd615c55f766dd63f949", size = 56357, upload-time = "2025-11-16T18:40:47.27Z" }, + { url = "https://files.pythonhosted.org/packages/03/30/e54ececd0403a5495c340b693075abec92a6d17dc44283b6cb059534f7ed/mapbox_earcut-2.0.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40218d887798451932f3c335992834aa807c35cd497c6e0733470fdbd77f9521", size = 59215, upload-time = "2025-11-16T18:40:48.682Z" }, + { url = "https://files.pythonhosted.org/packages/6e/e1/8fbff13a074c1fbf702b30ce7ec4d878bc664d659c1c2b1697831f4ea3a8/mapbox_earcut-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:39fa5cfa0e855b028ec9b0200c88ebfa252448f343ce2f67b6fc07fe1f22a3ae", size = 152304, upload-time = "2025-11-16T18:40:49.85Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/c757030b3cb3a9f2278ded6f7312d2b9d3761db6f3da8d395f7f7303dd66/mapbox_earcut-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:476b558473b8a43f238d46e819bc0f830c427842ec5feb19e23b4dcac8ad2455", size = 157270, upload-time = "2025-11-16T18:40:51.093Z" }, + { url = "https://files.pythonhosted.org/packages/96/63/589c6decb1f032d8811f1066da552f0a718830f592e6d6539fa4c3c766b8/mapbox_earcut-2.0.0-cp313-cp313-win32.whl", hash = "sha256:8c2d125c182acbc490b39503c0dec4f937bae180d0849a26bcea0ee4a76024bd", size = 51207, upload-time = "2025-11-16T18:40:52.285Z" }, + { url = "https://files.pythonhosted.org/packages/76/75/a79a6020c46d4f07731e88ec5cc9324f6b43343aba835def1dc0bf59fecf/mapbox_earcut-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:e049e6a37c228d7a9cb2f54ae405aa21d35c5175d849530fb32064ddb38ad5ab", size = 56416, upload-time = "2025-11-16T18:40:53.474Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5f/83e878c2b3e9e6db1f60b598a2cc5ed4c2b5bc8d281575c964869414a159/mapbox_earcut-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:8a833d73d63d4b6291bbd8b4d2f551e87f663282cdc547ecbbd9b423849ee996", size = 50103, upload-time = "2025-11-16T18:40:54.954Z" }, + { url = "https://files.pythonhosted.org/packages/96/fc/f1b74324c83f510213ff91eb8b1d2697ad5a12418c5fba966e80f1104a5f/mapbox_earcut-2.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad1dc141797037b7d4c9d8d2e52b9665b36294913a8ec31008b282d1a95b9bdc", size = 55728, upload-time = "2025-11-16T18:40:56.098Z" }, + { url = "https://files.pythonhosted.org/packages/7b/59/053c04e29c4bd22157d3b6255f1e5c19c46cb7a594c4314298bdcbca723f/mapbox_earcut-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0f0f5c6f5ed8ffdce8efe6a003ba598089d0ee07eabd41868db183be50484f9f", size = 52063, upload-time = "2025-11-16T18:40:57.227Z" }, + { url = "https://files.pythonhosted.org/packages/a6/77/acc2d553c3bb8c769535a280545bb7d9608141e90511a2e6215a54611776/mapbox_earcut-2.0.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82cd92775f37fd1e4b8464c5e74a00e87130eecc55ee3df2492b8ca2bdf6ef3e", size = 56522, upload-time = "2025-11-16T18:40:58.349Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f5/627dd6defd3c1a2b3069e9e27482aa04d268c841735e576c1e22848a34f6/mapbox_earcut-2.0.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:626ffc1310e0cc8910283e4ac3139e5fb0458f18f2c4874162f66159951933ff", size = 59204, upload-time = "2025-11-16T18:41:00.095Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3e/819185542ab095ba1244ad65ececb3edcde6fd0111248a0f9318d695bfcf/mapbox_earcut-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ea951d764a356cad95b23fef950d8aa3b44b933795ad09d977fea7d4dbe377c3", size = 152550, upload-time = "2025-11-16T18:41:01.233Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ad/85e0f815e4774b90ad6761bce55c80d13ee21b2a24014b0be0d5010b0049/mapbox_earcut-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:df1f217624abb5e02ecabcbd84369de970b8d8bc1e4e7c164c1cfcaddad76ca3", size = 157322, upload-time = "2025-11-16T18:41:02.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/4c/0f56369e7a000d2f3177d17baf34263559b206ae524fcd0c4c5d1d960dab/mapbox_earcut-2.0.0-cp314-cp314-win32.whl", hash = "sha256:6fa61307d38b50fc9bd5449c00dbae46d270a32b372c6fc3b8af4b85c85746e4", size = 52916, upload-time = "2025-11-16T18:41:04.122Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9d/8c557dd9b3d9fe2344f5bd5ff3bb0b2a42ed6addb7e43ca4358051743b04/mapbox_earcut-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:0da20ed3c81b240450118773bcedfac34e70a56998f66147222c46f4356fff67", size = 57713, upload-time = "2025-11-16T18:41:05.204Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ec/678c5553938d3a29d02dd41dd898672267f054afc4e2821958dee6ec86ce/mapbox_earcut-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:847e74bd5878e4c64793dc100f9288f5443f87c55c3fe391fd90509029136ff6", size = 51872, upload-time = "2025-11-16T18:41:06.323Z" }, + { url = "https://files.pythonhosted.org/packages/18/37/94f2d973669cbfef811e536713fe56ec012ba74e5f8795a832337b1866a3/mapbox_earcut-2.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ddc9e7175fc903185c64afbbf91febee56b50787dd0962fce2bfb4f20cf80d27", size = 56447, upload-time = "2025-11-16T18:41:07.443Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1c/e0afcc82659cc1727a7e59c4f9e9880bbc3f048a4a5325772b44d4a91dfd/mapbox_earcut-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6dc8a7568066af9a858018d6d92b7e77e164578f9fcd79093f1cbe4ec203461b", size = 53154, upload-time = "2025-11-16T18:41:08.618Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2d/9845281c8c35da2bea733b8c2df5b9fe694e73e7b05fe8a1d4c3c439a1bc/mapbox_earcut-2.0.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6abc5340edd9b433ab2dab2ee033082a199d5c51cce445124626c0040ec0d81b", size = 56285, upload-time = "2025-11-16T18:41:09.728Z" }, + { url = "https://files.pythonhosted.org/packages/97/8e/eeea762a519490662b8f480e2b35bf03701b0bcc5a446b62a4c5a1500b06/mapbox_earcut-2.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df7afdd8078a9aa28f469d9242531d304e09a4b14e514f048e021a949f3777b4", size = 58601, upload-time = "2025-11-16T18:41:10.872Z" }, + { url = "https://files.pythonhosted.org/packages/b9/67/932f80aa6af9bc1a317b6119052c74f327d81e00b457003a049e324b810c/mapbox_earcut-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a286f73e612a46cafd6d6c843365265090517af16823e2f37277c13cd8b6f09", size = 154924, upload-time = "2025-11-16T18:41:12.104Z" }, + { url = "https://files.pythonhosted.org/packages/87/38/5db4a91f9f90cbb447be61da5468a2955fad3a840ae4c7dbde789b09d45a/mapbox_earcut-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8d081fe1d00dc553e3e68c02fc395324aad0d8ed955f3ff59289264c9b21ace4", size = 159194, upload-time = "2025-11-16T18:41:13.364Z" }, + { url = "https://files.pythonhosted.org/packages/6b/03/de3843b13fe854a010fb2f8b25551d4d5fe1c879ff2e7c8d7d8d7d735a8e/mapbox_earcut-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:13049ca96431bbc7ef7fd7780dd1872209ca11a5c1977f7aa91a1b574a8af863", size = 54143, upload-time = "2025-11-16T18:41:14.564Z" }, + { url = "https://files.pythonhosted.org/packages/9a/89/fbdee5a56ba51df9be6098b5428636ad75aa994e98d8bec6113d5cba401e/mapbox_earcut-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6ace78e4fdba3b8cbb7768d44d77a981698305862a07f94bbb6f5cc16659adb4", size = 60833, upload-time = "2025-11-16T18:41:15.694Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mink" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mujoco" }, + { name = "qpsolvers", extra = ["daqp"] }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/66/e09aa0a8a689eaee797f91415ea6133b3d1e4fa5ee5bd1504315e5d56b37/mink-1.1.0.tar.gz", hash = "sha256:2480fdd42ec2a28ffd4361e6fc5a7c7bc353caffffb8343d9c5bf7089c15ca5e", size = 51211, upload-time = "2026-02-19T22:23:39.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/7a/dba5e7dd311b5fd5ac2f83a2baf20c203857e901fe21c6656331c787314a/mink-1.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:58090c5cece086a1149616d94a220918680ef2eab76737e0ab0f0384b80f8ca1", size = 69196, upload-time = "2026-02-19T22:51:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/c7/3d/97e4b9736f7d983d87f872eb80bbdc256a903ae1e4481f24bf3c317e376e/mink-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de76f557b2f667abbd1305a5074f051a6e66d439df53e48ada5359915e122fc1", size = 67759, upload-time = "2026-02-19T22:51:56.742Z" }, + { url = "https://files.pythonhosted.org/packages/15/6f/64c5dd93f1d19de05709e5c6ac66f6abe89d280838b434e9075f94e30b65/mink-1.1.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d11676be6d08ccf7943be6c8afa1ff64fa6be3fbab633fcc2e3f1cd1ef004b1", size = 70482, upload-time = "2026-02-19T22:51:57.937Z" }, + { url = "https://files.pythonhosted.org/packages/9c/71/3b219f6563b5a1a4e7bda894d7314b9eea1a5ec53d5cc3975b7951c6e84a/mink-1.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1f8426e6e39976a9863e832680583945a9ccb644d6f96c7fa40462d7859965", size = 69613, upload-time = "2026-02-19T22:51:59.093Z" }, + { url = "https://files.pythonhosted.org/packages/e2/2c/e0baaa2757f36382ac02a06caa163c8b617ff444149dad7616cc5bbff247/mink-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3dad64095cd401da7f99f16eca8dde1d9802f0915207e097d63371abc58c5b9e", size = 69612, upload-time = "2026-02-19T22:52:00.628Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f1/17b76e508e27ca1c5de2f5284a3ca24fce701cb18803f20dd32322dd72e1/mink-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5dd7ea03a464ceba19f43e8fab8a4ca5d872febcb3a17527a6944db65b18b9e7", size = 70650, upload-time = "2026-02-19T22:52:01.668Z" }, + { url = "https://files.pythonhosted.org/packages/c8/fc/8ead0efa16391ae2a39bc9101ae3f9ec5e29fd0edbbd8153efc00017c64c/mink-1.1.0-cp312-cp312-win32.whl", hash = "sha256:93556fedc3dd9f98dc6e3272d938ec71da2a4706e58668a587b620bc15194b7e", size = 71561, upload-time = "2026-02-19T22:52:03.721Z" }, + { url = "https://files.pythonhosted.org/packages/99/7f/cfba1085a845ce188259d25c72d3180c4f7431c566125f6b171f2fd62d06/mink-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:22efa22b554d8e1076890afbb3a7223d2ca249fae97d631d10fbbf71bc2c3c56", size = 72320, upload-time = "2026-02-19T22:52:05.702Z" }, + { url = "https://files.pythonhosted.org/packages/98/ff/d71fd73ee6506a647c18abc80bd1669f98b9f8eb3012f14e560c81c7bd51/mink-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:18f8fd87e5200d7c0ece7f7144647f882b6125147df4e49a20896e51ef2a01b9", size = 69190, upload-time = "2026-02-19T22:52:06.929Z" }, + { url = "https://files.pythonhosted.org/packages/a4/df/b79cef8fd2e6a84698be343333fd12a9add8c757c54b00c0705c099f9d64/mink-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:15ed3b3662cf794e05729c748906d87080a2633b1e82bc1a6365a68313eeae58", size = 67764, upload-time = "2026-02-19T22:52:08.12Z" }, + { url = "https://files.pythonhosted.org/packages/34/9c/7ff76edecaa94551d28bd3864bcd867b81f5e72bc60292fea768ca2f6314/mink-1.1.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4ae80916d3bd25003d214b488a313781385ad577b9085b715b2b0bc37d014ff5", size = 66867, upload-time = "2026-02-19T22:23:40.934Z" }, + { url = "https://files.pythonhosted.org/packages/36/bc/fb1a26dae8a96f1997e62cdbd64fa64a3ab91607687bc3ab036d5d547b49/mink-1.1.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7338119f6e5b5ce8c3464f6c908f2dbdba5780682bdf49387512cf16b597ea89", size = 70469, upload-time = "2026-02-19T22:52:10.269Z" }, + { url = "https://files.pythonhosted.org/packages/20/7e/eea61521375432f63b18d83d3d941a7a85f855814d95c4b302df5ec1f643/mink-1.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab440637caa215b4aeb5720111d1eb23d2ff2349814e756851ab777dea7d901a", size = 69614, upload-time = "2026-02-19T22:52:11.486Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8a/2000a2101dac173639d111668ce5359806bde8fd14b581e618627a52f514/mink-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:db9269d97e674a9668dcedd063e522ebff3532e61c57f25efedfd6596599353d", size = 69602, upload-time = "2026-02-19T22:52:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/ae/59/942b4798a8d861926ff8529cba3661ec5dc54d1932c4c9ddbffc3b726ba2/mink-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0e1167a0d5ebb3791e177a0502c91cf6b937719f0b2aa49194398be37484a4b8", size = 70645, upload-time = "2026-02-19T22:52:13.668Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c8/166fcf4e9e2936f2aa237572cbe8263cc42aa322b9607f7ba0937c189358/mink-1.1.0-cp313-cp313-win32.whl", hash = "sha256:1c7a214a8dc748eb7308b25dc6c7f124ad746c4977c87d0a02a63c0c29be9ff6", size = 71547, upload-time = "2026-02-19T22:52:14.918Z" }, + { url = "https://files.pythonhosted.org/packages/bd/6f/57709a92ff4b9c6f0be36bf6bc62ddd8c697f9263cb560bb9e9dbdd0a766/mink-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bdf77a25834a5cf8e8b855abfc702df12c1adff5305dec00f61272b2abd0747", size = 72316, upload-time = "2026-02-19T22:52:16.224Z" }, +] + +[[package]] +name = "msgspec" +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/60/f79b9b013a16fa3a58350c9295ddc6789f2e335f36ea61ed10a21b215364/msgspec-0.21.1.tar.gz", hash = "sha256:2313508e394b0d208f8f56892ca9b2799e2561329de9763b19619595a6c0f72c", size = 319193, upload-time = "2026-04-12T21:44:50.394Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/cf/317224852c00248c620a9bcf4b26e2e4ab8afd752f18d2a6ef73ebd423b6/msgspec-0.21.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4248cf0b6129b7d230eacd493c17cc2d4f3989f3bb7f633a928a85b7dcfa251", size = 196188, upload-time = "2026-04-12T21:44:07.181Z" }, + { url = "https://files.pythonhosted.org/packages/6d/81/074612945c0666078f7366f40000013de9f6ba687491d450df699bceebc9/msgspec-0.21.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5102c7e9b3acff82178449b85006d96310e690291bb1ea0142f1b24bcb8aabcb", size = 188473, upload-time = "2026-04-12T21:44:08.736Z" }, + { url = "https://files.pythonhosted.org/packages/8a/37/655101799590bcc5fddb2bd3fe0e6194e816c2d1da7c361725f5eb89a910/msgspec-0.21.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:846758412e9518252b2ac9bffd6f0e54d9ff614f5f9488df7749f81ff5c80920", size = 218871, upload-time = "2026-04-12T21:44:09.917Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d1/d4cd9fe89c7d400d7a18f86ccc94daa3f0927f53558846fcb60791dce5d6/msgspec-0.21.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21995e74b5c598c2e004110ad66ec7f1b8c20bf2bcf3b2de8fd9a3094422d3ff", size = 225025, upload-time = "2026-04-12T21:44:11.191Z" }, + { url = "https://files.pythonhosted.org/packages/24/bf/e20549e602b9edccadeeff98760345a416f9cce846a657e8b18e3396b212/msgspec-0.21.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6129f0cca52992e898fd5344187f7c8127b63d810b2fd73e36fca73b4c6475ee", size = 222672, upload-time = "2026-04-12T21:44:12.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/68/04d7a8f0f786545cf9b8c280c57aa6befb5977af6e884b8b54191cbe44b3/msgspec-0.21.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ef3ec2296248d1f8b9231acb051b6d471dfde8f21819e86c9adaaa9f42918521", size = 227303, upload-time = "2026-04-12T21:44:13.709Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4d/619866af2840875be408047bf9e70ceafbae6ab50660de7134ed1b25eb86/msgspec-0.21.1-cp312-cp312-win_amd64.whl", hash = "sha256:d4ab834a054c6f0cbeef6df9e7e1b33d5f1bc7b86dea1d2fd7cad003873e783d", size = 190017, upload-time = "2026-04-12T21:44:14.977Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2e/a8f9eca8fd00e097d7a9e99ba8a4685db994494448e3d4f0b7f6e9a3c0f7/msgspec-0.21.1-cp312-cp312-win_arm64.whl", hash = "sha256:628aaa35c74950a8c59da330d7e98917e1c7188f983745782027748ee4ca573e", size = 175345, upload-time = "2026-04-12T21:44:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/7e/74/f11ede02839b19ff459f88e3145df5d711626ca84da4e23520cebf819367/msgspec-0.21.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:764173717a01743f007e9f74520ed281f24672c604514f7d76c1c3a10e8edb66", size = 196176, upload-time = "2026-04-12T21:44:17.613Z" }, + { url = "https://files.pythonhosted.org/packages/bb/40/4476c1bd341418a046c4955aff632ec769315d1e3cb94e6acf86d461f9ed/msgspec-0.21.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:344c7cd0eaed1fb81d7959f99100ef71ec9b536881a376f11b9a6c4803365697", size = 188524, upload-time = "2026-04-12T21:44:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d9/9e9d7d7e5061b47540d03d640fab9b3965ba7ae49c1b2154861c8f007518/msgspec-0.21.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48943e278b3854c2f89f955ddc6f9f430d3f0784b16e47d10604ee0463cd21f5", size = 218880, upload-time = "2026-04-12T21:44:20.028Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/2bb344f34abb4b57e60c7c9c761994e0417b9718ec1460bf00c296f2a7ea/msgspec-0.21.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9aa659ebb0101b1cbc31461212b87e341d961f0ab0772aaf068a99e001ec4aa", size = 225050, upload-time = "2026-04-12T21:44:21.577Z" }, + { url = "https://files.pythonhosted.org/packages/1a/84/7c1e412f76092277bf760cef12b7979d03314d259ab5b5cafde5d0c1722d/msgspec-0.21.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7b27d1a8ead2b6f5b0c4f2d07b8be1ccfcc041c8a0e704781edebe3ae13c484", size = 222713, upload-time = "2026-04-12T21:44:22.83Z" }, + { url = "https://files.pythonhosted.org/packages/4e/27/0bba04b2b4ef05f3d068429410bc71d2cea925f1596a8f41152cccd5edb8/msgspec-0.21.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:38fe93e86b61328fe544cb7fd871fad5a27c8734bfda90f65e5dbe288ae50f61", size = 227259, upload-time = "2026-04-12T21:44:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/b0/2d/09574b0eea02fed2c2c1383dbaae2c7f79dc16dcd6487a886000afb5d7c4/msgspec-0.21.1-cp313-cp313-win_amd64.whl", hash = "sha256:8bc666331c35fcce05a7cd2d6221adbe0f6058f8e750711413d22793c080ac6a", size = 189857, upload-time = "2026-04-12T21:44:25.359Z" }, + { url = "https://files.pythonhosted.org/packages/46/34/105b1576ad182879914f0c821f17ee1d13abb165cb060448f96fe2aff078/msgspec-0.21.1-cp313-cp313-win_arm64.whl", hash = "sha256:42bb1241e0750c1a4346f2aa84db26c5ffd99a4eb3a954927d9f149ff2f42898", size = 175403, upload-time = "2026-04-12T21:44:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ad/86954e987d1d6a5c579e2c2e7832b65e0fff194179fdac4f581536086024/msgspec-0.21.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fab48eb45fdbfbdb2c0edfec00ffc53b6b6085beefc6b50b61e01659f9f8757f", size = 196261, upload-time = "2026-04-12T21:44:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a1/c5e46c3e42b866199365e35d11dddfd1fbd8bba4fdb3c52f965b1607ce94/msgspec-0.21.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3cb779ea0c35bc807ff941d415875c1f69ca0be91a2e907ab99a171811d86a9a", size = 188729, upload-time = "2026-04-12T21:44:28.99Z" }, + { url = "https://files.pythonhosted.org/packages/85/7d/1e29a319d678d6cb962ae5bdf32a6858ebdf38f73bc654c0e9c742a0c2c8/msgspec-0.21.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68604db36b3b4dd9bf160e436e12798a4738848144cea1aca1cb984011eb160f", size = 219866, upload-time = "2026-04-12T21:44:31.104Z" }, + { url = "https://files.pythonhosted.org/packages/25/1f/cca084ca2572810fff12ea9dbdcbe39eac048f40daf4a9077b49fcbe8cee/msgspec-0.21.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d6b9dc50948eaf65df54d2fd0ff66e6d8c32f116037209ee861810eb9b676cb", size = 224993, upload-time = "2026-04-12T21:44:32.649Z" }, + { url = "https://files.pythonhosted.org/packages/71/94/d2120fc9d419a89a3a7c13e5b7078798c4b392a96a02a6e2b3ce43a8766c/msgspec-0.21.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:52c5e21930942302394429c5a582ce7e6b62c7f983b3760834c2ce107e0dd6df", size = 223535, upload-time = "2026-04-12T21:44:33.839Z" }, + { url = "https://files.pythonhosted.org/packages/75/17/42418b66a3ad972a89bab73dd78b79cc6282bb488a25e73c853cee7443b9/msgspec-0.21.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:abbb39d65681fa24ed394e01af3d59d869068324f900c61d06062b7fb9980f2f", size = 227222, upload-time = "2026-04-12T21:44:35.093Z" }, + { url = "https://files.pythonhosted.org/packages/c4/33/265c894268cca88ff67b144ca2b4c522fc8b9a6f1966a3640c70516e78e1/msgspec-0.21.1-cp314-cp314-win_amd64.whl", hash = "sha256:5666b1b560b97b6ec2eb3fca8a502298ebac56e13bbca1f88523538ce83d01ea", size = 193810, upload-time = "2026-04-12T21:44:36.612Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8f/a6d35f25bf1fc63c492fdd88fdce01ba0875ead48c2b91f90f33653b4131/msgspec-0.21.1-cp314-cp314-win_arm64.whl", hash = "sha256:d8b8578e4c83b14ceea4cef0d0b747e31d9330fe4b03b2b2ad4063866a178f93", size = 179125, upload-time = "2026-04-12T21:44:38.198Z" }, + { url = "https://files.pythonhosted.org/packages/c6/39/74839641e64b99d87da55af0fc472854d42b46e2183b9e2a67fe1bb2a512/msgspec-0.21.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:15f523d51c00ebad412213bfe9f06f0a50ec2b93e0c19e824a2d267cabb48ea2", size = 200171, upload-time = "2026-04-12T21:44:39.414Z" }, + { url = "https://files.pythonhosted.org/packages/70/9b/ce0cca6d2d87fcd4b6ff97600790494e64f26a2c55d61507cd2755c16193/msgspec-0.21.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e47390360583ba3d5c6cb44cf0a9f61b0a06a899d3c2c00627cedebb2e2884b", size = 192879, upload-time = "2026-04-12T21:44:40.882Z" }, + { url = "https://files.pythonhosted.org/packages/a7/08/673a7bb05e5702dc787ddd3011195b509f9867927970da59052211929987/msgspec-0.21.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60800e6299b798142dc40b0644da77ceac5ea0568be58228417eae14135c847", size = 226281, upload-time = "2026-04-12T21:44:42.181Z" }, + { url = "https://files.pythonhosted.org/packages/7d/45/86508cf57283e9070b3c447e3ab25b792a7a0855a3ea4e0c6d111ac34c97/msgspec-0.21.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f8e9dfcd98419cf7568808470c4317a3fb30bef0e3715b568730a2b272a20d7", size = 229863, upload-time = "2026-04-12T21:44:43.442Z" }, + { url = "https://files.pythonhosted.org/packages/2c/62/e7c9367cd08d590559faacd711edbae36840342843e669440363f33c7d36/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92d89dfad13bd1ea640dc3e37e724ed380da1030b272bdf5ecafb983c3ad7c75", size = 230445, upload-time = "2026-04-12T21:44:44.806Z" }, + { url = "https://files.pythonhosted.org/packages/42/b4/c0f54632103846b658a10930025f4de41c8724b5e4805a5f3b395586cb7e/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0d03867786e5d7ba25d666df4b11320c27170f4aeafcb8e3a8b0a50a4fb742ca", size = 231822, upload-time = "2026-04-12T21:44:46.343Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1d/0d85cc79d0ccf5508e9c846cc66552a6a16bf92abd1dbd8362617f7b35cd/msgspec-0.21.1-cp314-cp314t-win_amd64.whl", hash = "sha256:740fbf1c9d59992ca3537d6fbe9ebbf9eaf726a65fbf31448e0ecbc710697a63", size = 206650, upload-time = "2026-04-12T21:44:47.601Z" }, + { url = "https://files.pythonhosted.org/packages/90/91/56c5d560f20e6c20e9e4f55bd0e458f7f162aa689ee350346c04c48eac0b/msgspec-0.21.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0d2cc73df6058d811a126ac3a8ad63a4dfa210c82f9cf5a004802eaf4712de90", size = 183149, upload-time = "2026-04-12T21:44:48.833Z" }, +] + +[[package]] +name = "mujoco" +version = "3.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "absl-py" }, + { name = "etils", extra = ["epath"] }, + { name = "glfw" }, + { name = "numpy" }, + { name = "pyopengl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/6e/9cba0cf43410aee5123ed670ce6e57f901974cc6a59093ce49200494b604/mujoco-3.7.0.tar.gz", hash = "sha256:d325c5448702a919db5b3d0050fff0af86d47da146d59678722b2112f7b55084", size = 917551, upload-time = "2026-04-14T12:50:31.331Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/7d/e1ad3b1b604b009c0df7246b50b5c5cf9e8e0689a10279661951c69850ea/mujoco-3.7.0-cp312-cp312-macosx_10_16_x86_64.whl", hash = "sha256:9193b8bc478708f199c2decb7bdeb06a962849f550ac182c35168ac9938ca859", size = 7217455, upload-time = "2026-04-14T12:50:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/5b/fc/225a068a2bec3de5029ba08866e03df9f159719cecedfc0cf100d4db6a18/mujoco-3.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:52b60fd634885c43c494868a992f696c078402c32b9c7a11bffa0fbf385687a7", size = 7162325, upload-time = "2026-04-14T12:50:11.025Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4d/a92e635cf1d38140c04fd504d07bacfd5d814753449fa75f3e7187660adb/mujoco-3.7.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a7216abee4fafe4bd6121548cb22dcacdb27f466da2f645bd9cbc404c0a29c9", size = 6709175, upload-time = "2026-04-14T12:50:13.188Z" }, + { url = "https://files.pythonhosted.org/packages/97/fa/a8698b7bb0168483845f8e492991ee5cf01695eb0d1f20ea7d8bf3d61344/mujoco-3.7.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79f741d93e2b4f315f32e7199ab1b21eb7b7ed82acd9daf7be24382e436f98b7", size = 7201272, upload-time = "2026-04-14T12:50:15.264Z" }, + { url = "https://files.pythonhosted.org/packages/32/b9/96a06d51d1b8a13ce010642c5e9a1b83b2364d6c290c2aba2d8c515f9871/mujoco-3.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:b0e31da299ff182d1063f9991ad21c21a8b086593b8dee588d19e910f2e49833", size = 5757244, upload-time = "2026-04-14T12:50:17.562Z" }, + { url = "https://files.pythonhosted.org/packages/4b/c8/1fedcd0d0b7dd86dd238502bc8ea228c0d48a901ffffc5d39272351c515c/mujoco-3.7.0-cp313-cp313-macosx_10_16_x86_64.whl", hash = "sha256:79730bec1e23a324cf66ccfa93585b5a8d3ba162d813cc0bfa6a42f776079983", size = 7217812, upload-time = "2026-04-14T12:50:19.583Z" }, + { url = "https://files.pythonhosted.org/packages/0f/75/8a54a9de7581f46e8dfb248d8cd2f972ef0d1db6ecfd8a70abb4ab12d56b/mujoco-3.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:70e008a2080156121caa60a7e0736c20b2278108cb35d1ab732f98e2e7c334f1", size = 7162437, upload-time = "2026-04-14T12:50:21.983Z" }, + { url = "https://files.pythonhosted.org/packages/64/bf/2180496f94c7a96b4520ad06e54505ef39b84b205ad674494c0d014584be/mujoco-3.7.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a81480e6dbbdcb5a5f4027c825935d5bcd34e3b9865bb818698701ee089e12f", size = 6709051, upload-time = "2026-04-14T12:50:24.407Z" }, + { url = "https://files.pythonhosted.org/packages/0f/de/4ebdd81f66f2d3879335e0f21f7be9b552d7edbc80c53f31472706d4e338/mujoco-3.7.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:246f61c885d8ac291a4f3b8e10d1deb3820302ab7a48b1edbf8a3539722841c3", size = 7201646, upload-time = "2026-04-14T12:50:26.473Z" }, + { url = "https://files.pythonhosted.org/packages/c8/24/313f0d31628123593af23441df7124ca1cc26dd0611ed5d31675899b190e/mujoco-3.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:67cae63b4c2c439ebffce7e185a0e129446a636af538dd3ccb5c9cd3f674033c", size = 5757431, upload-time = "2026-04-14T12:50:29.027Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, + { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, + { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, + { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, + { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, + { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, + { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, + { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, + { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, + { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, + { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, + { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, +] + +[[package]] +name = "proxsuite" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cmeel" }, + { name = "numpy" }, + { name = "scipy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/e8/9396b795ff1eb41f3e2ba0397417440d8479783bb235af95a01892354709/proxsuite-0.7.2-0-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:59960b635c7f2e874b378fdf2fad07a094f1818cdffabddbf13e39ae61fa6a4e", size = 1434525, upload-time = "2025-03-12T15:44:06.688Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6a/824ec181fbf45d13b4521b34fd1cc3b96c9818e752ac72a361df80b9e94c/proxsuite-0.7.2-0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:2ec27beccd57ff21d53ace0eb8fbe002f5144e4845d93a93173eb9ff037401ad", size = 606437, upload-time = "2025-03-12T15:44:08.408Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e8/3704f4b9c4d785cc166bdb50fe2db47e8f76450c6386d94ef383f185cebc/proxsuite-0.7.2-0-cp312-cp312-manylinux_2_17_x86_64.whl", hash = "sha256:5c5c4f0e960328720c403bb2248696bab8ba96cf612344e0874bc0586eeeb894", size = 1565881, upload-time = "2025-03-12T15:45:48.57Z" }, + { url = "https://files.pythonhosted.org/packages/95/87/f8fcf51a4ff1cba09ccfbebcbc8b0b483f42699cb94a4a8eb9e6ecb5fc5f/proxsuite-0.7.2-0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ccba984579f6e4b4a3db85e355bc06c51a42abfbbd3f75be38e7b06f83832cd1", size = 605135, upload-time = "2025-03-12T15:45:51.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b9/f31ef80e06095c05460832b73b57a25e338a68adc95f61b33e80ff13ecc9/proxsuite-0.7.2-0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80b5b6055c489074c74cec712c57016f7cf938a35234992139506d4046f9b80a", size = 1074058, upload-time = "2025-03-12T15:45:52.656Z" }, + { url = "https://files.pythonhosted.org/packages/81/86/208cd334288c9cea1401acae1cc25a048b646b4e1c24dc6a4a63c18b01b3/proxsuite-0.7.2-0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62d7226a2d44746b01a09b46644782393bb695fc7ab307e622bcad9ec853085b", size = 3364671, upload-time = "2025-03-12T15:45:54.356Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/8d8eddc7c88c3d5d8788a08963d1bf2d94cd7fa0372df5263ec1cb698f4a/proxsuite-0.7.2-0-cp312-cp312-win_amd64.whl", hash = "sha256:f16b58f528821c665bd2d461af68cb7330005ca5b7dc2b3b060aa5bd0646b3f6", size = 7128247, upload-time = "2025-03-12T15:44:09.995Z" }, + { url = "https://files.pythonhosted.org/packages/83/aa/86d346b72439c78b4411a4754895ac0590c2a1e0c4e3672ea438a6c67cb4/proxsuite-0.7.2-0-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:48bdd6d7d2588320e8f5d32e035ba40391adc4a6f1bf35bb727748781fcda421", size = 1434565, upload-time = "2025-03-12T15:44:13.637Z" }, + { url = "https://files.pythonhosted.org/packages/04/1d/d6b6d402f2ddf232fe0be220967eca75dc57d98ae7ca29600a489eac4e0e/proxsuite-0.7.2-0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4ef37e5b9aca29d7b2debd2ea06a1b54b9990ad1ffe7cfae8f6037da44560257", size = 606439, upload-time = "2025-03-12T15:44:15.691Z" }, + { url = "https://files.pythonhosted.org/packages/27/ea/6050672e84fa682c6c48edb6ad2511995681a51c5dd618a7870c90cc006c/proxsuite-0.7.2-0-cp313-cp313-manylinux_2_17_x86_64.whl", hash = "sha256:0a29464c3c0899b5de2b0b42bef23e2351fc71ad800d60a4f57bd90e0cd36567", size = 1565836, upload-time = "2025-03-12T15:45:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/b8/87/d69189d0df9de516112e07024ecbae8f8c707bd6bf0824dec2f083e8418d/proxsuite-0.7.2-0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:7820edf20a2f7708c9260fd48e16c3663a4e71f0ba77368420be391163ee281c", size = 605071, upload-time = "2025-03-12T15:45:57.27Z" }, + { url = "https://files.pythonhosted.org/packages/ba/bb/660648ebb7102cdf3629d1fa6576a5a3c529f8286066182bf21919748fdd/proxsuite-0.7.2-0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:23edbe79875b53a7da0eff6ec2471b74772ce39cb93701dbbac3fabd23f77f48", size = 1074091, upload-time = "2025-03-12T15:45:58.532Z" }, + { url = "https://files.pythonhosted.org/packages/ac/73/ae226e604be67d4ef1c4d1c92812b811e7c4b1326fe14efc81264e93c527/proxsuite-0.7.2-0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6622176c01b959533d55ebeb2e871f5d984f76b0867314ccc6b7993e37c8797b", size = 3364612, upload-time = "2025-03-12T15:46:00.02Z" }, + { url = "https://files.pythonhosted.org/packages/84/12/4564ffa8770c8ff33cb7c0242ba29bee6364318add6c4764c6c6aaaf1a12/proxsuite-0.7.2-0-cp313-cp313-win_amd64.whl", hash = "sha256:644ce1d255539ebe524f4be8c5f2d236a20ff01926b21759eb4fb4bbe3265518", size = 7129890, upload-time = "2025-03-12T15:44:17.165Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "pycollada" +version = "0.9.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/8d/52a5364a17eb96129962cae8d3ee7658775e085ad0ba38388684ad5944e9/pycollada-0.9.3.tar.gz", hash = "sha256:c34d6dcf0fe2eba5896f71c96d37a1c0fe1a61f08440fa0cfcec3dc2895d3302", size = 110826, upload-time = "2026-01-24T15:45:23.625Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/86/f1f61b7a0701f9d1299e5293d083318019f91021a4d449f94d59dbe024e4/pycollada-0.9.3-py3-none-any.whl", hash = "sha256:636e6496f60987586db82455ea7bbd9ade775e8181c6590c83b698b6cd53a9f5", size = 129206, upload-time = "2026-01-24T15:45:22.182Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyopengl" +version = "3.1.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/16/912b7225d56284859cd9a672827f18be43f8012f8b7b932bc4bd959a298e/pyopengl-3.1.10.tar.gz", hash = "sha256:c4a02d6866b54eb119c8e9b3fb04fa835a95ab802dd96607ab4cdb0012df8335", size = 1915580, upload-time = "2025-08-18T02:33:01.76Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/e4/1ba6f44e491c4eece978685230dde56b14d51a0365bc1b774ddaa94d14cd/pyopengl-3.1.10-py3-none-any.whl", hash = "sha256:794a943daced39300879e4e47bd94525280685f42dbb5a998d336cfff151d74f", size = 3194996, upload-time = "2025-08-18T02:32:59.902Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "qpsolvers" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/8d/d90b0aebb793d0e5093e7ed6b8b7c61bac0779a475e2c44ff312d30de871/qpsolvers-4.11.0.tar.gz", hash = "sha256:c6c036c775fb4f1048f586217412bbe67b5c128ece525ea2fe237b96eb3e29f2", size = 242117, upload-time = "2026-03-16T15:59:53.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/36/bb438a1ab5270be8a0d294a2734b31ebfabe8b1b41fde08f43966118b7e2/qpsolvers-4.11.0-py3-none-any.whl", hash = "sha256:02ced5690e17036802662f9054dcd8270fc8f4b8c3bddffbfcecd71a6353db00", size = 101875, upload-time = "2026-03-16T15:59:51.054Z" }, +] + +[package.optional-dependencies] +daqp = [ + { name = "daqp" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "rich" +version = "14.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/67/cae617f1351490c25a4b8ac3b8b63a4dda609295d8222bad12242dfdc629/rich-14.3.4.tar.gz", hash = "sha256:817e02727f2b25b40ef56f5aa2217f400c8489f79ca8f46ea2b70dd5e14558a9", size = 230524, upload-time = "2026-04-11T02:57:45.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/76/6d163cfac87b632216f71879e6b2cf17163f773ff59c00b5ff4900a80fa3/rich-14.3.4-py3-none-any.whl", hash = "sha256:07e7adb4690f68864777b1450859253bed81a99a31ac321ac1817b2313558952", size = 310480, upload-time = "2026-04-11T02:57:47.484Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, +] + +[[package]] +name = "rtree" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/09/7302695875a019514de9a5dd17b8320e7a19d6e7bc8f85dcfb79a4ce2da3/rtree-1.4.1.tar.gz", hash = "sha256:c6b1b3550881e57ebe530cc6cffefc87cd9bf49c30b37b894065a9f810875e46", size = 52425, upload-time = "2025-08-13T19:32:01.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/d9/108cd989a4c0954e60b3cdc86fd2826407702b5375f6dfdab2802e5fed98/rtree-1.4.1-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:d672184298527522d4914d8ae53bf76982b86ca420b0acde9298a7a87d81d4a4", size = 468484, upload-time = "2025-08-13T19:31:50.593Z" }, + { url = "https://files.pythonhosted.org/packages/f3/cf/2710b6fd6b07ea0aef317b29f335790ba6adf06a28ac236078ed9bd8a91d/rtree-1.4.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a7e48d805e12011c2cf739a29d6a60ae852fb1de9fc84220bbcef67e6e595d7d", size = 436325, upload-time = "2025-08-13T19:31:52.367Z" }, + { url = "https://files.pythonhosted.org/packages/55/e1/4d075268a46e68db3cac51846eb6a3ab96ed481c585c5a1ad411b3c23aad/rtree-1.4.1-py3-none-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa8c4496e31e9ad58ff6c7df89abceac7022d906cb64a3e18e4fceae6b77f65", size = 459789, upload-time = "2025-08-13T19:31:53.926Z" }, + { url = "https://files.pythonhosted.org/packages/d1/75/e5d44be90525cd28503e7f836d077ae6663ec0687a13ba7810b4114b3668/rtree-1.4.1-py3-none-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12de4578f1b3381a93a655846900be4e3d5f4cd5e306b8b00aa77c1121dc7e8c", size = 507644, upload-time = "2025-08-13T19:31:55.164Z" }, + { url = "https://files.pythonhosted.org/packages/fd/85/b8684f769a142163b52859a38a486493b05bafb4f2fb71d4f945de28ebf9/rtree-1.4.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b558edda52eca3e6d1ee629042192c65e6b7f2c150d6d6cd207ce82f85be3967", size = 1454478, upload-time = "2025-08-13T19:31:56.808Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a4/c2292b95246b9165cc43a0c3757e80995d58bc9b43da5cb47ad6e3535213/rtree-1.4.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f155bc8d6bac9dcd383481dee8c130947a4866db1d16cb6dff442329a038a0dc", size = 1555140, upload-time = "2025-08-13T19:31:58.031Z" }, + { url = "https://files.pythonhosted.org/packages/74/25/5282c8270bfcd620d3e73beb35b40ac4ab00f0a898d98ebeb41ef0989ec8/rtree-1.4.1-py3-none-win_amd64.whl", hash = "sha256:efe125f416fd27150197ab8521158662943a40f87acab8028a1aac4ad667a489", size = 389358, upload-time = "2025-08-13T19:31:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/3f/50/0a9e7e7afe7339bd5e36911f0ceb15fed51945836ed803ae5afd661057fd/rtree-1.4.1-py3-none-win_arm64.whl", hash = "sha256:3d46f55729b28138e897ffef32f7ce93ac335cb67f9120125ad3742a220800f0", size = 355253, upload-time = "2025-08-13T19:32:00.296Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, + { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, + { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, + { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, + { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "shapely" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/bc/0989043118a27cccb4e906a46b7565ce36ca7b57f5a18b78f4f1b0f72d9d/shapely-2.1.2.tar.gz", hash = "sha256:2ed4ecb28320a433db18a5bf029986aa8afcfd740745e78847e330d5d94922a9", size = 315489, upload-time = "2025-09-24T13:51:41.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/c0/f3b6453cf2dfa99adc0ba6675f9aaff9e526d2224cbd7ff9c1a879238693/shapely-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe2533caae6a91a543dec62e8360fe86ffcdc42a7c55f9dfd0128a977a896b94", size = 1833550, upload-time = "2025-09-24T13:50:30.019Z" }, + { url = "https://files.pythonhosted.org/packages/86/07/59dee0bc4b913b7ab59ab1086225baca5b8f19865e6101db9ebb7243e132/shapely-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ba4d1333cc0bc94381d6d4308d2e4e008e0bd128bdcff5573199742ee3634359", size = 1643556, upload-time = "2025-09-24T13:50:32.291Z" }, + { url = "https://files.pythonhosted.org/packages/26/29/a5397e75b435b9895cd53e165083faed5d12fd9626eadec15a83a2411f0f/shapely-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bd308103340030feef6c111d3eb98d50dc13feea33affc8a6f9fa549e9458a3", size = 2988308, upload-time = "2025-09-24T13:50:33.862Z" }, + { url = "https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b", size = 3099844, upload-time = "2025-09-24T13:50:35.459Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f3/9876b64d4a5a321b9dc482c92bb6f061f2fa42131cba643c699f39317cb9/shapely-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9eddfe513096a71896441a7c37db72da0687b34752c4e193577a145c71736fc", size = 3988842, upload-time = "2025-09-24T13:50:37.478Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/704c7292f7014c7e74ec84eddb7b109e1fbae74a16deae9c1504b1d15565/shapely-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:980c777c612514c0cf99bc8a9de6d286f5e186dcaf9091252fcd444e5638193d", size = 4152714, upload-time = "2025-09-24T13:50:39.9Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/319c9dc788884ad0785242543cdffac0e6530e4d0deb6c4862bc4143dcf3/shapely-2.1.2-cp312-cp312-win32.whl", hash = "sha256:9111274b88e4d7b54a95218e243282709b330ef52b7b86bc6aaf4f805306f454", size = 1542745, upload-time = "2025-09-24T13:50:41.414Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bf/cb6c1c505cb31e818e900b9312d514f381fbfa5c4363edfce0fcc4f8c1a4/shapely-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:743044b4cfb34f9a67205cee9279feaf60ba7d02e69febc2afc609047cb49179", size = 1722861, upload-time = "2025-09-24T13:50:43.35Z" }, + { url = "https://files.pythonhosted.org/packages/c3/90/98ef257c23c46425dc4d1d31005ad7c8d649fe423a38b917db02c30f1f5a/shapely-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b510dda1a3672d6879beb319bc7c5fd302c6c354584690973c838f46ec3e0fa8", size = 1832644, upload-time = "2025-09-24T13:50:44.886Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ab/0bee5a830d209adcd3a01f2d4b70e587cdd9fd7380d5198c064091005af8/shapely-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8cff473e81017594d20ec55d86b54bc635544897e13a7cfc12e36909c5309a2a", size = 1642887, upload-time = "2025-09-24T13:50:46.735Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5e/7d7f54ba960c13302584c73704d8c4d15404a51024631adb60b126a4ae88/shapely-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe7b77dc63d707c09726b7908f575fc04ff1d1ad0f3fb92aec212396bc6cfe5e", size = 2970931, upload-time = "2025-09-24T13:50:48.374Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a2/83fc37e2a58090e3d2ff79175a95493c664bcd0b653dd75cb9134645a4e5/shapely-2.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ed1a5bbfb386ee8332713bf7508bc24e32d24b74fc9a7b9f8529a55db9f4ee6", size = 3082855, upload-time = "2025-09-24T13:50:50.037Z" }, + { url = "https://files.pythonhosted.org/packages/44/2b/578faf235a5b09f16b5f02833c53822294d7f21b242f8e2d0cf03fb64321/shapely-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a84e0582858d841d54355246ddfcbd1fce3179f185da7470f41ce39d001ee1af", size = 3979960, upload-time = "2025-09-24T13:50:51.74Z" }, + { url = "https://files.pythonhosted.org/packages/4d/04/167f096386120f692cc4ca02f75a17b961858997a95e67a3cb6a7bbd6b53/shapely-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc3487447a43d42adcdf52d7ac73804f2312cbfa5d433a7d2c506dcab0033dfd", size = 4142851, upload-time = "2025-09-24T13:50:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/48/74/fb402c5a6235d1c65a97348b48cdedb75fb19eca2b1d66d04969fc1c6091/shapely-2.1.2-cp313-cp313-win32.whl", hash = "sha256:9c3a3c648aedc9f99c09263b39f2d8252f199cb3ac154fadc173283d7d111350", size = 1541890, upload-time = "2025-09-24T13:50:55.337Z" }, + { url = "https://files.pythonhosted.org/packages/41/47/3647fe7ad990af60ad98b889657a976042c9988c2807cf322a9d6685f462/shapely-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:ca2591bff6645c216695bdf1614fca9c82ea1144d4a7591a466fef64f28f0715", size = 1722151, upload-time = "2025-09-24T13:50:57.153Z" }, + { url = "https://files.pythonhosted.org/packages/3c/49/63953754faa51ffe7d8189bfbe9ca34def29f8c0e34c67cbe2a2795f269d/shapely-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2d93d23bdd2ed9dc157b46bc2f19b7da143ca8714464249bef6771c679d5ff40", size = 1834130, upload-time = "2025-09-24T13:50:58.49Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ee/dce001c1984052970ff60eb4727164892fb2d08052c575042a47f5a9e88f/shapely-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01d0d304b25634d60bd7cf291828119ab55a3bab87dc4af1e44b07fb225f188b", size = 1642802, upload-time = "2025-09-24T13:50:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/da/e7/fc4e9a19929522877fa602f705706b96e78376afb7fad09cad5b9af1553c/shapely-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d8382dd120d64b03698b7298b89611a6ea6f55ada9d39942838b79c9bc89801", size = 3018460, upload-time = "2025-09-24T13:51:02.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/18/7519a25db21847b525696883ddc8e6a0ecaa36159ea88e0fef11466384d0/shapely-2.1.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19efa3611eef966e776183e338b2d7ea43569ae99ab34f8d17c2c054d3205cc0", size = 3095223, upload-time = "2025-09-24T13:51:04.472Z" }, + { url = "https://files.pythonhosted.org/packages/48/de/b59a620b1f3a129c3fecc2737104a0a7e04e79335bd3b0a1f1609744cf17/shapely-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:346ec0c1a0fcd32f57f00e4134d1200e14bf3f5ae12af87ba83ca275c502498c", size = 4030760, upload-time = "2025-09-24T13:51:06.455Z" }, + { url = "https://files.pythonhosted.org/packages/96/b3/c6655ee7232b417562bae192ae0d3ceaadb1cc0ffc2088a2ddf415456cc2/shapely-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6305993a35989391bd3476ee538a5c9a845861462327efe00dd11a5c8c709a99", size = 4170078, upload-time = "2025-09-24T13:51:08.584Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8e/605c76808d73503c9333af8f6cbe7e1354d2d238bda5f88eea36bfe0f42a/shapely-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:c8876673449f3401f278c86eb33224c5764582f72b653a415d0e6672fde887bf", size = 1559178, upload-time = "2025-09-24T13:51:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/36/f7/d317eb232352a1f1444d11002d477e54514a4a6045536d49d0c59783c0da/shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c", size = 1739756, upload-time = "2025-09-24T13:51:12.105Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c4/3ce4c2d9b6aabd27d26ec988f08cb877ba9e6e96086eff81bfea93e688c7/shapely-2.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:9a522f460d28e2bf4e12396240a5fc1518788b2fcd73535166d748399ef0c223", size = 1831290, upload-time = "2025-09-24T13:51:13.56Z" }, + { url = "https://files.pythonhosted.org/packages/17/b9/f6ab8918fc15429f79cb04afa9f9913546212d7fb5e5196132a2af46676b/shapely-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ff629e00818033b8d71139565527ced7d776c269a49bd78c9df84e8f852190c", size = 1641463, upload-time = "2025-09-24T13:51:14.972Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/91d59ae525ca641e7ac5551c04c9503aee6f29b92b392f31790fcb1a4358/shapely-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f67b34271dedc3c653eba4e3d7111aa421d5be9b4c4c7d38d30907f796cb30df", size = 2970145, upload-time = "2025-09-24T13:51:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cb/4948be52ee1da6927831ab59e10d4c29baa2a714f599f1f0d1bc747f5777/shapely-2.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21952dc00df38a2c28375659b07a3979d22641aeb104751e769c3ee825aadecf", size = 3073806, upload-time = "2025-09-24T13:51:18.712Z" }, + { url = "https://files.pythonhosted.org/packages/03/83/f768a54af775eb41ef2e7bec8a0a0dbe7d2431c3e78c0a8bdba7ab17e446/shapely-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1f2f33f486777456586948e333a56ae21f35ae273be99255a191f5c1fa302eb4", size = 3980803, upload-time = "2025-09-24T13:51:20.37Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/559c7c195807c91c79d38a1f6901384a2878a76fbdf3f1048893a9b7534d/shapely-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cf831a13e0d5a7eb519e96f58ec26e049b1fad411fc6fc23b162a7ce04d9cffc", size = 4133301, upload-time = "2025-09-24T13:51:21.887Z" }, + { url = "https://files.pythonhosted.org/packages/80/cd/60d5ae203241c53ef3abd2ef27c6800e21afd6c94e39db5315ea0cbafb4a/shapely-2.1.2-cp314-cp314-win32.whl", hash = "sha256:61edcd8d0d17dd99075d320a1dd39c0cb9616f7572f10ef91b4b5b00c4aeb566", size = 1583247, upload-time = "2025-09-24T13:51:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/74/d4/135684f342e909330e50d31d441ace06bf83c7dc0777e11043f99167b123/shapely-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:a444e7afccdb0999e203b976adb37ea633725333e5b119ad40b1ca291ecf311c", size = 1773019, upload-time = "2025-09-24T13:51:24.873Z" }, + { url = "https://files.pythonhosted.org/packages/a3/05/a44f3f9f695fa3ada22786dc9da33c933da1cbc4bfe876fe3a100bafe263/shapely-2.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5ebe3f84c6112ad3d4632b1fd2290665aa75d4cef5f6c5d77c4c95b324527c6a", size = 1834137, upload-time = "2025-09-24T13:51:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/4d57db45bf314573427b0a70dfca15d912d108e6023f623947fa69f39b72/shapely-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5860eb9f00a1d49ebb14e881f5caf6c2cf472c7fd38bd7f253bbd34f934eb076", size = 1642884, upload-time = "2025-09-24T13:51:28.029Z" }, + { url = "https://files.pythonhosted.org/packages/5a/27/4e29c0a55d6d14ad7422bf86995d7ff3f54af0eba59617eb95caf84b9680/shapely-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b705c99c76695702656327b819c9660768ec33f5ce01fa32b2af62b56ba400a1", size = 3018320, upload-time = "2025-09-24T13:51:29.903Z" }, + { url = "https://files.pythonhosted.org/packages/9f/bb/992e6a3c463f4d29d4cd6ab8963b75b1b1040199edbd72beada4af46bde5/shapely-2.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a1fd0ea855b2cf7c9cddaf25543e914dd75af9de08785f20ca3085f2c9ca60b0", size = 3094931, upload-time = "2025-09-24T13:51:32.699Z" }, + { url = "https://files.pythonhosted.org/packages/9c/16/82e65e21070e473f0ed6451224ed9fa0be85033d17e0c6e7213a12f59d12/shapely-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:df90e2db118c3671a0754f38e36802db75fe0920d211a27481daf50a711fdf26", size = 4030406, upload-time = "2025-09-24T13:51:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/7c/75/c24ed871c576d7e2b64b04b1fe3d075157f6eb54e59670d3f5ffb36e25c7/shapely-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:361b6d45030b4ac64ddd0a26046906c8202eb60d0f9f53085f5179f1d23021a0", size = 4169511, upload-time = "2025-09-24T13:51:36.297Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f7/b3d1d6d18ebf55236eec1c681ce5e665742aab3c0b7b232720a7d43df7b6/shapely-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:b54df60f1fbdecc8ebc2c5b11870461a6417b3d617f555e5033f1505d36e5735", size = 1602607, upload-time = "2025-09-24T13:51:37.757Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f6/f09272a71976dfc138129b8faf435d064a811ae2f708cb147dccdf7aacdb/shapely-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9", size = 1796682, upload-time = "2025-09-24T13:51:39.233Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "sim" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "imageio", extra = ["ffmpeg"] }, + { name = "jaxtyping" }, + { name = "mink" }, + { name = "mujoco" }, + { name = "numpy" }, + { name = "proxsuite" }, + { name = "typer" }, + { name = "viser" }, +] + +[package.dev-dependencies] +dev = [ + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "imageio", extras = ["ffmpeg"], specifier = ">=2.37.3" }, + { name = "jaxtyping", specifier = ">=0.3.9" }, + { name = "mink", specifier = ">=1.1.0" }, + { name = "mujoco", specifier = ">=3.7.0" }, + { name = "numpy", specifier = ">=2.4.4" }, + { name = "proxsuite", specifier = ">=0.7.2,!=0.7.2.post1" }, + { name = "typer", specifier = ">=0.12.5" }, + { name = "viser", specifier = ">=1.0.26" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "ruff", specifier = ">=0.15.11" }, + { name = "ty", specifier = ">=0.0.32" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "svg-path" +version = "7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/b9/649abbe870842c185b12920e937e9b95d4c2b18de50af98d2c140df3e179/svg_path-7.0.tar.gz", hash = "sha256:9037486957cb1dcf4375ef42206499a47c111b8ffcbac6e3e55f9d079d875bb0", size = 23552, upload-time = "2025-07-06T15:20:40.823Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/83/4f5b250220e1a5acd31345a5ec1c95a7769725d0d8135276f399f44062f8/svg_path-7.0-py2.py3-none-any.whl", hash = "sha256:447cb1e16a95acea2dd867fe737fa99cb75d587b4fc64dbee709a8dd6891ad9c", size = 18208, upload-time = "2025-07-06T15:20:39.59Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "trimesh" +version = "4.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/1a/6043f116e5fd6df89a5ed33402c1c423913f02202d35892bfc164e5fd0cb/trimesh-4.12.0.tar.gz", hash = "sha256:72b32b19348f3d6f7952312458207c0d65816a96d4a7b9af423099768aa0feb4", size = 842103, upload-time = "2026-04-23T23:44:18.527Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/8e/47f2a79b53b58d89b672b98ebd1f4317ff45a2d073fac883261049c37b8a/trimesh-4.12.0-py3-none-any.whl", hash = "sha256:a0502bef0a9bd8fb61ef296a89a95dd50c22dbcc4329c282b113915e964341db", size = 741039, upload-time = "2026-04-23T23:44:16Z" }, +] + +[package.optional-dependencies] +easy = [ + { name = "charset-normalizer" }, + { name = "colorlog" }, + { name = "embreex" }, + { name = "httpx" }, + { name = "jsonschema" }, + { name = "lxml" }, + { name = "manifold3d", marker = "python_full_version < '3.14'" }, + { name = "mapbox-earcut" }, + { name = "networkx" }, + { name = "pillow" }, + { name = "pycollada" }, + { name = "rtree" }, + { name = "scipy" }, + { name = "shapely" }, + { name = "svg-path" }, + { name = "vhacdx" }, + { name = "xxhash" }, +] + +[[package]] +name = "ty" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/7e/2aa791c9ae7b8cd5024cd4122e92267f664ca954cea3def3211919fa3c1f/ty-0.0.32.tar.gz", hash = "sha256:8743174c5f920f6700a4a0c9de140109189192ba16226884cd50095b43b8a45c", size = 5522294, upload-time = "2026-04-20T19:29:01.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/eb/1075dc6a49d7acbe2584ae4d5b410c41b1f177a5adcc567e09eca4c69000/ty-0.0.32-py3-none-linux_armv6l.whl", hash = "sha256:dacbc2f6cd698d488ae7436838ff929570455bf94bfa4d9fe57a630c552aff83", size = 10902959, upload-time = "2026-04-20T19:28:31.907Z" }, + { url = "https://files.pythonhosted.org/packages/33/d2/c35fc8bc66e98d1ee9b0f8ed319bf743e450e1f1e997574b178fab75670f/ty-0.0.32-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:914bbc4f605ce2a9e2a78982e28fae1d3359a169d141f9dc3b4c7749cd5eca81", size = 10726172, upload-time = "2026-04-20T19:28:44.765Z" }, + { url = "https://files.pythonhosted.org/packages/96/32/c827da3ca480456fb02d8cea68a2609273b6c220fea0be9a4c8d8470b86e/ty-0.0.32-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4787ac9fe1f86b1f3133f5c6732adbe2df5668b50c679ac6e2d98cd284da812f", size = 10163701, upload-time = "2026-04-20T19:28:27.005Z" }, + { url = "https://files.pythonhosted.org/packages/ba/9e/2734478fbdb90c160cb2813a3916a16a2af5c1e231f87d635f6131d781fb/ty-0.0.32-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8ea0a728af99fe40dd744cba6441a2404f80b7f4bde17aa6da393810af5ea57", size = 10656220, upload-time = "2026-04-20T19:29:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/44/9f/0007da2d35e424debe7e9f86ffbc1ab7f60983cfbc5f0411324ab2de5292/ty-0.0.32-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2850561f9b018ae33d7e5bbfa0ac414d3c518513edcffe43877dc9801446b9c5", size = 10696086, upload-time = "2026-04-20T19:28:46.829Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5e/ce5fd4ec803222ae3e69a76d2a2db2eed55e19f5b131702b9789ef45f93d/ty-0.0.32-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b5fa2fb3c614349ee211d36476b49d88c5ef79a687cdb91b2872ad023b94d2f8", size = 11184800, upload-time = "2026-04-20T19:28:42.57Z" }, + { url = "https://files.pythonhosted.org/packages/6c/46/ebcf67a5999421331214aac51a7464db42de2be15bbe929c612a3ed0b039/ty-0.0.32-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b89969307ab2417d41c9be8059dd79feea577234e1e10d35132f5495e0d42c6", size = 11718718, upload-time = "2026-04-20T19:28:36.433Z" }, + { url = "https://files.pythonhosted.org/packages/18/2c/2141c86ed0ce0962b45cefb658a95e734f59759d47f20afdcd9c732910a1/ty-0.0.32-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b59868ede9b1d69a088f0d695df52a0061f95fa7baa1d5e0dc6fc9cf06e1334", size = 11346369, upload-time = "2026-04-20T19:28:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/7a/da/ed6f772339cf29bd9a46def9d6db5084689eb574ee4d150ff704224c1ed8/ty-0.0.32-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8300caf35345498e9b9b03e550bba03cee8f5f5f8ab4c83c3b1ff1b7403b7d3a", size = 11280714, upload-time = "2026-04-20T19:28:51.516Z" }, + { url = "https://files.pythonhosted.org/packages/da/9b/c6813987edf4816a40e0c8e408b555f97d3f267c7b3a1688c8bbdf65609c/ty-0.0.32-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:583c7094f4574b02f724db924f98b804d1387a0bd9405ecb5e078cc0f47fbcfb", size = 10638806, upload-time = "2026-04-20T19:28:29.651Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d4/0cefcbd2ad0f3d51762ccf58e652ec7da146eb6ae34f87228f6254bbb8be/ty-0.0.32-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e44ebe1bb4143a5628bc4db67ac0dfebe14594af671e4ee66f6f2e983da56501", size = 10726106, upload-time = "2026-04-20T19:29:06.3Z" }, + { url = "https://files.pythonhosted.org/packages/32/ad/2c8a97f91f06311f4367400f7d13534bbda2522c73c99a3e4c0757dff9b8/ty-0.0.32-py3-none-musllinux_1_2_i686.whl", hash = "sha256:06f17ada3e069cba6148342ef88e9929156beca8473e8d4f101b68f66c75643e", size = 10872951, upload-time = "2026-04-20T19:28:34.077Z" }, + { url = "https://files.pythonhosted.org/packages/ba/68/42293f9248106dd51875120971a5cc6ea315c2c4dcfb8e59aa063aa0af26/ty-0.0.32-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e96e60fa556cec04f15d7ea62d2ceee5982bd389233e961ab9fd42304e278175", size = 11363334, upload-time = "2026-04-20T19:28:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/df/92/be9abf4d3e589ad5023e2ea965b93e204ec856420d46adf73c5c36c04678/ty-0.0.32-py3-none-win32.whl", hash = "sha256:2ff2ebb4986b24aebcf1444db7db5ca41b36086040e95eea9f8fb851c11e805c", size = 10260689, upload-time = "2026-04-20T19:28:56.541Z" }, + { url = "https://files.pythonhosted.org/packages/14/61/dc86acea899349d2579cb8419aecedd83dc504d7d6a10df65eef546c8300/ty-0.0.32-py3-none-win_amd64.whl", hash = "sha256:ba7284a4a954b598c1b31500352b3ec1f89bff533825592b5958848226fdc7ee", size = 11255371, upload-time = "2026-04-20T19:28:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/43/01/beffec56d71ca25b343ede63adb076456b5b3e211f1c066452a44cd120b3/ty-0.0.32-py3-none-win_arm64.whl", hash = "sha256:7e10aadbdbda989a7d567ee6a37f8b98d4d542e31e3b190a2879fd581f75d658", size = 10658087, upload-time = "2026-04-20T19:28:59.286Z" }, +] + +[[package]] +name = "typer" +version = "0.24.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/b8/9ebb531b6c2d377af08ac6746a5df3425b21853a5d2260876919b58a2a4a/typer-0.24.2.tar.gz", hash = "sha256:ec070dcfca1408e85ee203c6365001e818c3b7fffe686fd07ff2d68095ca0480", size = 119849, upload-time = "2026-04-22T17:45:34.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/d1/9484b497e0a0410b901c12b8251c3e746e1e863f7d28419ffe06f7892fda/typer-0.24.2-py3-none-any.whl", hash = "sha256:b618bc3d721f9a8d30f3e05565be26416d06e9bcc29d49bc491dc26aba674fa8", size = 55977, upload-time = "2026-04-22T17:45:33.055Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "vhacdx" +version = "0.0.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/e3/d2abc3dc4c1cb216c2efdc70b36f80efeb1bdbd7d420a676ddc9d9d980e1/vhacdx-0.0.10.tar.gz", hash = "sha256:fcc23201e319d79fe25e064847efc254bd39ac30af28cc761409e1f9142dd033", size = 58125, upload-time = "2025-12-02T20:58:45.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/9c/66375e65634c80f6efb46e81915126bf3e55dc9d6615217590cbc8316d2e/vhacdx-0.0.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7dd17d697d6d4d7cf66f1e947e0530041913981e05f7025236bec28a350b1a33", size = 224998, upload-time = "2025-12-02T20:57:51.639Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e3/fc2644d3e7d0b2b52e2f681eb2878c0e1b9cafc53946f66736d0f01e237c/vhacdx-0.0.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:189ded39b709436cb732cdf694d4cf22e877aefb97e2ab2b55bf7ada9c030f93", size = 211130, upload-time = "2025-12-02T20:57:53.018Z" }, + { url = "https://files.pythonhosted.org/packages/e3/93/0b0f1977f5b3c2e1bbea5ef85e37a808ff73f1b7daf42950c57090e90dc7/vhacdx-0.0.10-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3b03d35ab56a93beee338175dbe0b87552353e5dfb3ff37467e88f56cedf7cc", size = 239661, upload-time = "2025-12-02T20:57:54.144Z" }, + { url = "https://files.pythonhosted.org/packages/94/98/d2a6aeb1c6570a1fc1be29ee03db795f643ab03c6df7635522f23796b39d/vhacdx-0.0.10-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8c54ed193fa0db0248928fbf5d438b3872d615a506889d5b89fc6467d6411a", size = 252938, upload-time = "2025-12-02T20:57:55.275Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/1e678efc161a0d7fe1806f5e037ce11cc5964db7e08ccfc220ef63951863/vhacdx-0.0.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5c898104140c72e4dc789e6125812671eee5e412916e83eff24a6148248ff5e", size = 1226696, upload-time = "2025-12-02T20:57:56.438Z" }, + { url = "https://files.pythonhosted.org/packages/90/5b/b302a0420a241c4910f4870eb9f39e6ada59858db441cc35bda511c17982/vhacdx-0.0.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:abdd0ba17786e578206594731df15c90e2751b6884220c8673124f47fd7ac620", size = 1287794, upload-time = "2025-12-02T20:57:57.694Z" }, + { url = "https://files.pythonhosted.org/packages/73/e9/f9729603ac75047a257f1b4ddac60cbde72b0abfd49ffed305751ba630a2/vhacdx-0.0.10-cp312-cp312-win_amd64.whl", hash = "sha256:79e7db59b4042295b21b79d55ba486a9a480550f696d466f158a30ed920dd0ec", size = 195033, upload-time = "2025-12-02T20:57:58.95Z" }, + { url = "https://files.pythonhosted.org/packages/0e/54/c2fc08d9324bbd92735caf9207cbabada3a8dd9d270d6e46ca69eb7f883d/vhacdx-0.0.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0599bc2a96de8fc9aeff460b3e88b8572e84ae95b8fc6c9888ef4b92023c22d5", size = 225014, upload-time = "2025-12-02T20:58:00.938Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9e/42adb642a12915acc9cb2bfab21710a6aabf045c26967ba0ff0e08a872d0/vhacdx-0.0.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dc648829a1e973f34ee11393a4f334ab55e3e0e9c4b9f6d6349af966fdf1895a", size = 211127, upload-time = "2025-12-02T20:58:02.107Z" }, + { url = "https://files.pythonhosted.org/packages/51/3d/63e090cd966817b89643d7e52e13df45043b22a42c7fbf702866bdd75bc0/vhacdx-0.0.10-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74c03f7315a434ec83cd0bff1e6bce6af4c01df61d677f48f3ffb36800606ee7", size = 239471, upload-time = "2025-12-02T20:58:03.173Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b4/07ab1c828bae0eb5c72cd9a4cbe8b0376d374509be3c7055e1a399bf85c3/vhacdx-0.0.10-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fcd02acc3733ec3a0a0d28ca7f526d4c56f14a3ceb4b12fce45acf72c09054a", size = 253019, upload-time = "2025-12-02T20:58:04.318Z" }, + { url = "https://files.pythonhosted.org/packages/05/cb/bc8a8858b300d2c092da11096ae0586ece446b4c41cb26620bf00d1d8232/vhacdx-0.0.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4b9f8a80ca4c54d7fa76419a2ebd9e9386cd177dc4d2b97f2208ac57c9a7e8aa", size = 1226933, upload-time = "2025-12-02T20:58:05.907Z" }, + { url = "https://files.pythonhosted.org/packages/15/52/213230883874615f1661903bce1ace5013d03b34696efce8d53c662a3358/vhacdx-0.0.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:847bd43e82afb439dd3fa972618d786d0b98d8ef04a8e8a6381f6945204d2505", size = 1288871, upload-time = "2025-12-02T20:58:07.432Z" }, + { url = "https://files.pythonhosted.org/packages/32/25/f0e6806824f88d47ab8bc1c9bf6f11634fd7b382d635d0696825f3b5672f/vhacdx-0.0.10-cp313-cp313-win_amd64.whl", hash = "sha256:ab300c5f3fe4e54f99af92f9ea27c977b09df5f59190b0a3e025161110f71ce7", size = 195091, upload-time = "2025-12-02T20:58:08.783Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b8/5137c048728fddd3315a79c94ba8663f3707f9268af9af15b15e1ef3cd85/vhacdx-0.0.10-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:147030c7683be4f21a3cdfb202b121c01716694b61ddad794345fcd9fa43d0ec", size = 225247, upload-time = "2025-12-02T20:58:09.918Z" }, + { url = "https://files.pythonhosted.org/packages/1b/08/5c731863db402e9878380f68be8722fabbcaf8cfe8d06237aaf15f116d95/vhacdx-0.0.10-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:069eb4381917b790921a33d4cc6ed07f7ed5362474232110baf8dd3dadcd768d", size = 211339, upload-time = "2025-12-02T20:58:10.951Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/e93ce9b653a9f435c530df8d5ad68a80b8bdc2b8518abc225fef9e7f349a/vhacdx-0.0.10-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7702892008b1150619c1f66a62ef88d1cb8f92b09d9c39a0bfb87d147f1c5ae2", size = 239974, upload-time = "2025-12-02T20:58:12.101Z" }, + { url = "https://files.pythonhosted.org/packages/77/dc/ef34f97a65385bc1f8ed4718fa5f7d96313e299e76761f1b69efaf597797/vhacdx-0.0.10-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4d550dfc377471b36f11065fc12cfbbd1750d63b10a336644bfdcbf27aa8382", size = 253245, upload-time = "2025-12-02T20:58:13.303Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6c/57051066bd0589b7fe68c32061821180f520b6a7ef4efa072b755dde63d3/vhacdx-0.0.10-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:edce8f0ff516a111b6f1d644782a1d496b3e9e34ff4ce09849c9b8071627bca5", size = 1227432, upload-time = "2025-12-02T20:58:14.73Z" }, + { url = "https://files.pythonhosted.org/packages/1d/49/3488f2bd991027bd86f072cf776935c80b4e630bd3bc43c3289bc6eeeba0/vhacdx-0.0.10-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4c463abbdce73d5d0b94eab2c9f43f2b55a4d0e788d87af659cc78029b960bf9", size = 1289126, upload-time = "2025-12-02T20:58:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/2f4506382a1133bf441cba2010017064e8f7af940d100141799d7e867e58/vhacdx-0.0.10-cp314-cp314-win_amd64.whl", hash = "sha256:b93c834f2bf1fa6630da5d3f77e94ea8e542fca31e385244a7ec905a32155549", size = 198706, upload-time = "2025-12-02T20:58:17.378Z" }, + { url = "https://files.pythonhosted.org/packages/db/f6/4fabfe65f3123abda09adc416a396caf8c2ad1b29c34a5178ec71754a163/vhacdx-0.0.10-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4c0f1bafc53e156472b0367533c2d3ec7a96b676b6d57aa92dd3e37519331b07", size = 228276, upload-time = "2025-12-02T20:58:18.545Z" }, + { url = "https://files.pythonhosted.org/packages/dc/70/bdc742628adcf9966cea81be7a651300bc399b492d10a763781af6d27041/vhacdx-0.0.10-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b0f8643dcb1f0774320fc1389ead0d0da4536e4c0fecfd4c8133baec0b6fa556", size = 214287, upload-time = "2025-12-02T20:58:19.696Z" }, + { url = "https://files.pythonhosted.org/packages/84/6a/f2e37ad333d3f671e1d59ba76bb61edc5520146539d52ee29e555becb4ac/vhacdx-0.0.10-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4547f3e55eb935d163d89c10ffdcadf8797c3b435a9dc82be4e0e27b1e3abff0", size = 240923, upload-time = "2025-12-02T20:58:21.105Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7a/f0325cd7ece95dbbc10d0c3f6d241d47beb3b99ae4dafe2e450082cd7bd9/vhacdx-0.0.10-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee09c4f2b6385546001b5e8609f428417fac147cfd3ea020fbc7dec0f11c489b", size = 254257, upload-time = "2025-12-02T20:58:22.176Z" }, + { url = "https://files.pythonhosted.org/packages/ac/56/53347b910351eb4cf32a797e177f18b8d82b1ef4e4325607254cfe88ad2a/vhacdx-0.0.10-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b94d198e4716f9220985523f374617432ef5530910f3730051f3e7fcba71798", size = 1228434, upload-time = "2025-12-02T20:58:23.302Z" }, + { url = "https://files.pythonhosted.org/packages/be/f5/f86c63da38b0446ef6652e8e72b84451e440418eaac0f554737e159ae36e/vhacdx-0.0.10-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:39c6d31ed27e3f33e9411927e1567ba37a18ba7ce9295efd1b24414b7313b503", size = 1288854, upload-time = "2025-12-02T20:58:24.46Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d1/b30dec954a24b41358297a3fbe7c30d8e2e818831f552cb34c904baa04e4/vhacdx-0.0.10-cp314-cp314t-win_amd64.whl", hash = "sha256:fc6a613082ec522a020e4f6a09f39ed42546de9aebe99548aa84938b1440871c", size = 204896, upload-time = "2025-12-02T20:58:25.825Z" }, +] + +[[package]] +name = "viser" +version = "1.0.26" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "imageio" }, + { name = "msgspec" }, + { name = "numpy" }, + { name = "requests" }, + { name = "rich" }, + { name = "tqdm" }, + { name = "trimesh" }, + { name = "typing-extensions" }, + { name = "websockets" }, + { name = "yourdfpy" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/ce/82a0e50fae21f5e02fcc5d9aff2ab59dccb9c319b6c4cf528f2228049b05/viser-1.0.26.tar.gz", hash = "sha256:dc08c6f505e70324b0603bdddf9714c00ac828c259ee49abd8ad094bfc90c91c", size = 4828261, upload-time = "2026-03-30T11:43:19.513Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/f7/762a2d5fab509d0c632b271e21e634462397cc02cca649771c3e9d2e0bcc/viser-1.0.26-py3-none-any.whl", hash = "sha256:03b177b4ef584f58f7b74fdf44cccb165b8a220ffd90728ef5c1e3d1b1fcf258", size = 4922888, upload-time = "2026-03-30T11:43:21.355Z" }, +] + +[[package]] +name = "wadler-lindig" +version = "0.1.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/67/cbae4bf7683a64755c2c1778c418fea96d00e34395bb91743f08bd951571/wadler_lindig-0.1.7.tar.gz", hash = "sha256:81d14d3fe77d441acf3ebd7f4aefac20c74128bf460e84b512806dccf7b2cd55", size = 15842, upload-time = "2025-06-18T07:00:42.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/96/04e7b441807b26b794da5b11e59ed7f83b2cf8af202bd7eba8ad2fa6046e/wadler_lindig-0.1.7-py3-none-any.whl", hash = "sha256:e3ec83835570fd0a9509f969162aeb9c65618f998b1f42918cfc8d45122fe953", size = 20516, upload-time = "2025-06-18T07:00:41.684Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "xxhash" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160, upload-time = "2025-10-02T14:37:08.097Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/07/d9412f3d7d462347e4511181dea65e47e0d0e16e26fbee2ea86a2aefb657/xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c", size = 32744, upload-time = "2025-10-02T14:34:34.622Z" }, + { url = "https://files.pythonhosted.org/packages/79/35/0429ee11d035fc33abe32dca1b2b69e8c18d236547b9a9b72c1929189b9a/xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204", size = 30816, upload-time = "2025-10-02T14:34:36.043Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f2/57eb99aa0f7d98624c0932c5b9a170e1806406cdbcdb510546634a1359e0/xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490", size = 194035, upload-time = "2025-10-02T14:34:37.354Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ed/6224ba353690d73af7a3f1c7cdb1fc1b002e38f783cb991ae338e1eb3d79/xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2", size = 212914, upload-time = "2025-10-02T14:34:38.6Z" }, + { url = "https://files.pythonhosted.org/packages/38/86/fb6b6130d8dd6b8942cc17ab4d90e223653a89aa32ad2776f8af7064ed13/xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa", size = 212163, upload-time = "2025-10-02T14:34:39.872Z" }, + { url = "https://files.pythonhosted.org/packages/ee/dc/e84875682b0593e884ad73b2d40767b5790d417bde603cceb6878901d647/xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0", size = 445411, upload-time = "2025-10-02T14:34:41.569Z" }, + { url = "https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2", size = 193883, upload-time = "2025-10-02T14:34:43.249Z" }, + { url = "https://files.pythonhosted.org/packages/53/5a/ddbb83eee8e28b778eacfc5a85c969673e4023cdeedcfcef61f36731610b/xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9", size = 210392, upload-time = "2025-10-02T14:34:45.042Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c2/ff69efd07c8c074ccdf0a4f36fcdd3d27363665bcdf4ba399abebe643465/xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e", size = 197898, upload-time = "2025-10-02T14:34:46.302Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/faa05ac19b3b622c7c9317ac3e23954187516298a091eb02c976d0d3dd45/xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374", size = 210655, upload-time = "2025-10-02T14:34:47.571Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7a/06aa7482345480cc0cb597f5c875b11a82c3953f534394f620b0be2f700c/xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d", size = 414001, upload-time = "2025-10-02T14:34:49.273Z" }, + { url = "https://files.pythonhosted.org/packages/23/07/63ffb386cd47029aa2916b3d2f454e6cc5b9f5c5ada3790377d5430084e7/xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae", size = 191431, upload-time = "2025-10-02T14:34:50.798Z" }, + { url = "https://files.pythonhosted.org/packages/0f/93/14fde614cadb4ddf5e7cebf8918b7e8fac5ae7861c1875964f17e678205c/xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb", size = 30617, upload-time = "2025-10-02T14:34:51.954Z" }, + { url = "https://files.pythonhosted.org/packages/13/5d/0d125536cbe7565a83d06e43783389ecae0c0f2ed037b48ede185de477c0/xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c", size = 31534, upload-time = "2025-10-02T14:34:53.276Z" }, + { url = "https://files.pythonhosted.org/packages/54/85/6ec269b0952ec7e36ba019125982cf11d91256a778c7c3f98a4c5043d283/xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829", size = 27876, upload-time = "2025-10-02T14:34:54.371Z" }, + { url = "https://files.pythonhosted.org/packages/33/76/35d05267ac82f53ae9b0e554da7c5e281ee61f3cad44c743f0fcd354f211/xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec", size = 32738, upload-time = "2025-10-02T14:34:55.839Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/3fbce1cd96534a95e35d5120637bf29b0d7f5d8fa2f6374e31b4156dd419/xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1", size = 30821, upload-time = "2025-10-02T14:34:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ea/d387530ca7ecfa183cb358027f1833297c6ac6098223fd14f9782cd0015c/xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6", size = 194127, upload-time = "2025-10-02T14:34:59.21Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0c/71435dcb99874b09a43b8d7c54071e600a7481e42b3e3ce1eb5226a5711a/xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263", size = 212975, upload-time = "2025-10-02T14:35:00.816Z" }, + { url = "https://files.pythonhosted.org/packages/84/7a/c2b3d071e4bb4a90b7057228a99b10d51744878f4a8a6dd643c8bd897620/xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546", size = 212241, upload-time = "2025-10-02T14:35:02.207Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/640b6eac0128e215f177df99eadcd0f1b7c42c274ab6a394a05059694c5a/xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89", size = 445471, upload-time = "2025-10-02T14:35:03.61Z" }, + { url = "https://files.pythonhosted.org/packages/5e/1e/3c3d3ef071b051cc3abbe3721ffb8365033a172613c04af2da89d5548a87/xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d", size = 193936, upload-time = "2025-10-02T14:35:05.013Z" }, + { url = "https://files.pythonhosted.org/packages/2c/bd/4a5f68381939219abfe1c22a9e3a5854a4f6f6f3c4983a87d255f21f2e5d/xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7", size = 210440, upload-time = "2025-10-02T14:35:06.239Z" }, + { url = "https://files.pythonhosted.org/packages/eb/37/b80fe3d5cfb9faff01a02121a0f4d565eb7237e9e5fc66e73017e74dcd36/xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db", size = 197990, upload-time = "2025-10-02T14:35:07.735Z" }, + { url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689, upload-time = "2025-10-02T14:35:09.438Z" }, + { url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068, upload-time = "2025-10-02T14:35:11.162Z" }, + { url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495, upload-time = "2025-10-02T14:35:12.971Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620, upload-time = "2025-10-02T14:35:14.129Z" }, + { url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542, upload-time = "2025-10-02T14:35:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880, upload-time = "2025-10-02T14:35:16.315Z" }, + { url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956, upload-time = "2025-10-02T14:35:17.413Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072, upload-time = "2025-10-02T14:35:18.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409, upload-time = "2025-10-02T14:35:20.31Z" }, + { url = "https://files.pythonhosted.org/packages/e3/8e/c6d158d12a79bbd0b878f8355432075fc82759e356ab5a111463422a239b/xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f", size = 215736, upload-time = "2025-10-02T14:35:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/bc/68/c4c80614716345d55071a396cf03d06e34b5f4917a467faf43083c995155/xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5", size = 214833, upload-time = "2025-10-02T14:35:23.32Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e9/ae27c8ffec8b953efa84c7c4a6c6802c263d587b9fc0d6e7cea64e08c3af/xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1", size = 448348, upload-time = "2025-10-02T14:35:25.111Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6b/33e21afb1b5b3f46b74b6bd1913639066af218d704cc0941404ca717fc57/xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee", size = 196070, upload-time = "2025-10-02T14:35:26.586Z" }, + { url = "https://files.pythonhosted.org/packages/96/b6/fcabd337bc5fa624e7203aa0fa7d0c49eed22f72e93229431752bddc83d9/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd", size = 212907, upload-time = "2025-10-02T14:35:28.087Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d3/9ee6160e644d660fcf176c5825e61411c7f62648728f69c79ba237250143/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729", size = 200839, upload-time = "2025-10-02T14:35:29.857Z" }, + { url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304, upload-time = "2025-10-02T14:35:31.222Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930, upload-time = "2025-10-02T14:35:32.517Z" }, + { url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787, upload-time = "2025-10-02T14:35:33.827Z" }, + { url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916, upload-time = "2025-10-02T14:35:35.107Z" }, + { url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799, upload-time = "2025-10-02T14:35:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044, upload-time = "2025-10-02T14:35:37.195Z" }, + { url = "https://files.pythonhosted.org/packages/7e/5e/0138bc4484ea9b897864d59fce9be9086030825bc778b76cb5a33a906d37/xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e", size = 32754, upload-time = "2025-10-02T14:35:38.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/d7/5dac2eb2ec75fd771957a13e5dda560efb2176d5203f39502a5fc571f899/xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405", size = 30846, upload-time = "2025-10-02T14:35:39.6Z" }, + { url = "https://files.pythonhosted.org/packages/fe/71/8bc5be2bb00deb5682e92e8da955ebe5fa982da13a69da5a40a4c8db12fb/xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3", size = 194343, upload-time = "2025-10-02T14:35:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/52badfb2aecec2c377ddf1ae75f55db3ba2d321c5e164f14461c90837ef3/xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6", size = 213074, upload-time = "2025-10-02T14:35:42.29Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2b/ae46b4e9b92e537fa30d03dbc19cdae57ed407e9c26d163895e968e3de85/xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063", size = 212388, upload-time = "2025-10-02T14:35:43.929Z" }, + { url = "https://files.pythonhosted.org/packages/f5/80/49f88d3afc724b4ac7fbd664c8452d6db51b49915be48c6982659e0e7942/xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7", size = 445614, upload-time = "2025-10-02T14:35:45.216Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ba/603ce3961e339413543d8cd44f21f2c80e2a7c5cfe692a7b1f2cccf58f3c/xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b", size = 194024, upload-time = "2025-10-02T14:35:46.959Z" }, + { url = "https://files.pythonhosted.org/packages/78/d1/8e225ff7113bf81545cfdcd79eef124a7b7064a0bba53605ff39590b95c2/xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd", size = 210541, upload-time = "2025-10-02T14:35:48.301Z" }, + { url = "https://files.pythonhosted.org/packages/6f/58/0f89d149f0bad89def1a8dd38feb50ccdeb643d9797ec84707091d4cb494/xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0", size = 198305, upload-time = "2025-10-02T14:35:49.584Z" }, + { url = "https://files.pythonhosted.org/packages/11/38/5eab81580703c4df93feb5f32ff8fa7fe1e2c51c1f183ee4e48d4bb9d3d7/xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152", size = 210848, upload-time = "2025-10-02T14:35:50.877Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6b/953dc4b05c3ce678abca756416e4c130d2382f877a9c30a20d08ee6a77c0/xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11", size = 414142, upload-time = "2025-10-02T14:35:52.15Z" }, + { url = "https://files.pythonhosted.org/packages/08/a9/238ec0d4e81a10eb5026d4a6972677cbc898ba6c8b9dbaec12ae001b1b35/xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5", size = 191547, upload-time = "2025-10-02T14:35:53.547Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ee/3cf8589e06c2164ac77c3bf0aa127012801128f1feebf2a079272da5737c/xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f", size = 31214, upload-time = "2025-10-02T14:35:54.746Z" }, + { url = "https://files.pythonhosted.org/packages/02/5d/a19552fbc6ad4cb54ff953c3908bbc095f4a921bc569433d791f755186f1/xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad", size = 32290, upload-time = "2025-10-02T14:35:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/b1/11/dafa0643bc30442c887b55baf8e73353a344ee89c1901b5a5c54a6c17d39/xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679", size = 28795, upload-time = "2025-10-02T14:35:57.162Z" }, + { url = "https://files.pythonhosted.org/packages/2c/db/0e99732ed7f64182aef4a6fb145e1a295558deec2a746265dcdec12d191e/xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4", size = 32955, upload-time = "2025-10-02T14:35:58.267Z" }, + { url = "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67", size = 31072, upload-time = "2025-10-02T14:35:59.382Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d9/72a29cddc7250e8a5819dad5d466facb5dc4c802ce120645630149127e73/xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad", size = 196579, upload-time = "2025-10-02T14:36:00.838Z" }, + { url = "https://files.pythonhosted.org/packages/63/93/b21590e1e381040e2ca305a884d89e1c345b347404f7780f07f2cdd47ef4/xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b", size = 215854, upload-time = "2025-10-02T14:36:02.207Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b8/edab8a7d4fa14e924b29be877d54155dcbd8b80be85ea00d2be3413a9ed4/xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b", size = 214965, upload-time = "2025-10-02T14:36:03.507Z" }, + { url = "https://files.pythonhosted.org/packages/27/67/dfa980ac7f0d509d54ea0d5a486d2bb4b80c3f1bb22b66e6a05d3efaf6c0/xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca", size = 448484, upload-time = "2025-10-02T14:36:04.828Z" }, + { url = "https://files.pythonhosted.org/packages/8c/63/8ffc2cc97e811c0ca5d00ab36604b3ea6f4254f20b7bc658ca825ce6c954/xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a", size = 196162, upload-time = "2025-10-02T14:36:06.182Z" }, + { url = "https://files.pythonhosted.org/packages/4b/77/07f0e7a3edd11a6097e990f6e5b815b6592459cb16dae990d967693e6ea9/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99", size = 213007, upload-time = "2025-10-02T14:36:07.733Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d8/bc5fa0d152837117eb0bef6f83f956c509332ce133c91c63ce07ee7c4873/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3", size = 200956, upload-time = "2025-10-02T14:36:09.106Z" }, + { url = "https://files.pythonhosted.org/packages/26/a5/d749334130de9411783873e9b98ecc46688dad5db64ca6e04b02acc8b473/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6", size = 213401, upload-time = "2025-10-02T14:36:10.585Z" }, + { url = "https://files.pythonhosted.org/packages/89/72/abed959c956a4bfc72b58c0384bb7940663c678127538634d896b1195c10/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93", size = 417083, upload-time = "2025-10-02T14:36:12.276Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b3/62fd2b586283b7d7d665fb98e266decadf31f058f1cf6c478741f68af0cb/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518", size = 193913, upload-time = "2025-10-02T14:36:14.025Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/c19c42c5b3f5a4aad748a6d5b4f23df3bed7ee5445accc65a0fb3ff03953/xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119", size = 31586, upload-time = "2025-10-02T14:36:15.603Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/4cc450345be9924fd5dc8c590ceda1db5b43a0a889587b0ae81a95511360/xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f", size = 32526, upload-time = "2025-10-02T14:36:16.708Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898, upload-time = "2025-10-02T14:36:17.843Z" }, +] + +[[package]] +name = "yourdfpy" +version = "0.0.60" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "numpy" }, + { name = "six" }, + { name = "trimesh", extra = ["easy"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/19/20c50861f30aff7720f9a601f386d73760c2df9961de1f98d0dbf3b85e69/yourdfpy-0.0.60.tar.gz", hash = "sha256:2af2d8bdeea1b85b642590a3b4236fdb35746d7b3e38ce460a169c18d9c4f868", size = 538238, upload-time = "2026-01-23T07:32:47.856Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/60/4ea0d6df0b497d51bf2ef87eaab0eb26f8bc3b3313c012da5df3383cced9/yourdfpy-0.0.60-py3-none-any.whl", hash = "sha256:8a187a8b18c98db87c76e9a950581b3c875b761e00df83942526c17ea693166c", size = 22194, upload-time = "2026-01-23T07:32:46.481Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965, upload-time = "2026-04-13T23:21:46.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" }, +] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, + { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, + { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, + { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, + { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, +] diff --git a/experiments/bimanual_sim/viser_render.py b/experiments/bimanual_sim/viser_render.py new file mode 100644 index 0000000..0613d8a --- /dev/null +++ b/experiments/bimanual_sim/viser_render.py @@ -0,0 +1,213 @@ +"""Bridge MuJoCo geometry to Viser scene handles. + +`build_viser_scene` walks every geom in the compiled model once, creates a +matching Viser mesh handle at its current world pose, and returns handle +pairs for the subset that can *move* in later frames. `update_viser` then +pushes the current world pose of each moving geom — this is what animates +the browser-side render. + +Static-vs-moving classification uses `model.body_weldid`: a body whose weld +root is the worldbody (id 0) is rigidly welded into the world frame and will +never change pose, so we skip it in the per-frame update and save the +websocket write plus the quaternion math. In the `data_center` scene this +drops ~⅔ of the per-frame update volume (chassis, rack, tower, pedestal +walls, bin walls, cable anchors are all static). + +Matrix → quaternion conversion uses `mujoco.mju_mat2Quat` (C kernel) rather +than `viser.transforms.SO3.from_matrix`. The latter runs multiple +`np.allclose` scans per call to pick between four quaternion branches and, +with ~60 geoms × 125 Hz, dominated the runtime profile at ~45 % of wallclock. + +Per-frame pose push bypasses viser's `MeshHandle.position` / `.wxyz` +property setters. The property setters each run `np.allclose(new, current)` +to suppress redundant websocket messages — useful for user-driven GUI +updates, expensive when we *know* every moving geom changed pose this +frame. We poke `handle._impl.{position,wxyz}[:]` in-place and enqueue the +`SetPosition` / `SetOrientation` messages ourselves, skipping the diff. +All enqueued messages are then sent as a single atomic websocket frame via +`server.atomic()`. + +Shapes supported: BOX, SPHERE, CYLINDER, CAPSULE, ELLIPSOID, MESH. Planes +are skipped (we add a Viser grid instead since a flat plane looks awkward). +""" + +from __future__ import annotations + +import mujoco +import numpy as np +import trimesh +import viser +from viser._messages import SetOrientationMessage, SetPositionMessage + + +def _geom_to_trimesh(model: mujoco.MjModel, geom_id: int) -> trimesh.Trimesh | None: + gtype = int(model.geom_type[geom_id]) + size = model.geom_size[geom_id] + + match gtype: + case mujoco.mjtGeom.mjGEOM_BOX: + return trimesh.creation.box(extents=2 * size) + case mujoco.mjtGeom.mjGEOM_SPHERE: + return trimesh.creation.icosphere(radius=float(size[0])) + case mujoco.mjtGeom.mjGEOM_CYLINDER: + return trimesh.creation.cylinder(radius=float(size[0]), height=2.0 * float(size[1])) + case mujoco.mjtGeom.mjGEOM_CAPSULE: + return trimesh.creation.capsule(radius=float(size[0]), height=2.0 * float(size[1])) + case mujoco.mjtGeom.mjGEOM_ELLIPSOID: + m = trimesh.creation.icosphere(radius=1.0, subdivisions=3) + m.apply_scale(size) + return m + case mujoco.mjtGeom.mjGEOM_MESH: + mid = int(model.geom_dataid[geom_id]) + vs = int(model.mesh_vertadr[mid]) + vn = int(model.mesh_vertnum[mid]) + fs = int(model.mesh_faceadr[mid]) + fn = int(model.mesh_facenum[mid]) + verts = np.asarray(model.mesh_vert[vs : vs + vn], dtype=np.float32).copy() + faces = np.asarray(model.mesh_face[fs : fs + fn], dtype=np.int32).copy() + return trimesh.Trimesh(vertices=verts, faces=faces, process=False) + return None + + +def _is_static_geom(model: mujoco.MjModel, geom_id: int) -> bool: + """A geom is static iff its body's weld-equivalence root is the worldbody. + + `model.body_weldid[body]` gives the id of the nearest ancestor body with + at least one DoF, or 0 (worldbody) if the body is rigidly welded to the + world frame. Bodies attached via a frame offset but no joint (typical of + pedestals, rack structure, bin walls) inherit weldid == 0 and are + correctly classified as static. + """ + body_id = int(model.geom_bodyid[geom_id]) + return int(model.body_weldid[body_id]) == 0 + + +def build_viser_scene( + server: viser.ViserServer, + model: mujoco.MjModel, + data: mujoco.MjData, + *, + ground_width: float = 3.0, + ground_cell: float = 0.1, +) -> list[tuple[int, viser.MeshHandle]]: + """Publish every geom in `model` to the Viser scene. + + Returns handle pairs for the geoms whose world pose can change across + frames; static geoms are created with their current world pose baked in + and never touched again. The caller must have initialised `data` to the + scene's start state (typically via `scene.apply_initial_state`) before + calling this — the static geom poses read here are frozen forever. + """ + server.scene.set_up_direction("+z") + server.scene.add_grid( + "/ground", + width=ground_width, + height=ground_width, + cell_size=ground_cell, + plane="xy", + ) + + mujoco.mj_forward(model, data) + quat_buf = np.empty(4, dtype=np.float64) + moving_handles: list[tuple[int, viser.MeshHandle]] = [] + + for g in range(model.ngeom): + if int(model.geom_type[g]) == mujoco.mjtGeom.mjGEOM_PLANE: + continue + mesh = _geom_to_trimesh(model, g) + if mesh is None: + continue + rgba = model.geom_rgba[g] + name = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_GEOM, g) or f"g{g}" + safe = name.replace("/", "_") + color: tuple[float, float, float] = (float(rgba[0]), float(rgba[1]), float(rgba[2])) + + pos = data.geom_xpos[g] + # geom_xmat rows are length-9 flat, which is what mju_mat2Quat wants. + mujoco.mju_mat2Quat(quat_buf, data.geom_xmat[g]) + + h = server.scene.add_mesh_simple( + f"/geoms/{g:04d}_{safe}", + vertices=np.asarray(mesh.vertices, dtype=np.float32), + faces=np.asarray(mesh.faces, dtype=np.int32), + color=color, + opacity=float(rgba[3]), + position=(float(pos[0]), float(pos[1]), float(pos[2])), + wxyz=( + float(quat_buf[0]), + float(quat_buf[1]), + float(quat_buf[2]), + float(quat_buf[3]), + ), + ) + if not _is_static_geom(model, g): + moving_handles.append((g, h)) + return moving_handles + + +def update_viser( + server: viser.ViserServer, + model: mujoco.MjModel, + data: mujoco.MjData, + handles: list[tuple[int, viser.MeshHandle]], +) -> None: + """Push world pose of every moving geom to its Viser handle. + + Three layers of batching vs. the naive `h.position = ...` / + `h.wxyz = ...`: + + 1. `server.atomic()` groups every enqueued message into one + client-side atomic update, halving the websocket framing overhead + and guaranteeing the browser sees all geoms move synchronously. + It also suppresses the per-`push` `call_soon_threadsafe` signal — + the atomic-end issues a single cross-thread wake instead of one + per message. + 2. Bypass the per-handle `position` / `wxyz` property setters. The + setters each run `np.allclose(new, current)` to suppress redundant + sends — right default for GUI-driven updates, pure overhead for + physics-driven updates that change every tick (cost ~44 % of + wallclock before this change). We update `_impl.{position,wxyz}[:]` + in-place so viser's cached state stays coherent with the wire. + 3. Bypass `queue_message`: cache the broadcast buffer's `push` once + and call it directly. `queue_message` layers a record-handle loop + (we don't record) and a `get_message_buffer()` call on top of + `buffer.push`; it showed ~14 % inclusive overhead over `push` + alone in profiling. All handles share the same `WebsockServer`, + so the cached `buffer_push` is valid for every geom. + """ + if not handles: + return + + # All handles from the same `ViserServer` share the broadcast buffer. + # `wsi.get_message_buffer()` returns `WebsockServer._broadcast_buffer`; + # `server.atomic()` starts/ends an atomic block on that same buffer. + wsi = handles[0][1]._impl.api._websock_interface # noqa: SLF001 + buffer_push = wsi.get_message_buffer().push + + # Local-bind hot-path symbols. Inside a ~60-iteration loop at 60 Hz this + # turns LOAD_GLOBAL/LOAD_METHOD into LOAD_FAST — measurable on profiled + # workloads, free to apply. + set_pos_msg = SetPositionMessage + set_ori_msg = SetOrientationMessage + mat2quat = mujoco.mju_mat2Quat + geom_xpos = data.geom_xpos + geom_xmat = data.geom_xmat + + quat_buf = np.empty(4, dtype=np.float64) + with server.atomic(): + for g, h in handles: + pos = geom_xpos[g] + mat2quat(quat_buf, geom_xmat[g]) + pos_tuple = (float(pos[0]), float(pos[1]), float(pos[2])) + wxyz_tuple = ( + float(quat_buf[0]), + float(quat_buf[1]), + float(quat_buf[2]), + float(quat_buf[3]), + ) + impl = h._impl # noqa: SLF001 — intentional viser-internal fast path + impl.position[:] = pos_tuple + impl.wxyz[:] = wxyz_tuple + name = impl.name + buffer_push(set_pos_msg(name, pos_tuple)) + buffer_push(set_ori_msg(name, wxyz_tuple)) diff --git a/experiments/bimanual_sim/welds.py b/experiments/bimanual_sim/welds.py new file mode 100644 index 0000000..67a2351 --- /dev/null +++ b/experiments/bimanual_sim/welds.py @@ -0,0 +1,123 @@ +"""Weld-equality "cheat" grasp. + +MuJoCo parallel-jaw grasping with simulated friction is notoriously unreliable +— cubes slip out under lateral acceleration, or require painstaking contact +tuning. Instead, every (arm, cube) pair has a pre-declared `mjEQ_WELD` +equality whose `active` flag we flip on and off. + +At activation we teleport the cube onto the TCP site and freeze the current +relative pose by writing into `model.eq_data[eq_id]`. MuJoCo then holds the +cube rigidly to link6. Releasing is just clearing the active flag. + +Rotation math goes through `viser.transforms.SO3` (already a viser transitive +dep) so we can avoid the verbose `mju_mat2Quat`/`mju_mulQuat` dance. +""" + +from __future__ import annotations + +import mujoco +import numpy as np +import viser.transforms as vtf + +WorldPose = tuple[tuple[float, float, float], tuple[float, float, float, float]] +"""(world_xyz, quat_wxyz) — explicit pose target for `activate_attachment_weld`.""" + + +def activate_grasp_weld( + model: mujoco.MjModel, + data: mujoco.MjData, + eq_id: int, + hand_body_id: int, + cube_body_id: int, + tcp_site_id: int, +) -> None: + """Teleport the cube onto the TCP site, then enable the weld locked at + that pose.""" + jnt_id = int(model.body_jntadr[cube_body_id]) + qpos_start = int(model.jnt_qposadr[jnt_id]) + qvel_start = int(model.jnt_dofadr[jnt_id]) + + tcp_pos = data.site_xpos[tcp_site_id].copy() + tcp_mat = data.site_xmat[tcp_site_id].reshape(3, 3).copy() + tcp_rot = vtf.SO3.from_matrix(tcp_mat) + + # Cube's freejoint qpos layout: [x, y, z, qw, qx, qy, qz]. + data.qpos[qpos_start : qpos_start + 3] = tcp_pos + data.qpos[qpos_start + 3 : qpos_start + 7] = tcp_rot.wxyz + data.qvel[qvel_start : qvel_start + 6] = 0.0 + mujoco.mj_forward(model, data) + + # Relative pose of the cube in the hand (link6) frame. + hand_pos = data.xpos[hand_body_id].copy() + hand_mat = data.xmat[hand_body_id].reshape(3, 3).copy() + hand_rot = vtf.SO3.from_matrix(hand_mat) + cube_pos = data.xpos[cube_body_id].copy() + cube_rot = vtf.SO3.from_matrix(data.xmat[cube_body_id].reshape(3, 3).copy()) + + rel_pos = hand_mat.T @ (cube_pos - hand_pos) + rel_rot = hand_rot.inverse() @ cube_rot + + # mjEQ_WELD data layout: anchor(3), relpose pos(3), relpose quat(4), torquescale(1). + # `eq_data` lives on model (static layout); poking it at runtime is legal. + model.eq_data[eq_id, 0:3] = 0.0 + model.eq_data[eq_id, 3:6] = rel_pos + model.eq_data[eq_id, 6:10] = rel_rot.wxyz + model.eq_data[eq_id, 10] = 1.0 + data.eq_active[eq_id] = 1 + + +def deactivate_grasp_weld(data: mujoco.MjData, eq_id: int) -> None: + data.eq_active[eq_id] = 0 + + +def activate_attachment_weld( + model: mujoco.MjModel, + data: mujoco.MjData, + eq_id: int, + body_a_id: int, + body_b_id: int, + *, + target_world_pose: WorldPose | None = None, +) -> None: + """Activate a body↔body weld equality. + + Two modes: + + * **target_world_pose=None** (legacy): freeze the *current* world pose + of body_b relative to body_a. Suitable when the caller has already + driven both bodies into the desired final pose; the weld then holds + them at that captured snapshot. + + * **target_world_pose=((x, y, z), (qw, qx, qy, qz))**: freeze body_b + at *exactly* this world pose, regardless of where it currently is. + We compute the relpose in body_a's frame so that the weld, once + active, snaps body_b to (and holds it at) the requested world + pose. Use for deterministic placements (server-on-shelf, + new-server-in-rack) where the runtime arm position shouldn't + bleed into the final object pose. + """ + a_pos = data.xpos[body_a_id].copy() + a_mat = data.xmat[body_a_id].reshape(3, 3).copy() + a_rot = vtf.SO3.from_matrix(a_mat) + + if target_world_pose is None: + b_pos = data.xpos[body_b_id].copy() + b_rot = vtf.SO3.from_matrix(data.xmat[body_b_id].reshape(3, 3).copy()) + else: + target_xyz, target_quat_wxyz = target_world_pose + b_pos = np.asarray(target_xyz, dtype=float) + b_rot = vtf.SO3(wxyz=np.asarray(target_quat_wxyz, dtype=float)) + + rel_pos = a_mat.T @ (b_pos - a_pos) + rel_rot = a_rot.inverse() @ b_rot + + model.eq_data[eq_id, 0:3] = 0.0 + model.eq_data[eq_id, 3:6] = rel_pos + model.eq_data[eq_id, 6:10] = rel_rot.wxyz + model.eq_data[eq_id, 10] = 1.0 + data.eq_active[eq_id] = 1 + + +def deactivate_weld(data: mujoco.MjData, eq_id: int) -> None: + """Alias for deactivate_grasp_weld — same action, different semantic name.""" + data.eq_active[eq_id] = 0 From bb12cfd3bd4a968d706730c01c378367edcf2941 Mon Sep 17 00:00:00 2001 From: Kingston Date: Sat, 25 Apr 2026 18:53:10 +0800 Subject: [PATCH 08/17] redo sim --- experiments/bimanual_sim/arm_handles.py | 24 +- experiments/bimanual_sim/pyproject.toml | 10 + experiments/bimanual_sim/robots/piper.py | 80 +- experiments/bimanual_sim/robots/tiago.py | 131 +- experiments/bimanual_sim/runner.py | 40 +- experiments/bimanual_sim/scene_base.py | 4 +- experiments/bimanual_sim/scene_check.py | 187 +- .../bimanual_sim/scenes/data_center.py | 1551 +++++++++-------- .../bimanual_sim/scenes/data_center_layout.py | 170 +- experiments/bimanual_sim/tools/_runtime.py | 4 +- experiments/bimanual_sim/tools/mj.py | 41 +- experiments/bimanual_sim/uv.lock | 196 ++- experiments/bimanual_sim/welds.py | 39 +- 13 files changed, 1524 insertions(+), 953 deletions(-) diff --git a/experiments/bimanual_sim/arm_handles.py b/experiments/bimanual_sim/arm_handles.py index cdef986..464db65 100644 --- a/experiments/bimanual_sim/arm_handles.py +++ b/experiments/bimanual_sim/arm_handles.py @@ -26,10 +26,17 @@ class ArmSide(StrEnum): - """Bimanual arm identity; value is the MuJoCo body-name prefix.""" + """Bimanual arm identity; value is the MuJoCo body-name prefix. - LEFT = "left_" - RIGHT = "right_" + The trailing `/` is dm_control.mjcf's namespace separator: when a piper + sub-MJCF is attached with `model="left"`, every body/joint/actuator + inside it is renamed `left/` (vs MjSpec's `left_` + convention used pre-migration). f-string concatenation (`f"{side}link6"`) + naturally produces the slash-namespaced compiled name. + """ + + LEFT = "left/" + RIGHT = "right/" @dataclass @@ -75,9 +82,18 @@ def get_arm_handles(model: mujoco.MjModel, side: ArmSide, n_cubes: int) -> ArmHa gripper_jnt = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, f"{side}joint7") lo, hi = model.jnt_range[gripper_jnt] + # Weld names use `_` as the side separator (`left_grasp_cube0`) because + # dm_control.mjcf reserves `/` for namespace scoping and rejects it in + # element names. Body / joint / actuator / site names compiled from the + # piper subtree stay slash-namespaced (`left/link6`); only the + # scene-root equality names use the underscore form. weld_ids = np.array( [ - mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_EQUALITY, f"{side}grasp_cube{i}") + mujoco.mj_name2id( + model, + mujoco.mjtObj.mjOBJ_EQUALITY, + f"{side.replace('/', '_')}grasp_cube{i}", + ) for i in range(n_cubes) ], dtype=np.int64, diff --git a/experiments/bimanual_sim/pyproject.toml b/experiments/bimanual_sim/pyproject.toml index 92cfe98..8fe7dc0 100644 --- a/experiments/bimanual_sim/pyproject.toml +++ b/experiments/bimanual_sim/pyproject.toml @@ -3,6 +3,11 @@ name = "sim" version = "0.1.0" requires-python = ">=3.12" dependencies = [ + # dm_control.mjcf is the assembly layer for nested robot/cable/camera + # subtrees. Native MuJoCo APIs (mj_step / MjModel / MjData) still drive + # runtime; mjcf is only used inside scenes/.build_spec to compose + # XML before compilation. + "dm-control>=1.0.20", "imageio[ffmpeg]>=2.37.3", "jaxtyping>=0.3.9", "mink>=1.1.0", @@ -48,3 +53,8 @@ ignore = [ # the tradeoff: runtime will surface real typos in dataclass field access # quickly during dev. unresolved-attribute = "ignore" +# dm_control depends on labmaze, which doesn't build on macOS (Bazel toolchain +# requirement; Linux EC2 installs cleanly). We use only `dm_control.mjcf` — +# the mujoco scene-assembly layer — at runtime on the EC2 box. Locally, ty +# can't resolve the import; ignore so static checks pass on either machine. +unresolved-import = "ignore" diff --git a/experiments/bimanual_sim/robots/piper.py b/experiments/bimanual_sim/robots/piper.py index c7dce4e..59b2479 100644 --- a/experiments/bimanual_sim/robots/piper.py +++ b/experiments/bimanual_sim/robots/piper.py @@ -1,10 +1,10 @@ """Agilex Piper loader — Menagerie-adapter for the bimanual arm attachments. Menagerie's `agilex_piper/piper.xml` stays untouched on disk; our scene -calls `attach_piper(scene_spec, prefix=..., frame=..., config=...)` which -reads upstream, attaches with the requested prefix, then overrides a -fixed set of actuator parameters via native `MjsActuator` properties -(`gainprm`, `biasprm`, `forcerange`). +calls `attach_piper(mount_site, side, ...)` which reads upstream, sets +the namespace prefix from `side` (e.g. `left`), overrides a fixed set of +actuator parameters via dm_control.mjcf attribute set, then attaches the +piper subtree at `mount_site`. Why we override: upstream ships `kp=80 N·m/rad kv=5 forcerange=±100` on joints 1–3, tuned for an empty wrist. Our scene carries a wrist camera @@ -25,8 +25,9 @@ from dataclasses import dataclass, field -import mujoco +from dm_control import mjcf +from arm_handles import ArmSide from paths import PIPER_XML @@ -35,8 +36,9 @@ class PiperJointGain: """kp / kv / forcerange override for a single Piper position actuator. `joint_suffix` is the name upstream uses for both the joint and the - actuator that drives it (Menagerie's convention). We concatenate the - scene-supplied `prefix` at attach time to resolve the final name. + actuator that drives it (Menagerie's convention). dm_control.mjcf + namespacing prepends the model name (`left/`, `right/`) at attach + time, so this stays unprefixed. """ joint_suffix: str @@ -91,54 +93,60 @@ class PiperConfig: ) -def _assert_menagerie_shape(piper_spec: mujoco.MjSpec, config: PiperConfig) -> None: +def _assert_menagerie_shape(piper: mjcf.RootElement, config: PiperConfig) -> None: """Fail fast if the upstream actuator names we plan to override are missing or the config references one that isn't upstream.""" - upstream_actuators = {a.name for a in piper_spec.actuators if a.name} + upstream_names = {a.name for a in piper.actuator.all_children() if a.name} for required in _PIPER_REQUIRED_ACTUATORS: - if required not in upstream_actuators: + if required not in upstream_names: raise RuntimeError( f"Piper upstream XML missing expected actuator {required!r}. " "Menagerie's agilex_piper/piper.xml may have changed shape — " "update robots/piper.py if the new naming is intentional." ) for gain in config.gains: - if gain.joint_suffix not in upstream_actuators: + if gain.joint_suffix not in upstream_names: raise RuntimeError( f"PiperConfig.gains references actuator {gain.joint_suffix!r} " - f"not in Piper upstream (actuators: {sorted(upstream_actuators)!r})." + f"not in Piper upstream (actuators: {sorted(upstream_names)!r})." ) -def attach_piper( - scene_spec: mujoco.MjSpec, - *, - prefix: str, - frame: mujoco.MjsFrame, +def load_piper( + side: ArmSide, config: PiperConfig = PiperConfig(), # noqa: B008 — frozen dataclass, no shared state -) -> None: - """Attach a prefixed Piper to `scene_spec` at `frame`, then apply - gain/forcerange overrides per `config`. - - `prefix` is prepended to every Piper body/joint/actuator/geom name on - attach (MjSpec convention). The post-attach override uses the native - `spec.actuator(name).gainprm = [...]` form — same as writing the - values directly into `` elements, just expressed in Python. - - Mutates `scene_spec` in place; no return value. +) -> mjcf.RootElement: + """Load Menagerie's Piper, set the dm_control namespace prefix from + `side`, and apply the gain/forcerange overrides per `config`. + + Returns the `mjcf.RootElement` *without* attaching it to a parent — + the caller is expected to mutate the subtree (e.g. add a TCP site or + a wrist camera on `link6`) BEFORE calling `parent_site.attach(piper)`, + so any element added inside the subtree picks up the namespace + prefix automatically. + + `side`'s value is the dm_control namespace prefix (`left/`, `right/`); + the trailing `/` is stripped before assigning to `piper.model`. Once + attached, every body / joint / actuator / site / camera in the piper + subtree is renamed `/` (e.g. `left/link6`, `left/joint1`). """ - piper_spec = mujoco.MjSpec.from_file(str(PIPER_XML)) - _assert_menagerie_shape(piper_spec, config) - scene_spec.attach(piper_spec, prefix=prefix, frame=frame) + piper = mjcf.from_path(str(PIPER_XML)) + piper.model = side.rstrip("/") + _assert_menagerie_shape(piper, config) for g in config.gains: - name = f"{prefix}{g.joint_suffix}" - act = scene_spec.actuator(name) + act = piper.find("actuator", g.joint_suffix) if act is None: raise RuntimeError( - f"attach_piper: actuator {name!r} not found on scene spec after " - f"attach. Check prefix={prefix!r} / PiperConfig.gains." + f"load_piper: actuator {g.joint_suffix!r} not found in piper " + f"after assert pass — internal inconsistency." ) - act.gainprm = [g.kp, 0, 0, 0, 0, 0, 0, 0, 0, 0] - act.biasprm = [0.0, -g.kp, -g.kv, 0, 0, 0, 0, 0, 0, 0] + # piper.xml ships high-level `` actuators. + # mjcf maps these to the same `kp` / `kv` attributes; setting them + # produces the same compiled gainprm/biasprm/biastype tuple as + # writing the lower-level values directly. + act.kp = g.kp + act.kv = g.kv act.forcerange = list(g.forcerange) + + return piper diff --git a/experiments/bimanual_sim/robots/tiago.py b/experiments/bimanual_sim/robots/tiago.py index 87b8326..b0bb21e 100644 --- a/experiments/bimanual_sim/robots/tiago.py +++ b/experiments/bimanual_sim/robots/tiago.py @@ -1,11 +1,17 @@ """PAL TIAGo loader — Menagerie-adapter for the data-center scene's mobile base. Menagerie's `pal_tiago/tiago.xml` stays untouched on disk; everything our -scene needs to change about it is declared here as a typed -`TiagoConfig` + applied by `load_tiago(config)`. Scenes import only -`load_tiago` and `torso_world_pos_at_zero` from this module — not -`TIAGO_XML` directly — so there's a single place to look when Menagerie -updates and something needs to adjust. +scene needs to change about it is declared here as a typed `TiagoConfig` ++ applied by `load_tiago(config)`. Scenes import only `load_tiago` and +`torso_world_pos_at_zero` from this module — not `TIAGO_XML` directly — +so there's a single place to look when Menagerie updates and something +needs to adjust. + +Built atop `dm_control.mjcf`: `load_tiago` returns an `mjcf.RootElement` +which the scene then composes (Pipers, cameras, rack, cart, cables) by +attaching at sites. Compilation happens once, at the scene's +`build_scene()`, via `MjModel.from_xml_string(root.to_xml_string(), +root.get_assets())`. Customizations we currently apply: @@ -34,7 +40,7 @@ from collections.abc import Iterator from dataclasses import dataclass -import mujoco +from dm_control import mjcf from paths import TIAGO_XML @@ -62,86 +68,101 @@ class TiagoConfig: ) -def _iter_body_subtree(root: mujoco.MjsBody) -> Iterator[mujoco.MjsBody]: +def _iter_body_subtree(root: mjcf.Element) -> Iterator[mjcf.Element]: """Pre-order traversal yielding `root` and every descendant body.""" yield root - for child in root.bodies: - yield from _iter_body_subtree(child) + for child in root.all_children(): + if child.tag == "body": + yield from _iter_body_subtree(child) -def _collect_dead_body_names(spec: mujoco.MjSpec, subtree_roots: tuple[str, ...]) -> set[str]: +def _collect_dead_body_names(root: mjcf.RootElement, subtree_roots: tuple[str, ...]) -> set[str]: """Names of every body inside each named subtree, collected BEFORE - `spec.delete(...)` runs so downstream orphan-reference pruning - (contact excludes) knows which names just disappeared.""" + `body.remove()` runs so downstream orphan-reference pruning (contact + excludes) knows which names just disappeared.""" dead: set[str] = set() for root_name in subtree_roots: - root = spec.body(root_name) - for b in _iter_body_subtree(root): + body = root.find("body", root_name) + if body is None: + continue + for b in _iter_body_subtree(body): if b.name: dead.add(b.name) return dead -def _assert_menagerie_shape(spec: mujoco.MjSpec, config: TiagoConfig) -> None: +def _assert_menagerie_shape(root: mjcf.RootElement, config: TiagoConfig) -> None: """Fail at load time if the upstream names we plan to touch are missing. Cheaper to triage here than as a mysterious MuJoCo compile - error 400 lines into `build_spec`. - """ - # Everything the scene downstream assumes exists: + error 400 lines into `build_scene`.""" for name in _TIAGO_REQUIRED_BODIES: - if spec.body(name) is None: + if root.find("body", name) is None: raise RuntimeError( f"TIAGo upstream XML missing expected body {name!r}. " "Menagerie's pal_tiago/tiago.xml may have changed shape — " "update robots/tiago.py if the new naming is intentional." ) - # Each strip target must actually be there: for subtree in config.strip_subtrees: - if spec.body(subtree) is None: + if root.find("body", subtree) is None: raise RuntimeError( f"TiagoConfig.strip_subtrees references {subtree!r} but it's " "not in TIAGo's upstream XML." ) - # Freejoint to remove must exist where we expect it: - if config.remove_freejoint_name is not None: - base = spec.body("base_link") - names = {j.name for j in base.joints if j.name} - if config.remove_freejoint_name not in names: - raise RuntimeError( - f"TiagoConfig.remove_freejoint_name={config.remove_freejoint_name!r} " - f"but base_link's joints are {sorted(names)!r}." - ) + if ( + config.remove_freejoint_name is not None + and root.find("joint", config.remove_freejoint_name) is None + ): + raise RuntimeError( + f"TiagoConfig.remove_freejoint_name={config.remove_freejoint_name!r} " + "but no such joint in TIAGo's upstream XML." + ) + +def load_tiago(config: TiagoConfig = TiagoConfig()) -> mjcf.RootElement: # noqa: B008 — frozen dataclass, no shared state + """Return a customized, uncompiled TIAGo `mjcf.RootElement` per `config`. -def load_tiago(config: TiagoConfig = TiagoConfig()) -> mujoco.MjSpec: # noqa: B008 — frozen dataclass, no shared state - """Return a customized, uncompiled TIAGo MjSpec per `config`. + Callers typically treat this as the root and compose more children + (Pipers, cameras, rack, cart, cables) before compiling — that's + what `scenes/data_center.py::build_scene` does. - Callers typically treat this as the root spec and add more bodies to - its worldbody before `spec.compile()` — that's what - `scenes/data_center.py::build_spec` does. + The returned root has `.model = ""` (no namespace) so TIAGo's body + names compile unprefixed (`base_link`, `torso_lift_link`); attached + children carry their own namespace prefixes via their `.model` + attribute (e.g. `left/`, `cable1/`). """ - spec = mujoco.MjSpec.from_file(str(TIAGO_XML)) - _assert_menagerie_shape(spec, config) + root = mjcf.from_path(str(TIAGO_XML)) + # TIAGo's upstream MJCF declares ``; clearing the + # model attribute keeps body names like `torso_lift_link` unprefixed in + # the compiled scene (rather than `tiago/torso_lift_link`). + root.model = "" + _assert_menagerie_shape(root, config) + + # Collect dead-body names BEFORE removing subtrees so orphan-exclude + # pruning below can match against the deleted set. + dead_bodies = _collect_dead_body_names(root, config.strip_subtrees) + + # Prune `` entries referencing dead bodies first — body Element + # references would dangle if we removed the bodies first. + if root.contact is not None: + for exc in list(root.contact.all_children()): + if exc.tag != "exclude": + continue + b1 = getattr(exc.body1, "name", None) if exc.body1 is not None else None + b2 = getattr(exc.body2, "name", None) if exc.body2 is not None else None + if b1 in dead_bodies or b2 in dead_bodies: + exc.remove() - dead_bodies = _collect_dead_body_names(spec, config.strip_subtrees) - for root_name in config.strip_subtrees: - spec.delete(spec.body(root_name)) # cascades to descendants + for subtree in config.strip_subtrees: + body = root.find("body", subtree) + if body is not None: + body.remove() if config.remove_freejoint_name is not None: - base = spec.body("base_link") - for j in list(base.joints): - if j.name == config.remove_freejoint_name: - spec.delete(j) - break - - # Prune orphaned `` entries (TIAGo's upstream declares 3, all - # referencing arm/gripper bodies that are now gone). TIAGo has no - # `` or `` block upstream, so nothing else to prune. - for exc in list(spec.excludes): - if exc.bodyname1 in dead_bodies or exc.bodyname2 in dead_bodies: - spec.delete(exc) + joint = root.find("joint", config.remove_freejoint_name) + if joint is not None: + joint.remove() - return spec + return root def torso_world_pos_at_zero() -> tuple[float, float, float]: @@ -155,8 +176,8 @@ def torso_world_pos_at_zero() -> tuple[float, float, float]: In TIAGo's upstream file `torso_lift_link` hangs off `torso_fixed_link` whose own pos is (0, 0, 0), so the local pos is the world pos at qpos=0. """ - spec = mujoco.MjSpec.from_file(str(TIAGO_XML)) - body = spec.body("torso_lift_link") + root = mjcf.from_path(str(TIAGO_XML)) + body = root.find("body", "torso_lift_link") if body is None: raise RuntimeError( "TIAGo upstream XML missing torso_lift_link — " diff --git a/experiments/bimanual_sim/runner.py b/experiments/bimanual_sim/runner.py index 98fde16..e6be65b 100644 --- a/experiments/bimanual_sim/runner.py +++ b/experiments/bimanual_sim/runner.py @@ -44,7 +44,7 @@ from arm_handles import ArmHandles, ArmSide, get_arm_handles from cameras import CameraRole, add_frustum_widgets, update_frustum_widgets from scene_base import Step -from scene_check import AttachmentConstraint, check_scene, print_schematic +from scene_check import AttachmentConstraint, CameraInvariant, check_scene, print_schematic from viser_render import build_viser_scene, update_viser from welds import ( activate_attachment_weld, @@ -78,32 +78,11 @@ def _collect_cube_body_ids(model: mujoco.MjModel, n_cubes: int) -> list[int]: def _extract_attachment_constraints(scene) -> tuple[AttachmentConstraint, ...]: - """Adapt a scene's `ATTACHMENTS` tuple (of `_AttachmentWeldSpec`) into the - scene-agnostic `AttachmentConstraint` shape that `scene_check` consumes. - - Scenes expose their registry via a module-level `ATTACHMENTS` attribute - where each entry has `.name`, `.body_a`, `.body_b`, `.kind` (an enum - with `.value in {"weld", "connect"}`), and `.connect_anchor_in_a`. - Scenes without the registry get an empty tuple — `check_scene` then - skips the attachment validations silently. - """ - raw = getattr(scene, "ATTACHMENTS", ()) - if not raw: - return () - out: list[AttachmentConstraint] = [] - for a in raw: - # `a.kind` may be a StrEnum; its value is "weld" or "connect". - kind_value = getattr(a.kind, "value", a.kind) - out.append( - AttachmentConstraint( - name=str(a.name), - body_a=str(a.body_a), - body_b=str(a.body_b), - kind=kind_value, # type: ignore[arg-type] - connect_anchor_in_a=tuple(a.connect_anchor_in_a), - ) - ) - return tuple(out) + """Read a scene's `ATTACHMENTS` tuple — already a tuple of the + `WeldAttachment | ConnectAttachment` union from `scene_check`. Scenes + without the registry get an empty tuple, and `check_scene` silently + skips the attachment validations.""" + return tuple(getattr(scene, "ATTACHMENTS", ())) def main() -> None: @@ -142,9 +121,7 @@ def main() -> None: scene = _load_scene(args.scene) print(f"Building scene: {getattr(scene, 'NAME', args.scene)}") - spec = scene.build_spec() - model = spec.compile() - data = mujoco.MjData(model) + model, data = scene.build_spec() print( f"compiled: nbody={model.nbody} njnt={model.njnt} nu={model.nu} " f"neq={model.neq} ngeom={model.ngeom}" @@ -189,6 +166,7 @@ def main() -> None: grippable_names: tuple[str, ...] = getattr(scene, "GRIPPABLES", ()) allowed_overlaps: tuple[tuple[str, str], ...] = getattr(scene, "ALLOWED_STATIC_OVERLAPS", ()) attachment_constraints = _extract_attachment_constraints(scene) + camera_invariants: tuple[CameraInvariant, ...] = getattr(scene, "CAMERA_INVARIANTS", ()) if args.inspect: # --inspect: print the schematic, run checks (which may raise), exit @@ -208,6 +186,7 @@ def main() -> None: grippable_names=grippable_names, allowed_static_overlaps=allowed_overlaps, attachment_constraints=attachment_constraints, + camera_invariants=camera_invariants, ) print("\ncheck_scene: OK") return @@ -219,6 +198,7 @@ def main() -> None: grippable_names=grippable_names, allowed_static_overlaps=allowed_overlaps, attachment_constraints=attachment_constraints, + camera_invariants=camera_invariants, ) task_plan: dict[ArmSide, list[Step]] | None = None diff --git a/experiments/bimanual_sim/scene_base.py b/experiments/bimanual_sim/scene_base.py index 2d19b1f..043bb92 100644 --- a/experiments/bimanual_sim/scene_base.py +++ b/experiments/bimanual_sim/scene_base.py @@ -7,8 +7,8 @@ # (empty tuple for scenes with no articulated arm) N_CUBES: int # number of grippable objects (0 if no grasp) - def build_spec() -> mujoco.MjSpec: - '''Construct the uncompiled MJCF spec.''' + def build_spec() -> tuple[mujoco.MjModel, mujoco.MjData]: + '''Construct + compile the scene; return (model, data).''' def apply_initial_state( model: mujoco.MjModel, diff --git a/experiments/bimanual_sim/scene_check.py b/experiments/bimanual_sim/scene_check.py index 4107ffe..3e8f123 100644 --- a/experiments/bimanual_sim/scene_check.py +++ b/experiments/bimanual_sim/scene_check.py @@ -36,6 +36,7 @@ "eq_type_mismatch", "connect_anchor_oob", "cable_short", + "camera_invariant", ] @@ -61,21 +62,88 @@ def __init__(self, violations: list[SceneCheckViolation]) -> None: # ----------------------------------------------------------------------------- -# Attachment-constraint descriptor (scene-agnostic) +# Attachment-constraint descriptors (scene-agnostic, discriminated union) # ----------------------------------------------------------------------------- -# Scenes with mjEQ_WELD / mjEQ_CONNECT equalities can expose a tuple of these -# so `check_scene` can validate each entry (body refs resolve, compiled eq -# type matches, CONNECT anchors lie inside body_a). The scene's own -# `_AttachmentWeldSpec` maps trivially to this shape; the runner adapts. +# `AttachmentConstraint` is the union of `WeldAttachment` and +# `ConnectAttachment`; scenes declare a tuple of these as the single source +# of truth, consumed by `build_spec` (creates the equality), the scene's +# `apply_initial_state` (resets to default), and `check_scene` (validates +# the compiled fact matches the declared kind). Splitting into two variants +# instead of a `kind: Literal["weld","connect"]` field puts the +# "anchor required for connect, absent for weld" rule into the type system — +# `WeldAttachment` simply doesn't carry an anchor, so a misuse is impossible +# to construct. @dataclass(frozen=True) -class AttachmentConstraint: +class WeldAttachment: + """`mjEQ_WELD` — pins the full pose (position + orientation) of body_b + relative to body_a. Activation captures the current relpose at the call + site so the body doesn't snap to body_a's origin.""" + + name: str + body_a: str + body_b: str + initially_active: bool = True + + +@dataclass(frozen=True) +class ConnectAttachment: + """`mjEQ_CONNECT` — pins a point in body_a's local frame to body_b's + origin. No orientation constraint, so body_b can rotate freely about + the anchor (real plug-in-socket behaviour).""" + name: str body_a: str body_b: str - kind: Literal["weld", "connect"] - connect_anchor_in_a: tuple[float, float, float] = (0.0, 0.0, 0.0) + anchor_in_a: tuple[float, float, float] + initially_active: bool = True + + +AttachmentConstraint = WeldAttachment | ConnectAttachment + + +# ----------------------------------------------------------------------------- +# Camera invariants (scene-agnostic, discriminated union) +# ----------------------------------------------------------------------------- +# Scenes pin every declared camera's *intent* — which mode it runs in, what +# body it's attached to, and (for TARGETBODY mode) what it's pointed at — so a +# subsequent edit that flips a camera mode or moves it to the wrong parent +# fails at compile-time-startup rather than as "the wrist view looks weird" +# at viser-time. +# +# Two variants encode the targetbody-required-for-tracking-modes rule at the +# type level: `FixedCameraInvariant` carries no targetbody; the targeting / +# tracking modes are `TargetingCameraInvariant` and require one. Mapping to +# MuJoCo's `mjtCamLight` int constants happens inside `_check_camera_invariants` +# at the boundary so scene declarations don't import mujoco. + +TargetingCameraMode = Literal["targetbody", "targetbodycom", "track", "trackcom"] + + +@dataclass(frozen=True) +class FixedCameraInvariant: + """A `mode="fixed"` camera rigidly attached to `parent_body`. Has no + target — the optical axis is whatever the MJCF `quat` rotates the + camera frame to.""" + + name: str + parent_body: str + + +@dataclass(frozen=True) +class TargetingCameraInvariant: + """A camera that points at (or follows) another body. `mode` selects + between `targetbody`/`targetbodycom` (orient toward body) and + `track`/`trackcom` (also follow body's translation).""" + + name: str + parent_body: str + targetbody: str + mode: TargetingCameraMode = "targetbody" + + +CameraInvariant = FixedCameraInvariant | TargetingCameraInvariant # ----------------------------------------------------------------------------- @@ -259,6 +327,7 @@ def check_scene( grippable_names: tuple[str, ...] = (), allowed_static_overlaps: tuple[tuple[str, str], ...] = (), attachment_constraints: tuple[AttachmentConstraint, ...] = (), + camera_invariants: tuple[CameraInvariant, ...] = (), ) -> None: """Run every invariant; raise `SceneCheckError` if any failed. @@ -273,6 +342,7 @@ def check_scene( violations.extend(_check_tcp_not_in_static_geom(data, arms, ctx)) violations.extend(_check_grippables_reachable(model, data, grippable_names, arms)) violations.extend(_check_attachment_constraints(model, data, attachment_constraints)) + violations.extend(_check_camera_invariants(model, camera_invariants)) if violations: raise SceneCheckError(violations) @@ -380,6 +450,79 @@ def _check_grippables_reachable( return out +_TARGETING_CAMERA_MODE_TO_MJ: dict[TargetingCameraMode, int] = { + "track": int(mujoco.mjtCamLight.mjCAMLIGHT_TRACK), + "trackcom": int(mujoco.mjtCamLight.mjCAMLIGHT_TRACKCOM), + "targetbody": int(mujoco.mjtCamLight.mjCAMLIGHT_TARGETBODY), + "targetbodycom": int(mujoco.mjtCamLight.mjCAMLIGHT_TARGETBODYCOM), +} +_FIXED_CAMERA_MODE_MJ: int = int(mujoco.mjtCamLight.mjCAMLIGHT_FIXED) + + +def _check_camera_invariants( + model: mujoco.MjModel, + invariants: tuple[CameraInvariant, ...], +) -> list[SceneCheckViolation]: + out: list[SceneCheckViolation] = [] + for inv in invariants: + cam_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_CAMERA, inv.name) + if cam_id < 0: + out.append( + SceneCheckViolation( + kind="camera_invariant", + detail=f"camera {inv.name!r} not found in compiled model", + ) + ) + continue + # Parent body — the body the camera is rigidly attached to. + parent_body_id = int(model.cam_bodyid[cam_id]) + actual_parent = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_BODY, parent_body_id) + if actual_parent != inv.parent_body: + out.append( + SceneCheckViolation( + kind="camera_invariant", + detail=( + f"camera {inv.name!r} parent body {actual_parent!r} " + f"!= declared {inv.parent_body!r}" + ), + ) + ) + # Mode + targetbody — branch on the variant. The variant itself + # encodes the "fixed has no target / targeting must have one" rule, + # so there's no need to re-validate that here. + actual_mode = int(model.cam_mode[cam_id]) + if isinstance(inv, FixedCameraInvariant): + expected_mode = _FIXED_CAMERA_MODE_MJ + mode_label = "fixed" + else: + expected_mode = _TARGETING_CAMERA_MODE_TO_MJ[inv.mode] + mode_label = inv.mode + if actual_mode != expected_mode: + out.append( + SceneCheckViolation( + kind="camera_invariant", + detail=( + f"camera {inv.name!r} mode={actual_mode} " + f"!= declared {mode_label!r} (expected {expected_mode})" + ), + ) + ) + if isinstance(inv, TargetingCameraInvariant): + target_id = int(model.cam_targetbodyid[cam_id]) + actual_target = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_BODY, target_id) + if actual_target != inv.targetbody: + out.append( + SceneCheckViolation( + kind="camera_invariant", + detail=( + f"camera {inv.name!r} targetbody={actual_target!r} " + f"!= declared {inv.targetbody!r}" + ), + ) + ) + return out + + def _check_attachment_constraints( model: mujoco.MjModel, data: mujoco.MjData, @@ -412,25 +555,30 @@ def _check_attachment_constraints( detail=f"attachment {c.name!r} body_b={c.body_b!r} not found", ) ) - # Verify compiled equality type matches the declared kind. + # Verify compiled equality type matches the declared variant. compiled_type = int(model.eq_type[eq_id]) - expected = mujoco.mjtEq.mjEQ_WELD if c.kind == "weld" else mujoco.mjtEq.mjEQ_CONNECT - if compiled_type != int(expected): + if isinstance(c, WeldAttachment): + expected = int(mujoco.mjtEq.mjEQ_WELD) + kind_label = "weld" + else: + expected = int(mujoco.mjtEq.mjEQ_CONNECT) + kind_label = "connect" + if compiled_type != expected: out.append( SceneCheckViolation( kind="eq_type_mismatch", detail=( - f"attachment {c.name!r} declared kind={c.kind!r} " + f"attachment {c.name!r} declared kind={kind_label!r} " f"but compiled eq type={compiled_type} " - f"(expected {int(expected)})" + f"(expected {expected})" ), ) ) continue - # For CONNECT entries, the anchor must lie inside body_a's local AABB. - if c.kind == "connect" and body_a_id >= 0: + # CONNECT-only: anchor must lie inside body_a's local AABB. + if isinstance(c, ConnectAttachment) and body_a_id >= 0: mins, maxs = _body_local_aabb(model, body_a_id) - ax = np.asarray(c.connect_anchor_in_a, dtype=float) + ax = np.asarray(c.anchor_in_a, dtype=float) # Use <= / >= rather than strict to accept boundary anchors (ports # sit exactly on the server's front face by design). if not bool(np.all(ax >= mins - 1e-6) and np.all(ax <= maxs + 1e-6)): @@ -522,8 +670,8 @@ def print_schematic( if attachment_constraints: print() - weld_count = sum(1 for c in attachment_constraints if c.kind == "weld") - connect_count = sum(1 for c in attachment_constraints if c.kind == "connect") + weld_count = sum(1 for c in attachment_constraints if isinstance(c, WeldAttachment)) + connect_count = sum(1 for c in attachment_constraints if isinstance(c, ConnectAttachment)) print( f"Attachment constraints ({len(attachment_constraints)}): " f"{connect_count}× CONNECT, {weld_count}× WELD" @@ -531,4 +679,5 @@ def print_schematic( for c in attachment_constraints: eq_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_EQUALITY, c.name) status = "✓" if eq_id >= 0 else "✗ MISSING" - print(f" {c.name:<28} {c.kind.upper():<8} {c.body_a!r} ↔ {c.body_b!r} {status}") + kind_label = "WELD" if isinstance(c, WeldAttachment) else "CONNECT" + print(f" {c.name:<28} {kind_label:<8} {c.body_a!r} ↔ {c.body_b!r} {status}") diff --git a/experiments/bimanual_sim/scenes/data_center.py b/experiments/bimanual_sim/scenes/data_center.py index 111bcee..fb850f2 100644 --- a/experiments/bimanual_sim/scenes/data_center.py +++ b/experiments/bimanual_sim/scenes/data_center.py @@ -43,14 +43,23 @@ import mujoco import numpy as np +from dm_control import mjcf from arm_handles import ArmHandles, ArmSide from cameras import CameraRole from ik import PositionOnly, solve_ik from paths import D405_MESH_STL, D435I_XML -from robots.piper import attach_piper +from robots.piper import load_piper from robots.tiago import load_tiago from scene_base import CubeID, Position3, Step, make_cube_id +from scene_check import ( + AttachmentConstraint, + CameraInvariant, + ConnectAttachment, + FixedCameraInvariant, + TargetingCameraInvariant, + WeldAttachment, +) from scenes.data_center_layout import HOME_ARM_Q, IK_SEED_Q, LAYOUT from welds import activate_attachment_weld @@ -59,9 +68,12 @@ # Grippable objects addressable via Step.weld_activate / weld_deactivate. # Index order matters: the runner uses it as an int index into this list. GRIPPABLES: tuple[str, ...] = ( - "cable1_connector", - "cable2_connector", - "cable3_connector", + # Cable connector bodies live inside the `cable{N}` attachment subtrees + # (each cable is its own mjcf.RootElement attached at a site on the + # rack), so dm_control namespaces them as `cable1/connector` etc. + "cable1/connector", + "cable2/connector", + "cable3/connector", "server", "new_server", ) @@ -82,11 +94,40 @@ class DataCenterAux(StrEnum): AUX_ACTUATOR_NAMES: tuple[str, ...] = tuple(m.value for m in DataCenterAux) CAMERAS: tuple[tuple[str, CameraRole], ...] = ( - # Realsense D435i mounted on top of the torso lift, pointing forward-down. - ("top_d435i_cam", CameraRole.TOP), - # Realsense D405 on each arm's link6, along the gripper axis. - ("left_wrist_d405_cam", CameraRole.WRIST), - ("right_wrist_d405_cam", CameraRole.WRIST), + # Realsense D435i attached at the top of the camera pole. dm_control + # namespaces names of attached subtrees with `/`, so the camera + # the d435i.xml declares as `d435i_cam` compiles as `top/d435i_cam`. + ("top/d435i_cam", CameraRole.TOP), + # Wrist D405 cameras are added directly inside the piper subtree + # (`{side}link6`) before attach so they pick up the piper's namespace + # prefix. Result: `left/wrist_d405_cam`, `right/wrist_d405_cam`. + ("left/wrist_d405_cam", CameraRole.WRIST), + ("right/wrist_d405_cam", CameraRole.WRIST), +) + +# Camera invariants — pinned at startup by `scene_check.check_scene`. Each +# entry locks a camera's structural identity (parent body, MuJoCo cam mode, +# optional targetbody) so a future edit that flips the top cam to FIXED, +# or accidentally re-parents a wrist cam to base_link, fails fast at +# startup with a clear message instead of as "the wrist view looks +# wrong" days later in viser. +CAMERA_INVARIANTS: tuple[CameraInvariant, ...] = ( + # Top camera rides the camera pole's mesh body (`top/d435i`), attached + # to the static `base_link` (not the moving torso_lift_link) so the + # view stays stable regardless of lift qpos. Targets `rack_frame`, so + # the optical axis tracks the rack as the robot's base rotates. + TargetingCameraInvariant( + name="top/d435i_cam", + parent_body="top/d435i", + targetbody="rack_frame", + mode="targetbody", + ), + # Wrist cams mount on each arm's link6 (`left/link6`, `right/link6`) + # in FIXED mode. The 180°-x quat on the camera makes its optical + # axis (-z) align with link6's +z (the gripper axis), so each wrist + # view always looks at whatever the arm is reaching for. + FixedCameraInvariant(name="left/wrist_d405_cam", parent_body="left/link6"), + FixedCameraInvariant(name="right/wrist_d405_cam", parent_body="right/link6"), ) @@ -123,38 +164,37 @@ class DataCenterAux(StrEnum): ("rack_side_R", "rack_top"), ("rack_side_R", "rack_bottom"), ("rack_top", "rack_bottom"), - # Rack shelves span the whole interior between the surrounding panels. + # Shelf is a thin plate spanning the full rack interior; touches the + # surrounding panels at every edge. Patch panel sits on top of it. ("rack_rear", "rack_shelf"), ("rack_side_L", "rack_shelf"), ("rack_side_R", "rack_shelf"), - ("rack_rear", "rack_lower_shelf"), - ("rack_side_L", "rack_lower_shelf"), - ("rack_side_R", "rack_lower_shelf"), - # Cable bracket is a decorative bar bolted flush against the +y side. - ("cable_bracket", "rack_side_R"), - # Each cable anchor body sits inside the bracket (that's how the cable - # emerges from the fixture), and the composite's first-segment body - # (`cable{i}_B_first`) is at the anchor origin — both overlap the - # bracket and each other by design. - ("cable_bracket", "cable1_anchor"), - ("cable_bracket", "cable2_anchor"), - ("cable_bracket", "cable3_anchor"), - ("cable_bracket", "cable1_B_first"), - ("cable_bracket", "cable2_B_first"), - ("cable_bracket", "cable3_B_first"), - ("cable1_anchor", "cable1_B_first"), - ("cable2_anchor", "cable2_B_first"), - ("cable3_anchor", "cable3_B_first"), - # The cable composite's first body also crosses the rack side panel - # (the bracket sits outside +y; the first segment extends back into - # the rack toward the port). - ("rack_side_R", "cable1_B_first"), - ("rack_side_R", "cable2_B_first"), - ("rack_side_R", "cable3_B_first"), + ("rack_shelf", "patch_panel"), + # Shelf top is at server bottom z; cable anchors mount on top of the + # patch panel which sits on top of the shelf — anchor bodies graze + # the shelf top by design. + ("rack_shelf", "cable1/anchor"), + ("rack_shelf", "cable2/anchor"), + ("rack_shelf", "cable3/anchor"), + # Cables emerge from a 1U patch panel mounted on the rack front rails, + # one rack-unit below the server slot. Each cable's anchor body + # touches the patch-panel face where it emerges, and the rod body + # starts co-located with the anchor (rest pose) so the two overlap + # by design. Under dm_control namespacing the cable subtrees compile + # as `cable{N}/anchor`, `cable{N}/rod`, `cable{N}/connector`. + ("patch_panel", "cable1/anchor"), + ("patch_panel", "cable2/anchor"), + ("patch_panel", "cable3/anchor"), + ("patch_panel", "cable1/rod"), + ("patch_panel", "cable2/rod"), + ("patch_panel", "cable3/rod"), + ("cable1/anchor", "cable1/rod"), + ("cable2/anchor", "cable2/rod"), + ("cable3/anchor", "cable3/rod"), # Top camera sits directly on top of the camera pole — the pole-top # and mesh-bottom AABBs touch by design. The camera-mesh body # contains ~9 sub-geoms so a body-pair allow covers the lot. - ("world", "top_d435i"), + ("world", "top/d435i"), # TIAGo's base mesh uses a conservative sphere bound in scene_check # (rbound ≈ 0.30 m at mesh origin z≈0.16), so after pulling the rack # closer to center_x=0.58 the rbound sphere grazes the rack walls @@ -163,7 +203,11 @@ class DataCenterAux(StrEnum): ("world", "rack_side_L"), ("world", "rack_side_R"), ("world", "rack_bottom"), - ("world", "rack_lower_shelf"), + # Same conservative-sphere story for the floor-standing cart at + # cart.center_y=-0.55: its casters and bottom shelf sit close enough + # to the floor that TIAGo's base-mesh sphere bound overlaps them by + # design. + ("world", "cart_frame"), ) @@ -203,107 +247,92 @@ class AttachmentWeldName(StrEnum): PORT2_NEW = "weld_port2_new" PORT3_NEW = "weld_port3_new" SERVER_IN_RACK = "weld_server_in_rack" - SERVER_ON_LOWER_SHELF = "weld_server_on_lower_shelf" - NEW_IN_BIN = "weld_new_in_bin" + SERVER_ON_CART_BOTTOM = "weld_server_on_cart_bottom" + NEW_ON_CART_TOP = "weld_new_on_cart_top" NEW_IN_RACK = "weld_new_in_rack" -class ConstraintKind(StrEnum): - """Which MuJoCo equality type backs an entry in `ATTACHMENTS`.""" - - WELD = "weld" # mjEQ_WELD — pins full pose (position + orientation) - CONNECT = "connect" # mjEQ_CONNECT — pins only a point (like a ball joint) - - -@dataclass(frozen=True) -class _AttachmentWeldSpec: - """One scene body-pair equality. The registry below is the single source - of truth for the name, the two bodies, the initial active flag, and the - constraint kind — consumed by `build_spec` (create the equality), - `_resolve_scene_ids` (collect the eq id), and `apply_initial_state` (reset - to the spec default). - """ - - name: AttachmentWeldName - body_a: str - body_b: str - initially_active: bool - kind: ConstraintKind = ConstraintKind.WELD - # Only meaningful when kind == CONNECT: the anchor point in body_a's - # local frame that body_b's origin should be pinned to. For port - # connects this is the port geom's position on the server body. - connect_anchor_in_a: tuple[float, float, float] = (0.0, 0.0, 0.0) - - -# Port anchor in server's local frame. Server and new_server share identical -# port layouts, so both reuse this tuple when a CONNECT equality is built. -# Delegated to `LAYOUT.port_anchor_in_server_frame` so the port geom position -# and the connect anchor stay in lockstep. -def _port_anchor_in_server_frame(port_idx: int) -> tuple[float, float, float]: - return LAYOUT.port_anchor_in_server_frame(port_idx) - - -ATTACHMENTS: tuple[_AttachmentWeldSpec, ...] = ( +# Single source of truth for body-pair equalities. Entries are +# `WeldAttachment` (full-pose pin) or `ConnectAttachment` (point-pin with an +# anchor in body_a's local frame); the variant itself encodes the constraint +# kind, so build_spec / apply_initial_state pattern-match on `isinstance(...)` +# rather than reading a separate `kind` field. `scene_check.check_scene` +# consumes the same tuple to verify each compiled equality matches its +# declared variant. +ATTACHMENTS: tuple[AttachmentConstraint, ...] = ( # Port connects — body_a is the SERVER (anchor is in server's local # frame); body_b is the cable connector whose origin gets pinned. - _AttachmentWeldSpec( - AttachmentWeldName.PORT1_OLD, - "server", - "cable1_connector", - True, - kind=ConstraintKind.CONNECT, - connect_anchor_in_a=_port_anchor_in_server_frame(0), + # Cable connectors live inside the `cable{N}` attachment subtrees so + # dm_control namespaces them as `cable{N}/connector`. Anchor positions + # come from `LAYOUT.port_anchor_in_server_frame` so the port geom + # position and the connect anchor stay in lockstep. + ConnectAttachment( + name=AttachmentWeldName.PORT1_OLD, + body_a="server", + body_b="cable1/connector", + anchor_in_a=LAYOUT.port_anchor_in_server_frame(0), + initially_active=True, ), - _AttachmentWeldSpec( - AttachmentWeldName.PORT2_OLD, - "server", - "cable2_connector", - True, - kind=ConstraintKind.CONNECT, - connect_anchor_in_a=_port_anchor_in_server_frame(1), + ConnectAttachment( + name=AttachmentWeldName.PORT2_OLD, + body_a="server", + body_b="cable2/connector", + anchor_in_a=LAYOUT.port_anchor_in_server_frame(1), + initially_active=True, ), - _AttachmentWeldSpec( - AttachmentWeldName.PORT3_OLD, - "server", - "cable3_connector", - True, - kind=ConstraintKind.CONNECT, - connect_anchor_in_a=_port_anchor_in_server_frame(2), + ConnectAttachment( + name=AttachmentWeldName.PORT3_OLD, + body_a="server", + body_b="cable3/connector", + anchor_in_a=LAYOUT.port_anchor_in_server_frame(2), + initially_active=True, ), - _AttachmentWeldSpec( - AttachmentWeldName.PORT1_NEW, - "new_server", - "cable1_connector", - False, - kind=ConstraintKind.CONNECT, - connect_anchor_in_a=_port_anchor_in_server_frame(0), + ConnectAttachment( + name=AttachmentWeldName.PORT1_NEW, + body_a="new_server", + body_b="cable1/connector", + anchor_in_a=LAYOUT.port_anchor_in_server_frame(0), + initially_active=False, ), - _AttachmentWeldSpec( - AttachmentWeldName.PORT2_NEW, - "new_server", - "cable2_connector", - False, - kind=ConstraintKind.CONNECT, - connect_anchor_in_a=_port_anchor_in_server_frame(1), + ConnectAttachment( + name=AttachmentWeldName.PORT2_NEW, + body_a="new_server", + body_b="cable2/connector", + anchor_in_a=LAYOUT.port_anchor_in_server_frame(1), + initially_active=False, ), - _AttachmentWeldSpec( - AttachmentWeldName.PORT3_NEW, - "new_server", - "cable3_connector", - False, - kind=ConstraintKind.CONNECT, - connect_anchor_in_a=_port_anchor_in_server_frame(2), + ConnectAttachment( + name=AttachmentWeldName.PORT3_NEW, + body_a="new_server", + body_b="cable3/connector", + anchor_in_a=LAYOUT.port_anchor_in_server_frame(2), + initially_active=False, + ), + # Server placement welds (full pose): + WeldAttachment( + name=AttachmentWeldName.SERVER_IN_RACK, + body_a="server", + body_b="rack_frame", + initially_active=True, + ), + WeldAttachment( + name=AttachmentWeldName.SERVER_ON_CART_BOTTOM, + body_a="server", + body_b="cart_frame", + initially_active=False, + ), + WeldAttachment( + name=AttachmentWeldName.NEW_ON_CART_TOP, + body_a="new_server", + body_b="cart_frame", + initially_active=True, + ), + WeldAttachment( + name=AttachmentWeldName.NEW_IN_RACK, + body_a="new_server", + body_b="rack_frame", + initially_active=False, ), - # Full-pose welds — these need orientation pinning so a stowed server - # keeps its rack-aligned orientation and doesn't roll around. - _AttachmentWeldSpec(AttachmentWeldName.SERVER_IN_RACK, "server", "rack_frame", True), - # Old server gets staged on the rack's lower shelf (not a torso bin) — - # the robot's cart only carries the NEW server at init. Weld to - # rack_frame (static) so the stowed old server stays put when the - # robot lifts. - _AttachmentWeldSpec(AttachmentWeldName.SERVER_ON_LOWER_SHELF, "server", "rack_frame", False), - _AttachmentWeldSpec(AttachmentWeldName.NEW_IN_BIN, "new_server", "torso_lift_link", True), - _AttachmentWeldSpec(AttachmentWeldName.NEW_IN_RACK, "new_server", "rack_frame", False), ) @@ -327,10 +356,11 @@ def grippable_id(name: str) -> CubeID: def grasp_weld(side: ArmSide, cube_id: CubeID) -> str: - """Name of the per-arm grasp weld for a given cube. Matches the - `{prefix}grasp_cube{i}` convention used when the arm handles are built - in `arm_handles.get_arm_handles`.""" - return f"{side}grasp_cube{cube_id}" + """Name of the per-arm grasp weld for a given cube. Uses `_` as the + side separator (`left_grasp_cube0`) because dm_control.mjcf reserves + `/` for namespace scoping. Matches the convention `arm_handles` + uses to look up `weld_ids` after compilation.""" + return f"{side.replace('/', '_')}grasp_cube{cube_id}" # ----------------------------------------------------------------------------- @@ -338,29 +368,109 @@ def grasp_weld(side: ArmSide, cube_id: CubeID) -> str: # ----------------------------------------------------------------------------- -def _static_box(parent, name: str, pos, half, rgba): - body = parent.add_body(name=name, pos=list(pos)) - body.add_geom(type=mujoco.mjtGeom.mjGEOM_BOX, size=list(half), rgba=list(rgba)) - return body +def _add_cart(root: mjcf.RootElement, visual_class: str) -> mjcf.Element: + """Service cart: 4 corner posts, 2 shelves, 4 casters, push handle. + + Returns the cart_frame body so weld equalities can reference it + later. All sub-geoms use the `visual` default class (no contacts, + no mass) — the cart is a visual stand; servers are held in place by + welds, not by sitting on the shelves. + """ + cart_cfg = LAYOUT.cart + cart_rgba = [0.55, 0.58, 0.60, 1.0] + metal_rgba = [0.40, 0.42, 0.44, 1.0] + wheel_rgba = [0.10, 0.10, 0.12, 1.0] + + cart = root.worldbody.add( + "body", + name="cart_frame", + pos=[cart_cfg.center_x, cart_cfg.center_y, 0.0], + ) + + # Corner posts. + post_top_z = cart_cfg.top_shelf_z + 0.01 + post_half_z = post_top_z * 0.5 + for sx in (-1.0, 1.0): + for sy in (-1.0, 1.0): + cart.add( + "geom", + dclass=visual_class, + type="box", + pos=[ + sx * (cart_cfg.half_x - cart_cfg.post_half), + sy * (cart_cfg.half_y - cart_cfg.post_half), + post_half_z, + ], + size=[cart_cfg.post_half, cart_cfg.post_half, post_half_z], + rgba=metal_rgba, + ) + + # Top + bottom shelf plates. + cart.add( + "geom", + dclass=visual_class, + name="cart_top_shelf", + type="box", + pos=[0.0, 0.0, cart_cfg.top_shelf_z], + size=[cart_cfg.half_x, cart_cfg.half_y, cart_cfg.shelf_thickness], + rgba=cart_rgba, + ) + cart.add( + "geom", + dclass=visual_class, + name="cart_bottom_shelf", + type="box", + pos=[0.0, 0.0, cart_cfg.bottom_shelf_z], + size=[cart_cfg.half_x, cart_cfg.half_y, cart_cfg.shelf_thickness], + rgba=cart_rgba, + ) + # Casters at each corner. + for sx in (-1.0, 1.0): + for sy in (-1.0, 1.0): + cart.add( + "geom", + dclass=visual_class, + type="sphere", + pos=[ + sx * (cart_cfg.half_x - cart_cfg.post_half), + sy * (cart_cfg.half_y - cart_cfg.post_half), + cart_cfg.caster_radius, + ], + size=[cart_cfg.caster_radius], + rgba=wheel_rgba, + ) -def _add_bin(parent, name_prefix: str, local_pos) -> None: - """Open shelf — a single floor plate. + # Push handle on the rear edge (the side facing away from the rack — + # cart_y is negative, so the rack is at +y from cart's POV; the handle + # sits at -y so an operator pushing the cart faces the rack). + handle_y = -cart_cfg.half_y - cart_cfg.post_half + handle_z_top = cart_cfg.handle_height + handle_z_mid = (cart_cfg.top_shelf_z + handle_z_top) * 0.5 + handle_post_half_z = (handle_z_top - cart_cfg.top_shelf_z) * 0.5 + for sx in (-1.0, 1.0): + cart.add( + "geom", + dclass=visual_class, + type="box", + pos=[ + sx * (cart_cfg.half_x - cart_cfg.post_half), + handle_y, + handle_z_mid, + ], + size=[cart_cfg.post_half, cart_cfg.post_half, handle_post_half_z], + rgba=metal_rgba, + ) + cart.add( + "geom", + dclass=visual_class, + type="box", + pos=[0.0, handle_y, handle_z_top], + size=[cart_cfg.half_x, cart_cfg.post_half, cart_cfg.post_half], + rgba=metal_rgba, + ) - The bin used to be a U of walls (back + 2 sides + floor) but the back - was visually wider than the torso shell (protruded out the robot's - sides) and the side walls sat in the path of the piper shoulder/elbow - on cable-reach poses — compile-time contacts made the arm physically - unable to track its IK-planned q, so TCP settled ~28 cm above the - planned height. Server stow is a weld, not contact-based, so none of - those walls buy physical correctness; only the floor remains for the - visual of a shelf holding a server. - """ - x, y, z = local_pos - bx, by, bz = LAYOUT.bins.half - wall_t = LAYOUT.bins.wall_thickness - rgba = (0.72, 0.72, 0.75, 1.0) - _static_box(parent, f"{name_prefix}_floor", (x, y, z - bz - wall_t), (bx, by, wall_t), rgba) + return cart def _quat_align_x_to(direction: np.ndarray) -> np.ndarray: @@ -382,26 +492,26 @@ def _quat_align_x_to(direction: np.ndarray) -> np.ndarray: return np.array([float(np.cos(half)), axis[0] * sin_h, axis[1] * sin_h, axis[2] * sin_h]) -def _add_cable(spec: mujoco.MjSpec, rack_body, cable_idx: int, port_y: float, rgba) -> None: - """Build a cable via MuJoCo's native composite + elasticity plugin. - - Previously hand-rolled as a 7-segment ball-jointed capsule chain. The - native `composite type="cable"` with the `mujoco.elasticity.cable` - plugin implements Discrete Elastic Rods — proper bend/twist stiffness - in SI units (Pa) and a maintained first-party plugin rather than hand- - tuned ball damping. `composite type="rope"` was removed in MuJoCo 3.x; - the cable plugin is the canonical replacement. - - The composite tip body inside the sub-spec is renamed to `connector` - before attach so, with prefix `cable{i+1}_`, it resolves as - `cable{i+1}_connector` in the compiled model — matching the name the - grasp welds and `ATTACHMENTS` registry reference. The per-port distinct - connector geom (box / flat rectangle / cylinder) is added to that same - body so it moves with the last segment. - - The attach frame's `quat` rotates the composite's default +x layout to - point from the shared side-mounted bracket toward the port. Per-cable - stagger along rack +x comes from `LAYOUT.cable_anchor_world`. +def _build_cable_root( + cable_idx: int, port_y: float, rgba +) -> tuple[mjcf.RootElement, np.ndarray, np.ndarray]: + """Build a `cable{N}` `mjcf.RootElement` — anchor + single rigid rod + on a ball joint + connector tip. Returns (root, local_anchor_in_rack, + attach_quat) so the caller can attach via a site on the rack with + the right pose. + + Topology: + anchor (rigid box, attached at rack site) + └─ rod (ball joint, capsule along +x, length = anchor↔port) + └─ connector (rigid sphere, no joint, at rod's +x tip) + + Length matches the direct anchor↔port distance so the rest pose lays + the rod straight from the patch-panel anchor to the server port — + visually clean, no curling, no chain segments poking past the server. + A multi-segment chain we tried earlier let the connector follow long + arm pulls but produced a tangled rest shape because of the slack. + The replug task plan now uses a small (~5 cm) pull distance compatible + with the rigid rod's reach. """ cables_cfg = LAYOUT.cables rack = LAYOUT.rack @@ -409,316 +519,349 @@ def _add_cable(spec: mujoco.MjSpec, rack_body, cable_idx: int, port_y: float, rg rack_origin = np.array([rack.center_x, 0.0, rack.center_z]) local_anchor = world_anchor - rack_origin - # Connector tip needs to land at the port geom's world position so the - # port connect equality anchors the connector seated inside the socket. world_port = LAYOUT.port_world_pos(list(LAYOUT.ports.y_offsets).index(port_y)) direction = world_port - world_anchor direction_len = float(np.linalg.norm(direction)) - # MuJoCo's composite cable with count=N creates N-1 actual bodies spanning - # `size * (N-2)/(N-1)` along its +x axis (the first slot is consumed by - # the parent anchor body, and the last body lands one segment short of - # `size`). Empirically, count=11 → 10 bodies → last body at 0.9 × size. - # To make the tip (B_last) land AT the port, inflate `cable_len` by 10/9. - # Without this, the tip sits 4–5 cm short of the port at init and the - # connect equality has to yank the cable on every reset, which makes - # "plugged in" look like a cable straining toward the socket. - count = cables_cfg.n_seg + 1 - composite_span_fraction = (count - 2) / (count - 1) # 9/10 for count=11 - nominal_cable_len = direction_len / composite_span_fraction - cable_len = min(nominal_cable_len, LAYOUT.cable_max_len) + cable_len = min(direction_len, LAYOUT.cable_max_len) attach_quat = _quat_align_x_to(direction) - r, g, b, a = rgba - cable_xml = f""" - - - - - - - - - - - - - - - - - - - """ - - cable_spec = mujoco.MjSpec.from_string(cable_xml) - # Rename the composite tip so it resolves as cable{i+1}_connector after - # the attach prefix; ATTACHMENTS + grasp welds reference that exact name. - tip = cable_spec.body("B_last") - tip.name = "connector" - - # Spherical connector head centred on the tip body's origin. A sphere is - # orientation-invariant, so it sits cleanly inside the port socket geom - # regardless of which direction the cable routes in from — no matter - # whether the tip body's +x ends up pointing along world -y (bracket on - # the side), +x (frontal), or anywhere else, the rendered plug stays - # seated. Previously each cable had a directional box/rectangle/cylinder - # offset along tip +x by CONN_LEN/2, which made the plug appear crooked - # next to its port when the cable direction didn't align with the - # server's +x axis. Port visual distinction is still carried by the - # socket geoms on the server (box / flat box / cylinder) + matching - # colour on the sphere. - tip.add_geom( - type=mujoco.mjtGeom.mjGEOM_SPHERE, + seg_radius = cables_cfg.seg_radius + + cable = mjcf.RootElement(model=f"cable{cable_idx + 1}") + anchor = cable.worldbody.add("body", name="anchor") + anchor.add( + "geom", + type="box", + size=[0.015, 0.010, 0.010], + rgba=[0.3, 0.3, 0.3, 1.0], + contype=0, + conaffinity=0, + ) + rod = anchor.add("body", name="rod", pos=[0.0, 0.0, 0.0]) + rod.add("joint", name="rod_ball", type="ball", damping=20.0) + rod.add( + "geom", + type="capsule", + fromto=[0.0, 0.0, 0.0, cable_len, 0.0, 0.0], + size=[seg_radius], + rgba=list(rgba), + mass=0.05, + contype=0, + conaffinity=0, + ) + connector = rod.add("body", name="connector", pos=[cable_len, 0.0, 0.0]) + # Connector head ~24 mm sphere — reads as the plug ferrule on a + # 14 mm cable, big enough to spot from the 3/4 hero camera. + connector.add( + "geom", + type="sphere", pos=[0.0, 0.0, 0.0], - size=[0.014], + size=[0.012], rgba=list(rgba), mass=0.02, ) + return cable, local_anchor, attach_quat + + +def _add_server_body( + parent: mjcf.Element, + *, + name: str, + pos, + rgba_chassis, + server_cfg, + port_local_x: float, + port_y_offsets, + port_colors, +) -> None: + """Build a server `` (chassis + 3 ports) at `parent`. Both + `server` and `new_server` share this layout.""" + server = parent.add("body", name=name, pos=list(pos)) + server.add("freejoint") + server.add( + "geom", + type="box", + size=list(server_cfg.half), + rgba=rgba_chassis, + mass=server_cfg.mass, + contype=0, + conaffinity=0, + ) + # No "handle rail" geoms on the server front face — earlier we placed + # two small white rectangles at ±15 cm to mark grasp targets, but they + # read as decoration nobody could explain. The arms grip the chassis + # sides directly via weld activation at the same world coords; the + # boxes were visual noise. + shapes = ("box", "box", "cylinder") + for i, (y, rgba, shape) in enumerate(zip(port_y_offsets, port_colors, shapes, strict=True)): + if shape == "box": + size = [0.010, 0.018, 0.018] if i == 0 else [0.010, 0.012, 0.007] + server.add( + "geom", + type="box", + pos=[port_local_x, y, 0.0], + size=size, + rgba=list(rgba), + ) + else: # cylinder — fiber (fromto only; MuJoCo forbids pos+fromto) + server.add( + "geom", + type="cylinder", + fromto=[port_local_x - 0.010, y, 0.0, port_local_x + 0.005, y, 0.0], + size=[0.010], + rgba=list(rgba), + ) - attach_frame = rack_body.add_frame(pos=list(local_anchor), quat=list(attach_quat)) - spec.attach(cable_spec, prefix=f"cable{cable_idx + 1}_", frame=attach_frame) +def build_spec() -> tuple[mujoco.MjModel, mujoco.MjData]: + """Build the data-center scene as a `dm_control.mjcf.RootElement`, + compile it via `mujoco.MjModel.from_xml_string`, and return the + `(model, data)` pair. -def build_spec() -> mujoco.MjSpec: + Returns plain MuJoCo handles (not a `mjcf.Physics`) so downstream + runtime code (runner.advance_arm, tools/_runtime.advance_timeline) + operates against the standard mujoco.MjModel/MjData API. dm_control + is used only as the assembly layer. + """ # Start from PAL TIAGo (wheels + torso_lift_link). `robots.tiago.load_tiago` # strips the upstream single arm + head, removes the `reference` freejoint - # (required for mink IK — see that module's docstring), and prunes - # now-orphan contact excludes. All customizations are declared in its - # `TiagoConfig`; defaults are what the data-center scene needs. - spec = load_tiago() - spec.option.integrator = mujoco.mjtIntegrator.mjINT_IMPLICITFAST - spec.option.cone = mujoco.mjtCone.mjCONE_ELLIPTIC - spec.option.impratio = 10.0 - spec.option.timestep = 0.002 + # (required for mink IK — see that module's docstring), prunes orphan + # contact excludes, and clears the model name so TIAGo's body names + # compile unprefixed. + root = load_tiago() + + # Physics options. mjcf maps these to the `