From 07f27f80e7abe97ba174f27192009d0f72a75bd4 Mon Sep 17 00:00:00 2001 From: David Yuan <47169162+DaivdYuan@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:10:59 -0700 Subject: [PATCH] Release the SpeedTuning simulation reproduction --- .github/workflows/sim-tests.yml | 58 ++ .gitignore | 35 + .python-version | 1 + CHANGELOG.md | 28 + CITATION.cff | 25 + CONTRIBUTING.md | 21 + LICENSE | 22 + MANIFEST.in | 7 + NOTICE.md | 22 + README.md | 246 ++++- act_integration.py | 242 +++++ assets/__init__.py | 1 + assets/bimanual_viperx_ee_insertion.xml | 59 ++ assets/bimanual_viperx_ee_transfer_cube.xml | 48 + .../bimanual_viperx_ee_transfer_tea_bag.xml | 103 ++ assets/bimanual_viperx_insertion.xml | 53 + assets/bimanual_viperx_transfer_cube.xml | 42 + assets/bimanual_viperx_transfer_tea_bag.xml | 97 ++ assets/scene.xml | 38 + assets/tabletop.stl | Bin 0 -> 684 bytes assets/vx300s_10_custom_finger_left.stl | Bin 0 -> 83384 bytes assets/vx300s_10_custom_finger_right.stl | Bin 0 -> 83384 bytes assets/vx300s_1_base.stl | Bin 0 -> 99984 bytes assets/vx300s_2_shoulder.stl | Bin 0 -> 63884 bytes assets/vx300s_3_upper_arm.stl | Bin 0 -> 102984 bytes assets/vx300s_4_upper_forearm.stl | Bin 0 -> 49584 bytes assets/vx300s_5_lower_forearm.stl | Bin 0 -> 99884 bytes assets/vx300s_6_wrist.stl | Bin 0 -> 70784 bytes assets/vx300s_7_gripper.stl | Bin 0 -> 450084 bytes assets/vx300s_8_gripper_prop.stl | Bin 0 -> 31684 bytes assets/vx300s_9_gripper_bar.stl | Bin 0 -> 379484 bytes assets/vx300s_dependencies.xml | 17 + assets/vx300s_left.xml | 59 ++ assets/vx300s_right.xml | 59 ++ benchmarks/README.md | 18 + benchmarks/scripted_results.json | 59 ++ chunked_policy.py | 324 +++++++ configs/README.md | 39 + configs/__init__.py | 1 + configs/ablations/act_encoder.json | 8 + configs/ablations/frame_skip_25.json | 5 + configs/ablations/frame_skip_5.json | 5 + configs/ablations/no_image.json | 11 + configs/ablations/random_resnet.json | 5 + configs/ablations/reward_beta_0.json | 5 + configs/ablations/reward_beta_1.json | 5 + configs/ablations/reward_beta_3.json | 5 + configs/ablations/scripted_policy.json | 5 + configs/paper_sim.json | 49 + configs/scripted_insertion.json | 15 + configs/scripted_pick_and_place.json | 15 + configs/scripted_tea_bag.json | 47 + configs/scripted_tea_bag_randomized.json | 12 + constants.py | 46 + detr/LICENSE | 201 ++++ detr/README.md | 9 + detr/__init__.py | 1 + detr/main.py | 77 ++ detr/models/__init__.py | 9 + detr/models/backbone.py | 127 +++ detr/models/detr_vae.py | 274 ++++++ detr/models/position_encoding.py | 90 ++ detr/models/transformer.py | 311 ++++++ detr/util/__init__.py | 1 + detr/util/misc.py | 468 +++++++++ docs/EXTERNAL_POLICIES.md | 147 +++ docs/SCRIPTED_REPRODUCTION.md | 154 +++ docs/assets/speedtuning_teaser.png | Bin 0 -> 219511 bytes ee_sim_env.py | 235 +++++ experiment_config.py | 73 ++ policy.py | 81 ++ policy_loader.py | 97 ++ policy_speed_env.py | 515 ++++++++++ pyproject.toml | 92 ++ requirements-sim.txt | 6 + rl/__init__.py | 1 + rl/rainbowDQN/__init__.py | 0 rl/rainbowDQN/dqnAgent.py | 289 ++++++ rl/rainbowDQN/network.py | 196 ++++ rl/rainbowDQN/replayBuffer.py | 234 +++++ rl/rainbowDQN/segment_tree.py | 142 +++ scripted_policy.py | 233 +++++ scripts/__init__.py | 1 + scripts/check_chunked_policy.py | 25 + scripts/eval_speed_policy.py | 147 +++ scripts/policy_cli.py | 179 ++++ scripts/rainbow_poc.py | 157 +++ scripts/run_sim.py | 108 +++ scripts/sweep_speed_policy.py | 158 +++ scripts/train_speed_policy.py | 189 ++++ sim_env.py | 221 +++++ sim_tasks.py | 181 ++++ speed_evaluation.py | 93 ++ speed_observation.py | 284 ++++++ speed_policy.py | 322 +++++++ speed_training.py | 298 ++++++ tests/test_chunked_policies.py | 98 ++ tests/test_paper_parity.py | 241 +++++ tests/test_sim_envs.py | 107 ++ tests/test_speed_infrastructure.py | 158 +++ uv.lock | 911 ++++++++++++++++++ 101 files changed, 9900 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/sim-tests.yml create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 CHANGELOG.md create mode 100644 CITATION.cff create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 NOTICE.md create mode 100644 act_integration.py create mode 100644 assets/__init__.py create mode 100644 assets/bimanual_viperx_ee_insertion.xml create mode 100644 assets/bimanual_viperx_ee_transfer_cube.xml create mode 100644 assets/bimanual_viperx_ee_transfer_tea_bag.xml create mode 100644 assets/bimanual_viperx_insertion.xml create mode 100644 assets/bimanual_viperx_transfer_cube.xml create mode 100644 assets/bimanual_viperx_transfer_tea_bag.xml create mode 100644 assets/scene.xml create mode 100644 assets/tabletop.stl create mode 100644 assets/vx300s_10_custom_finger_left.stl create mode 100644 assets/vx300s_10_custom_finger_right.stl create mode 100644 assets/vx300s_1_base.stl create mode 100644 assets/vx300s_2_shoulder.stl create mode 100644 assets/vx300s_3_upper_arm.stl create mode 100644 assets/vx300s_4_upper_forearm.stl create mode 100644 assets/vx300s_5_lower_forearm.stl create mode 100644 assets/vx300s_6_wrist.stl create mode 100644 assets/vx300s_7_gripper.stl create mode 100644 assets/vx300s_8_gripper_prop.stl create mode 100644 assets/vx300s_9_gripper_bar.stl create mode 100644 assets/vx300s_dependencies.xml create mode 100644 assets/vx300s_left.xml create mode 100644 assets/vx300s_right.xml create mode 100644 benchmarks/README.md create mode 100644 benchmarks/scripted_results.json create mode 100644 chunked_policy.py create mode 100644 configs/README.md create mode 100644 configs/__init__.py create mode 100644 configs/ablations/act_encoder.json create mode 100644 configs/ablations/frame_skip_25.json create mode 100644 configs/ablations/frame_skip_5.json create mode 100644 configs/ablations/no_image.json create mode 100644 configs/ablations/random_resnet.json create mode 100644 configs/ablations/reward_beta_0.json create mode 100644 configs/ablations/reward_beta_1.json create mode 100644 configs/ablations/reward_beta_3.json create mode 100644 configs/ablations/scripted_policy.json create mode 100644 configs/paper_sim.json create mode 100644 configs/scripted_insertion.json create mode 100644 configs/scripted_pick_and_place.json create mode 100644 configs/scripted_tea_bag.json create mode 100644 configs/scripted_tea_bag_randomized.json create mode 100644 constants.py create mode 100644 detr/LICENSE create mode 100644 detr/README.md create mode 100644 detr/__init__.py create mode 100644 detr/main.py create mode 100644 detr/models/__init__.py create mode 100644 detr/models/backbone.py create mode 100644 detr/models/detr_vae.py create mode 100644 detr/models/position_encoding.py create mode 100644 detr/models/transformer.py create mode 100644 detr/util/__init__.py create mode 100644 detr/util/misc.py create mode 100644 docs/EXTERNAL_POLICIES.md create mode 100644 docs/SCRIPTED_REPRODUCTION.md create mode 100644 docs/assets/speedtuning_teaser.png create mode 100644 ee_sim_env.py create mode 100644 experiment_config.py create mode 100644 policy.py create mode 100644 policy_loader.py create mode 100644 policy_speed_env.py create mode 100644 pyproject.toml create mode 100644 requirements-sim.txt create mode 100644 rl/__init__.py create mode 100644 rl/rainbowDQN/__init__.py create mode 100644 rl/rainbowDQN/dqnAgent.py create mode 100644 rl/rainbowDQN/network.py create mode 100644 rl/rainbowDQN/replayBuffer.py create mode 100644 rl/rainbowDQN/segment_tree.py create mode 100644 scripted_policy.py create mode 100644 scripts/__init__.py create mode 100644 scripts/check_chunked_policy.py create mode 100644 scripts/eval_speed_policy.py create mode 100644 scripts/policy_cli.py create mode 100644 scripts/rainbow_poc.py create mode 100644 scripts/run_sim.py create mode 100644 scripts/sweep_speed_policy.py create mode 100644 scripts/train_speed_policy.py create mode 100644 sim_env.py create mode 100644 sim_tasks.py create mode 100644 speed_evaluation.py create mode 100644 speed_observation.py create mode 100644 speed_policy.py create mode 100644 speed_training.py create mode 100644 tests/test_chunked_policies.py create mode 100644 tests/test_paper_parity.py create mode 100644 tests/test_sim_envs.py create mode 100644 tests/test_speed_infrastructure.py create mode 100644 uv.lock diff --git a/.github/workflows/sim-tests.yml b/.github/workflows/sim-tests.yml new file mode 100644 index 0000000..6a2f840 --- /dev/null +++ b/.github/workflows/sim-tests.yml @@ -0,0 +1,58 @@ +name: Simulation tests + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + MUJOCO_GL: osmesa + PYOPENGL_PLATFORM: osmesa + steps: + - uses: actions/checkout@v4 + - name: Verify checkpoint-free release contents + run: | + forbidden="$(git ls-files | grep -E '(^|/)(outputs|checkpoints|runs|wandb)/|\.(pt|pth|ckpt|onnx|h5|hdf5|pkl|pickle)$' || true)" + if [ -n "$forbidden" ]; then + echo "Generated model artifacts must not be tracked:" + echo "$forbidden" + exit 1 + fi + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + cache: pip + - name: Install headless MuJoCo rendering libraries + run: | + sudo apt-get update + sudo apt-get install --yes libgl1 libosmesa6 + - name: Install public package and test dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[rl,learned,test]" build twine + - name: Run tests + run: pytest -q + - name: Run supported integration commands + run: | + speedtuning-sim + speedtuning-sim --speed 1.5 + speedtuning-check-chunks + speedtuning-rainbow-poc + speedtuning-eval-speed --task tea_bag --base-policy recorded-chunk --episodes 1 + speedtuning-sweep --task tea_bag --base-policy scripted --speed-start 1.0 --speed-stop 1.1 --episodes-per-speed 1 --output /tmp/sweep.json + - name: Build source and wheel distributions + run: | + python -m build + python -m twine check dist/* + tar -tzf dist/*.tar.gz | grep 'docs/SCRIPTED_REPRODUCTION.md' + tar -tzf dist/*.tar.gz | grep 'benchmarks/scripted_results.json' + - name: Verify the wheel and packaged MuJoCo assets + run: | + python -m venv /tmp/speedtuning-package-check + /tmp/speedtuning-package-check/bin/python -m pip install dist/*.whl + cd /tmp + /tmp/speedtuning-package-check/bin/python -c "from experiment_config import load_experiment_config; assert load_experiment_config('scripted-pick-and-place')[0]['decisions'] == 100000" + /tmp/speedtuning-package-check/bin/speedtuning-sim --task tea_bag diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f39a8db --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Python +__pycache__/ +*.py[cod] +.pytest_cache/ +.coverage +htmlcov/ +.mypy_cache/ +.ruff_cache/ + +# Environments and editors +.venv/ +.venv-legacy/ +.vscode/ +.idea/ +**/.DS_Store + +# Packaging +build/ +dist/ +*.egg-info/ + +# Generated research artifacts +outputs/ +results/ +logs/ +tmp/ +.tmp/ +data/ +data_local/ +checkpoints/ +*.pt +*.pth +*.ckpt +wandb/ +_wandb/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..c8cfe39 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..96b7602 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,28 @@ +# Changelog + +## 0.1.0 + +- Released pick-and-place, insertion, and tea-bag MuJoCo tasks. +- Added parameterized execution speed for retained waypoint policies. +- Added a model-agnostic variable-speed action-chunk interface. +- Added external chunk-policy and speed-policy factory loading. +- Added supported Rainbow DQN speed-policy training, checkpoints, and evaluation. +- Added decision-level speed execution with fresh receding-horizon chunks and + shared frame-skip semantics for training and evaluation. +- Added stacked proprioceptive/visual speed observations with pretrained, random, + and external image-encoder support. +- Added retained ACT checkpoint/backbone adapters, checkpointed preprocessing, + seeded physical-acceleration metrics, fixed-speed sweeps, and plotting. +- Added an archival paper configuration and manifests for every published + simulation ablation. +- Added runnable scripted-policy presets for pick-and-place, insertion, and tea + bag, including the retained reward and Rainbow update schedule. +- Added from-scratch reproduction instructions and machine-readable reference + results for all three simulated tasks. +- Added seeded tea-bag pose randomization as a separately labeled robustness + protocol while preserving the fixed-pose historical environment. +- Added periodic training snapshots, task/protocol metadata validation, and safe + loading for locally generated speed-policy checkpoints. +- Added clean-install packaging, continuous integration, and release tests. +- Removed real-robot, private-path, scratch-output, and trained-checkpoint + artifacts from the public surface. diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000..4f7c297 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,25 @@ +cff-version: 1.2.0 +message: "If you use this software, please cite the SpeedTuning paper." +title: "SpeedTuning simulation and speed-policy infrastructure" +type: software +version: 0.1.0 +authors: + - family-names: Yuan + given-names: David D. +license: MIT +repository-code: "https://github.com/DaivdYuan/SpeedTuning" +preferred-citation: + type: conference-paper + title: "SpeedTuning: Speeding Up Policy Execution with Lightweight Reinforcement Learning" + authors: + - family-names: Yuan + given-names: David D. + - family-names: Zhao + given-names: Tony Z. + - family-names: Burns + given-names: Kaylee + - family-names: Finn + given-names: Chelsea + collection-title: "2025 IEEE International Conference on Robotics and Automation (ICRA)" + year: 2025 + doi: "10.1109/ICRA55743.2025.11128753" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6386907 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,21 @@ +# Contributing + +Bug reports and focused pull requests for the supported simulation surface are +welcome. Before opening a pull request: + +1. Install Python 3.10 and the development extras with + `uv sync --extra rl --extra learned --extra test`. +2. Run `uv run pytest -q`. +3. Run `uv run speedtuning-sim` and `uv run speedtuning-check-chunks` when + changing tasks, policies, interpolation, or physics assets. +4. Run `uv run speedtuning-rainbow-poc` when changing speed-policy learning. +5. Run a two-point `speedtuning-sweep` smoke test when changing metrics, + decision timing, or experiment manifests. + +Please keep real-robot dependencies, private checkpoints, datasets, machine-local +paths, and generated outputs outside this repository. New external policy support +should use the public adapters instead of adding a dependency on another research +repository to the core environment. + +By contributing, you agree that your contribution may be distributed under the +license applicable to the directory you modify. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..686d48e --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2023 Tony Z. Zhao +Copyright (c) 2024-2026 David D. Yuan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..cab13be --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,7 @@ +include CHANGELOG.md +include CITATION.cff +include CONTRIBUTING.md +include NOTICE.md +include requirements-sim.txt +recursive-include benchmarks *.json *.md +recursive-include docs *.md *.png diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..5c5eace --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,22 @@ +# Notices and attribution + +SpeedTuning simulation and speed-policy infrastructure includes code and assets +derived from [Action Chunking with Transformers +(ACT)](https://github.com/tonyzhaozh/act), originally released under the MIT +License. The original Tony Z. Zhao copyright notice is retained in `LICENSE`. + +The files under `detr/` are modified from +[DETR](https://github.com/facebookresearch/detr) and are distributed under the +Apache License 2.0 included at `detr/LICENSE`. + +The ALOHA/ViperX MuJoCo XML files and meshes under `assets/` were inherited from +the MIT-licensed ACT repository history. Task-specific tea-bag environment files +were added in the SpeedTuning development history and are distributed under this +repository's MIT License. + +SpeedTuning simulator recovery, public integration, and release engineering: +Copyright (c) 2024-2026 David D. Yuan. + +The README teaser image is rendered from the SpeedTuning project-page figure, +Copyright (c) the SpeedTuning authors and shared under CC BY-SA 4.0. The source +project page is https://daivdyuan.github.io/speed-tuning/. diff --git a/README.md b/README.md index fa59c36..ec65482 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,245 @@ -# SpeedTuning +
+
+
+
+
+SpeedTuning keeps a base manipulation policy fixed and learns a lightweight +speed policy that accelerates safe phases while preserving precision around +critical interactions. +
+ +> [!NOTE] +> This repository is the simulation reproduction release. It provides complete +> from-scratch speed-policy training with bundled scripted task policies. + +## Overview + +Imitation-learned manipulation policies often inherit the operator's pace and +the hardware constraints present during data collection. Applying one global +interpolation factor can make execution faster, but it cannot distinguish +between transit phases that tolerate aggressive acceleration and contact-rich +phases that require precision. + +SpeedTuning adds a small reinforcement-learning policy on top of a frozen base +policy. At each decision, it selects a speed multiplier from the current robot +and task observation. The base policy continues to predict actions; SpeedTuning +only changes how quickly those actions are executed. + +This release supports the full simulation loop: + +1. run a task with a fixed scripted base policy; +2. train a Rainbow DQN policy over discrete speed multipliers; +3. evaluate success against physical acceleration; +4. compare the adaptive policy with matched fixed-speed baselines. + +## Included tasks + +| Task | Simulator objective | Public preset | +| --- | --- | --- | +| Pick-and-place | Transfer a cube between grippers | `scripted-pick-and-place` | +| Insertion | Insert a peg into a socket | `scripted-insertion` | +| Tea bag | Move a tea bag into a cup | `scripted-tea-bag` | + +An additional `scripted-tea-bag-randomized` preset samples initial tea-bag poses +for distributional evaluation. The retained fixed-pose environment remains +available for historical parity. + +## Installation + +Python 3.10 is required. MuJoCo and DM Control are pinned because contact +dynamics affect the scripted policies. + +Using `uv`: + +```bash +git clone https://github.com/DaivdYuan/SpeedTuning.git +cd SpeedTuning + +uv sync --extra test +uv run speedtuning-sim +``` + +Using `pip`: + +```bash +python3.10 -m venv .venv +source .venv/bin/activate +python -m pip install -e ".[test]" +speedtuning-sim +``` + +On a headless Linux machine, prefix simulator commands with `MUJOCO_GL=egl`. + +## Quick simulation check + +Run all three scripted tasks at nominal speed: + +```bash +uv run speedtuning-sim +``` + +Run one task with a fixed `1.5x` speed multiplier: + +```bash +uv run speedtuning-sim --task insertion --speed 1.5 +``` + +Each command prints a JSON summary and exits nonzero if the task fails. + +## Train a speed policy + +Install the reinforcement-learning extra and run a short CPU smoke test: + +```bash +uv sync --extra rl --extra test + +uv run speedtuning-train-speed \ + --config scripted-tea-bag \ + --task tea_bag \ + --decisions 1000 \ + --checkpoint-interval 0 \ + --output outputs/smoke_test.pt +``` + +For a full 100,000-decision run, use the preset without the smoke-test +overrides: + +```bash +uv run speedtuning-train-speed \ + --config scripted-tea-bag \ + --task tea_bag \ + --output outputs/tea_bag_speed.pt \ + --report outputs/tea_bag_speed.training.json +``` + +Training defaults to CPU. Add `--device cuda` when CUDA is available. Hardware +changes wall-clock time, not the simulation protocol or acceleration metric. + +Every full preset trains a separate task-specific policy. Generated checkpoints +and reports are written under the ignored `outputs/` directory; no pretrained +artifact is required or distributed. + +## Evaluate + +Evaluate the learned speed policy: + +```bash +uv run speedtuning-eval-speed \ + --config scripted-tea-bag \ + --task tea_bag \ + --speed-policy rainbow \ + --speed-checkpoint outputs/tea_bag_speed.pt \ + --episodes 20 +``` + +Measure a fixed-speed frontier: + +```bash +uv run speedtuning-sweep \ + --config scripted-tea-bag \ + --task tea_bag \ + --speed-start 1.0 --speed-stop 3.0 --speed-step 0.25 \ + --episodes-per-speed 20 \ + --output outputs/tea_bag_sweep.json +``` + +Physical acceleration is the nominal task horizon divided by the number of +executed MuJoCo steps. It is not the arithmetic mean of commanded multipliers. + +## Simulation reference results + +One seeded run using the final checkpoint from each 100,000-decision training +run produced: + +| Protocol | Adaptive SpeedTuning | Matched fixed speed | +| --- | --- | --- | +| Pick-and-place | 98% success at 3.856x | 66% at 3.846x | +| Insertion | 97% success at 2.387x | 52% at 2.381x | +| Tea bag, randomized poses | 78% success at 2.077x | 24% at 2.075x | + +These are reference points in the pinned simulator, not exact-decimal +guarantees. Reinforcement learning is stochastic; reruns should be compared by +the success/acceleration tradeoff. + +See the [full reproduction guide](docs/SCRIPTED_REPRODUCTION.md) for reward and +update definitions, all-task commands, pose protocols, and seeded evaluation. +The compact machine-readable record is +[`benchmarks/scripted_results.json`](benchmarks/scripted_results.json). + +## Bring your own task policy + +The speed controller can wrap an external policy that returns action chunks with +shape `[time, 14]`. A `module:factory` adapter makes it possible to train or +evaluate another repository's task policy without modifying this codebase. + +See [External task-policy integration](docs/EXTERNAL_POLICIES.md) for: + +- the Python and CLI interfaces; +- ACT checkpoint and normalization support; +- visual, state, and external speed-policy observations; +- the archival learned-policy configuration and ablations. + +## Commands + +| Command | Purpose | +| --- | --- | +| `speedtuning-sim` | Run scripted simulator tasks at a fixed speed | +| `speedtuning-train-speed` | Train a Rainbow speed policy | +| `speedtuning-eval-speed` | Evaluate fixed, profiled, or learned speed policies | +| `speedtuning-sweep` | Build a success-versus-acceleration curve | +| `speedtuning-check-chunks` | Validate action-chunk integration | +| `speedtuning-rainbow-poc` | Run a small Rainbow optimization check | + +## Scope and limitations + +- This release reproduces the methodology with scripted base policies in + simulation; it does not claim to reproduce the paper's learned-ACT table. +- Real-robot execution is not part of the supported API. +- External task policies remain responsible for their architectures, + preprocessing, normalization statistics, and checkpoint compatibility. +- The physics stack is intentionally pinned for reproducibility. + +## Citation + +If you use this code, please cite: + +```bibtex +@inproceedings{yuan2025speedtuning, + title = {{SpeedTuning}: Speeding Up Policy Execution with Lightweight Reinforcement Learning}, + author = {Yuan, David D. and Zhao, Tony Z. and Burns, Kaylee and Finn, Chelsea}, + booktitle = {2025 IEEE International Conference on Robotics and Automation (ICRA)}, + year = {2025}, + doi = {10.1109/ICRA55743.2025.11128753} +} +``` + +Citation metadata is also available in [`CITATION.cff`](CITATION.cff). + +## License and acknowledgments + +SpeedTuning is released under the MIT License. The ACT-derived DETR code under +`detr/` retains its Apache-2.0 license. Simulator assets and upstream attribution +are documented in [`NOTICE.md`](NOTICE.md). diff --git a/act_integration.py b/act_integration.py new file mode 100644 index 0000000..9ac89c0 --- /dev/null +++ b/act_integration.py @@ -0,0 +1,242 @@ +"""Load retained ACT checkpoints through the public chunk-policy interface.""" + +from __future__ import annotations + +import json +import pickle +from pathlib import Path + +import numpy as np + +from chunked_policy import TorchChunkPredictor + + +REQUIRED_STATS = ("qpos_mean", "qpos_std", "action_mean", "action_std") + + +def _load_mapping(path): + path = Path(path) + if path.suffix == ".npz": + with np.load(path) as values: + return {key: values[key] for key in values.files} + if path.suffix == ".json": + return json.loads(path.read_text()) + if path.suffix in {".pkl", ".pickle"}: + with path.open("rb") as stream: + return pickle.load(stream) + raise ValueError("ACT stats must use .npz, .json, .pkl, or .pickle") + + +def _checkpoint_parts(checkpoint, device): + try: + import torch + except ImportError as exc: + raise RuntimeError("ACT integration requires: uv sync --extra learned") from exc + if checkpoint is None: + raise ValueError("An ACT checkpoint path is required") + # ACT checkpoints may include NumPy normalization arrays and legacy config + # objects. They therefore require pickle loading and must come from a + # trusted source (normally the user's own task-policy training run). + payload = torch.load( + Path(checkpoint), map_location=device, weights_only=False + ) + if not isinstance(payload, dict): + raise ValueError("ACT checkpoint must contain a state dictionary or payload") + for key in ("model_state_dict", "policy_state_dict", "state_dict"): + if key in payload: + return payload, payload[key] + # A raw torch state dictionary maps names to tensors. + if payload and all(isinstance(key, str) for key in payload): + return {}, payload + raise ValueError("ACT checkpoint does not contain recognizable model weights") + + +def _resolve_config(payload, policy_config, camera_names, device): + config = dict(payload.get("policy_config", {})) + config.update(policy_config or {}) + if camera_names is not None: + config["camera_names"] = list(camera_names) + if not config.get("camera_names"): + raise ValueError("ACT policy_config must provide camera_names") + required = ("num_queries", "hidden_dim", "dim_feedforward", "enc_layers", "dec_layers", "nheads") + missing = [key for key in required if key not in config] + if missing: + raise ValueError(f"ACT policy_config is missing: {', '.join(missing)}") + config.setdefault("lr", 1e-4) + config.setdefault("lr_backbone", 0.0) + config.setdefault("kl_weight", 10.0) + config.setdefault("backbone", "resnet18") + config.setdefault("pretrained_backbone", False) + config["device"] = device + return config + + +def _resolve_stats(payload, stats_path): + stats = dict(payload.get("stats", {})) + if stats_path is not None: + stats.update(_load_mapping(stats_path)) + missing = [key for key in REQUIRED_STATS if key not in stats] + if missing: + raise ValueError( + "ACT normalization stats are missing: " + ", ".join(missing) + ) + return {key: np.asarray(stats[key], dtype=np.float32) for key in REQUIRED_STATS} + + +def load_act_policy( + checkpoint, + device="cpu", + stats_path=None, + policy_config=None, + camera_names=None, + strict=True, +): + """Return the ACT module, resolved configuration, and normalization stats.""" + + from policy import ACTPolicy + + payload, state_dict = _checkpoint_parts(checkpoint, device) + config = _resolve_config(payload, policy_config, camera_names, device) + stats = _resolve_stats(payload, stats_path) + model = ACTPolicy(config) + incompatible = model.load_state_dict(state_dict, strict=bool(strict)) + if not strict and (incompatible.missing_keys or incompatible.unexpected_keys): + # Keep the information available to callers without printing during imports. + model.checkpoint_incompatibilities = { + "missing_keys": list(incompatible.missing_keys), + "unexpected_keys": list(incompatible.unexpected_keys), + } + model.eval() + return model, config, stats + + +def build_act_chunk_predictor( + task_name, + checkpoint, + device="cpu", + stats_path=None, + policy_config=None, + camera_names=None, + strict=True, +): + """Factory usable as ``act_integration:build_act_chunk_predictor``.""" + + del task_name + model, config, stats = load_act_policy( + checkpoint=checkpoint, + device=device, + stats_path=stats_path, + policy_config=policy_config, + camera_names=camera_names, + strict=strict, + ) + return TorchChunkPredictor( + model=model, + camera_names=config["camera_names"], + qpos_mean=stats["qpos_mean"], + qpos_std=stats["qpos_std"], + action_mean=stats["action_mean"], + action_std=stats["action_std"], + device=device, + ) + + +class ACTBackboneObservationEncoder: + """Use a supplied ACT task policy's ResNet backbone for speed features.""" + + requires_images = True + + def __init__(self, model, camera_names, include_qvel=True, device="cpu"): + import torch + + self.torch = torch + self.device = torch.device(device) + self.camera_names = tuple(camera_names) + self.include_qvel = bool(include_qvel) + self.backbone = model.model.backbones[0] + self.backbone.to(self.device).eval() + self.feature_dim = int(self.backbone.num_channels) + self.mean = torch.tensor( + [0.485, 0.456, 0.406], dtype=torch.float32, device=self.device + ).view(1, 3, 1, 1) + self.std = torch.tensor( + [0.229, 0.224, 0.225], dtype=torch.float32, device=self.device + ).view(1, 3, 1, 1) + + def reset(self): + return None + + def __call__(self, observation): + torch = self.torch + if "images" not in observation: + raise ValueError("ACT-backbone speed observations require images") + images = np.stack( + [observation["images"][name] for name in self.camera_names] + ).transpose(0, 3, 1, 2) + tensor = torch.as_tensor(images, dtype=torch.float32, device=self.device) / 255.0 + tensor = (tensor - self.mean) / self.std + features = [] + with torch.inference_mode(): + for image in tensor: + backbone_features, _ = self.backbone(image.unsqueeze(0)) + feature_map = backbone_features[-1] + features.append(feature_map.mean(dim=(2, 3)).squeeze(0)) + proprioception = [np.asarray(observation["qpos"], dtype=np.float32)] + if self.include_qvel: + proprioception.append(np.asarray(observation["qvel"], dtype=np.float32)) + return np.concatenate( + proprioception + + [torch.cat(features).detach().cpu().numpy().astype(np.float32)] + ) + + def output_dim(self, env_state_dim): + del env_state_dim + return 14 + (14 if self.include_qvel else 0) + len(self.camera_names) * self.feature_dim + + def spec(self): + return { + "type": "act_backbone", + "camera_names": list(self.camera_names), + "include_qpos": True, + "include_qvel": self.include_qvel, + "include_env_state": False, + "feature_dim": self.feature_dim, + } + + def state_dict(self): + return { + key: value.detach().cpu() + for key, value in self.backbone.state_dict().items() + } + + def load_state_dict(self, state_dict): + self.backbone.load_state_dict(state_dict) + + +def build_act_observation_encoder( + task_name, + checkpoint, + device="cpu", + stats_path=None, + policy_config=None, + camera_names=None, + include_qvel=True, + strict=True, +): + """Factory for the task-policy image-encoder ablation.""" + + del task_name + model, config, _ = load_act_policy( + checkpoint=checkpoint, + device=device, + stats_path=stats_path, + policy_config=policy_config, + camera_names=camera_names, + strict=strict, + ) + return ACTBackboneObservationEncoder( + model, + config["camera_names"], + include_qvel=include_qvel, + device=device, + ) diff --git a/assets/__init__.py b/assets/__init__.py new file mode 100644 index 0000000..9d30322 --- /dev/null +++ b/assets/__init__.py @@ -0,0 +1 @@ +"""Packaged MuJoCo models and meshes for the simulation tasks.""" diff --git a/assets/bimanual_viperx_ee_insertion.xml b/assets/bimanual_viperx_ee_insertion.xml new file mode 100644 index 0000000..700aaac --- /dev/null +++ b/assets/bimanual_viperx_ee_insertion.xml @@ -0,0 +1,59 @@ +e9VfM*l^l zf`q;|LoRjud_M;6ZQw_sOW#?W;HP9&)KAOkxr#;w3H^+vjA%$Pn1~;;(T_lvenM@6 zpLTlExqL*Uf`l$B7juMDjL+r6k3g3$X*R*7_Eg_!Nl^J-vs*- z=+ZTYO>phA;^mCoRQpI&kkED4=_gqz1{1%`S`+L`pi9?$Ho>)M)@+S}sos^SAffBv zrn3Vn1`~8Ya{ChK(lxkEaBJ{QqeRQ7HIS$vq1%#6jRGhJ6NTO-$>U3)OSeQe!L8b| zu0IA-t0qxFLbr*>{<<&icCh;E*2{bebm =C zsV{*p-7~O>&;636)XR{lAffvxe`YyGF_`GJuk12k0$sX?V-wt4dXP?V|4O2Qgzjst zTC<;GFmbd^F797B2z2S5noV%8Z^qBdxSuFdK|=Q#r~kf>Vtno=2Kf@`(mhU_;NI(y zQPq}F?^U9Lgzoz$&vck#Fu`@MFM%%IbG8ZYg)>3DaES^Mx(}cH-f4 )6<>l|XPe;rty^fG1 >LZmKmuL54YuFtkw66r-J;s#40u$iAi=8@_^9mc62?FRUAjg6Ji=lHvEM;Z zL4sF7a13vjEK)zaNT5r%sGrAuAO #x@~sxJPF*>*N+MbURlnxp1Dj>K0mv-FLdc`J#*PhM8-t0 zOaI-!mw^iIS0AB051AwQ9u@BkUHXdHvxBY`g7=A-qFWmvg89?L)l397-Z7~b)c7zuRoHplRe$yfxB$)JJ+ z)%jKo@3>El1iE;eV|d4wEP}_DP(gy~d^?6U;zgrTF%szFZ7YTzBMaku&SPY#AVD>_ z6~j9Y7bAf#-sTve+mB_Nc5)0J>q7+zs==)o-toj333Tx`$MB9>Mso}vvqS|6s`ITF z-f__w33Tx`$MBB5S_F^1qJjj~`Bn_?_->2@x_Fyoc*l?}g2#|iL4sNdD~5NRIz|Fr zyv;GZW8oITW8tVEL9K)p!#my{BY`g7<`~{FeX2tO0(eXx6(p#Yuwr=g1~C%o;%$!M z%}rPY=O$1=f?5eHhByBaBY`g7<`~`_i$!pb1r;QyU9n<#^EfdQ=;CdT;ms9U1m}uS zL4w*9D~2~86(fNz-sTwIoR>v#mJAgns9mvQc=Kv866oS>j^WMSSp;YMP(gxPA}fYB zKNur{F5c!C-W;SwaMltPBxr2Wis8+3#z>%xw>gG4mueB5OGO0<8nd)wc=NR}66oS> zj-f|{zkC%K6<>neL_IG0zX^8fJ=p|hk@Zo9p`v53OP`ZBe;y-&E`3G3IeLrWtUfA8 z=zHUx2M{BHE`4Xca}6wlXD6V7gnq`n^BH0!(50VH@0 5^s>loPfHo-Kk361vQL=a n-Z?B5!E;zpK| zZAbm{|6(n?9;+?k?BY`g65_#uF=~j(rI$5QY6(n?< z=$(HRBY`g6qI&0eSp>D`JY&m>!3q+(ZTHS2i;+N=?iqOJs#yfjRYL^{-AD1xhl`Ow zm+s+s=iFHY&$&Yd3EkK7&g+YjK$q^RdFKx5ULVf{w5}B^Na#MJcYb1w1iEyO(>n)} zd#{l^2ND$|bl=xI&r |D_!8*SZ!+GwwN?zCTZ;-3`kl% xqW8u8UfLnUb zj3UkJ8~E=rWs$!qNa$_9F=Waj`xETavHh~hk*MH)d9?x`mEXujBq~Vo+5;y1#!Dhm zL4sEjFyS}u6Nw5Eye@zV|IsK^kkI$sf7BTXbm`~Ke+*e)E53vJDsX08$Me7Es34)Q zh`p|epIaEyx6Y9Fg)V(Xyg6Ynfr>sS&e39QXQmnzB=lAA=87WqQK5o_zE&}2u&nc7 z1qoht!FSL*BzQ$OAC+H*HxdbS;d_ZQ1}aGK%5sk3%?ax|F%k)M;ro>%MkFdo z;9H-A2tWl1UQ^FUWv|tzL-HOK66nIWS4WIURFJ@TY6lU33KIM^0X{0f+;$`q=)$o9 zXAD%3(A)mm@R$g8>A&mI5AU@KKn3@!kJcWOu`cfC96c&X=&RtLKSu&x`l`p6F|n>9 zD@f=&8)H_+B3MB}KQVqedhc_^1iJJS8e?|JiopsJx)j8i^Rft5kkF;e{#F=XUECMC zbZL$;6KBO>1qoeC_|F``eW6R&N-<^>tr)Bzp=-Vv^PCpJ3KF_T^`C2i`$Ctl!DGzV zS}|BbLbnY5a}97`=+Z4wj9FwW1}jMD7S4a>0PYK2xuF* zew$buXK@~S|M2iTGQXM9n}xN_?`Qh& yH=5?(3Q4a zeAjSKUq31&=vlC7E0>D3DDDeg^z2%M-?JNu3O)Iji^~e`>FY;@1eHyT_AlqSFLY6j zVG(|{Pb4Z-cUdm3?Ql la)djGNZeOIz!#VNIP)_R;6tE-1pUI|XX1d9 z2yhZ#T(7U3HTel`m*O`k5s3=!H*dKW1{3|}4X)%kj{sDVpx@%KV$lDcM5L4W;v#27 zj^RfH;1#9z$~s$a;qWRrj|vqesI9eV|F#whbWy8q5i#}vybARntZnXb;GVvIR7g +fabM_yzk9wK!-E9%gRL0; zJ!IS$y7acr4+hXbdzKz5ItJ(Ka8Li=N5zD`3U(%rBj^~oFLdeq;lI)X6@3ReGlqNm z`cWaFpBVp@7Pv2T>1WrwZo+#WsOYDib5OXauOAf>x^(%kg1~*DOPA)D*ECo)0Og8w z9n9Gt+|$>O3JG1$`>#;IeWB}1eVoJZ*|pnqs>3}<=+?(?g#umWF9f=DYv)~W5UHOl zRCLS9c@^A~@0ovzfrM_U{nrBEzR;z62Hu(d-lIZA_cD0I6ZiD>qe4RWYV5Jsi$9#V zdP`|iWnF>$LYMw~Ory?U+6lUw;dYymBOKcm=TV`8#Kc4^EqZd%cy>GJJ#$o$priU6 z&$kcfB)+)x-va`C+VcQZaKG4UJI@0ZB=oiN>siwIc&`uFyVkLDeGY$j_5kQGy+k+? zRNGm!|EK{H=;BcWzBm4D)R$woN|fc|k_vnBD}&yn;xgzZsLWg2-nC0~{B~=A`$8Ah zJ{DouKJ+B!W8(9ZXes=}(UWLx^Ye;F>%1SRAVE*QMf>LskU$reG>h<-LGO8>LZ#Mn zaoNT_ef_AApjyJB{Z}I4zR*Rrl10Q=UwIX(;jC@02XRkdKPn{fZdyd7zJqwDsQs{F za4Um%);GqNrvwRVuPoYsH6ZQ_T|8F9t%S^d1!|)Ye)qZjo_MUq31&sBO2;$(mUc zVEyxdcG0Cvl((1RJt|am8RXu}|Lv%d&}H79>GZXuLKpSBtn={p$h=2|ir(g)-v8~W zkkIk``l6Bg%(<4)*NXem5aWN;a7gHT<3FB=`$Cs~7X01~tSkUOKdk8Iir+bKPhWjX z!jaI=vHw^f?h9SI-1v{&A%U(hrPjAqLj?(4qx!u&2OxniUC#aA&r#7e!{;#o>$&o4 z)f_R9(DjwyID>;gm#*z%9wXyB$cnB-KaZFEAC3wMU4#3L$vBP*UAkrPANN5;w>Y22 zmf)ypG|Fn#K95Fum6!-U%i_yCOMM4f;cd>V=xx3t`hNHmtoRZE0r}{ct?OGOUKSBa zr%nI9^pJR4yX@^I$}dJFDlrjPhQ$67!;0YR!t r$;Nk zguV(kak|dcWy7}x6Xj2Qeo81N+;cvUNF75fI))}3&y{uNHnr2cFDA5$w*vy;DbWN$ zUsh*o;(x}l2)!@8Uw)SCZG9g53r-M^BKyj$uS<)JGRi3VCQxm;Kd{o74AI83bAc+m zdr&2Q%P4X>P}yT-C*RK#|K{y26VCrupn}AMrK5~HO#;=gvxm_b*}~nUME;|FWpuHM z4gy_cDn=Sh4klA=Iu9UXbKyzi*Ao#^R5S!CNc=P_jgh`*ay9a&{zM$^G+8{j5h3?i z4|NdeN>ur*$h|G48vc7XB1T6q6yY;^%k!N|3sjKk8#-?1%KoWTHM1)bhf4n}Zp`T| z)7~oTAkY=-b-w7O%hRZz9=9Um$69}g9Kk(hib^2@6(lOZ$|B1Rr{9md*^-FZm)D5u znS08Ohx0lJbdCMlkim1(tL-J~6Y <1FI8puh=26_DN^C;C5z#3KA*9@5tuUGOIZSs}WJ? z%3-nTaA#TThjb1CU85o{${`)Is0NP>B9cBiDOQ#2BzrAPCs09Rd2mwm{>ZGV_EbT{ z{Y9t6$^@O{e>GD&2y|5}9?x`D&!$dxDoaH6?=FcN{W{3s-6;hsNbFf1WDf3^T_t>4 znusg`SHv#{{nBR9qz(dIVZo)$mx*(!qYd*DVcxwjiau&1=T1!^P(dQr=7#3Hrn%M7 zEqRH^x#Xc}vAT`C*gm#{K-bP}wah~gbE|KU<|JZDiT_0N{H^89zhVnikjNg|%gkFd zSe49}gNVV8o{0q4TFE;d--RK8uFwn}&0mfMs{>Us5wWS@8}YnTOL?LD`!H0H_@(t| zbBoBU9u3VvgmLSw7?ZxG4E^>$2Z65bDTkST*XLDLccvmD=b29;){5q`?vQ6;s2~xt zWSUvESbnwQ?-WGr+!tV+soPwRbl-Cj==y2wB(vJg{7RKbLd4OGag28No60AL?}wp+ z#Lk-w&C|IHsy5+?h=~0nj&WgHQ`z>~H3xyN=A-7Cnfet}6@QOIMEl_Q#<^lmWS!PG z!caltySyvS4(UQv#-*`{sQ)RxQTt?L*<#vx2Z63kBbJ!|HVjcitG=e5ewyTojQGPF z$%n-+hM|JQ#jcyoX9 |E2=V_x=BRgbxDm^of^ojYYsRFbj=?`Kj59QnCg@JA`$M< z$&6p~)R&EC9|}VSi8ucqF;o6qTuuJ*3=!#@Bsa1})sy`)cpL<}n)Kgic6(P`bx(De zh-xWQ8W%^`m7Z+7!%#tDLa__x;%g<<$Cmqu*!NFLV@2w^GP&IDAkdYe?@3c!DXBWe z+DSy@=+wr*JGJGC8#}^KK_Y#>TV~NyrRmJK60xm*8soCNw#@bG1_yzzbG`mHQ_0e* z?UO%<7=J0P@n>C^tnv4zFjSCOzU8SY_m)-v++9IL>Otv^0&Qza)o+P|Ko{r2hEFW5 z>?|1)s2~wKBDSZ;kx 6dWdg
5n-GbR`Z;?uoa#f|@pQB@vl3rZhSftt;OrJQs$F zgV>nRQ)POn+FWrP5i=GgGn%S;^25x(odmdYrA*}+(!YYLK4l{jzn=^=Hq*1ab>y8e zR1yPGzC#kvM VHGksc~nCxLPxg zvHDIE`K)1}Kn01%r&D@5jw`D&-n>mj#pVIVh9S*lot-Hi1iCt}$mCg@FH{{pbAyPe z%%4Q)?q+gc?sNhbB(C*J?WwxHjQXj?Ga|OWdM%FEZz22rnaM$*D >L0u>}0o=EFi@@py8<+u10o)^F6Z7*w133CwW znw%xQCn!+~wN#`bBH^%eqH($QvgtZQpn`-kJAr4=%n&uJdoU5hiysgRb99oKzx&of zpsUX4B%Z+hh1I(CK}4+Sw_hwQ*h#+bSyP~b#FjO2JWV?mR1@lk@KLppV*ISmvc%oG z4gy{ECnxaCcveuwUtEZYE=!`tnFXC?mV^xiDo9MK@yQ&KH@~V>vlPd;u}8d~&`C}> zTH8UOtN4Jlp1YNcsH$W5Jc^v!BnE!$B2%7fDNsRT?DnVTjWofk(Xz@!jA*}H>~M9L zi!Zkqs32jizHR0{5~SW9sq{YxbUjaU%-q)}huT=H4iU$OtrT lEv#OTNLl;AkbBI;x@B$NOqO y68S!$eU%%&fP9yYJ5tzNF?3}QfgFsjPb+b)(s;uh8&;~^On|iIdLeEu- zM|A`$NbElQlldrh4mDwA4I=i;UnK74=_#LgZ|ES Xg1X>;5#C=bpA~oE!I b5Qj3VU-z*n@l L(3SdCMsv#L4C>(K zrbN{EezUlIxtpwzp`1VkiK+4Pm|L4?R%yq$h`6Ixh#hnuvlE6n2y~5|@m9Wyn?W`8 zG$UeR{H@~Nv2OB2ijo2qBz6x^Xr}6xNqt;bn}|%|tHe&aANQ`7aS-UbS<)lhg``*a zk2EJD)!c33kLYgl?9;*m6(k-%IwN}x%Bb#K;u>!Gp4DPjp`P;b&&3@Cx+ P1GA0sLrO0 zB;vvIu42%1w@luszCZerGin|lt@^+;L4gy`nr;dw`y(xwA z#OL$KpMIRUHYq|*-B(+ng2W#si^zeEQ>yFb-9*G`KVG!`EkYKW@vVbE*XZ-@ zsbWX;r5Jbzkw6z-1-C0*nz>?Ox8Cwo%kKm#NGy=o V67#MgKM-iHcu$EFD#atK-G(_`PKGkD~-CNK}6t z6+JCe67^r7aYS@j94acd?I*_?5e@=f8Gg?o-@Hnq9w*@#NT7nmpGStt+MScCbNj{+ zQL$90DA%x`Y|^l=KmuKH?k|yde@d>V59Un6PqkW!MrL1`;M<-86(shCoRh9RDO954 z-xHC3NE`9v{l4<>ur3Y)T| |owt4f#p5RozSNbxCCggiC7y+8$tAEVNnwQi+W z?Ncx@r`{-Wxom{&yuX!$K-b_z`OJN{)2SB~IuJ1_$xJc6d~dlgNi%^861D%XY<{;i zz1mf`JrO^*nk8h--m;I;&_SRJ+j(cd1QjH(_d%na848J$&HKq)dA=8T*OsQNFXv}V ztd6{yPzkTJ+ZFG6X>t6STUNO^M4*C1(7-?Cva?B*Ig;;r>&9h8NcMg*XSRV30$q5e z-L8fOT8Juh`pWblA_OW(?A{j7+_Wx*8X3g*9G?;-(1lmp?Rxju2od*vA9?eeE&>%K zo^LL0stIY-=&HRb20kT7pbM`wjn?j(F7D>*Eti zC5)U*#ElhMM6R;^WKdEkfv$+M+hx$oM5^ofNkr_WY)qwyezM1haRL=2YNWd_yM0Kc zhSy`F@~b4`ai@OrQ={0$r_YCNb-74pcK hjXoL?kXcRUG)#OBTA_*+HNS-xHnhwWuJ0Z?A6G_pVr?PEbF2?(k%RPsY`r zf6E7_5~!(hrdGncNpD`RM56zFx9l}=vOooi1(jl%f%y`vgk|};x-l)W*cseUt{6Mf zL7)rorrY&h!=hq$9=9C7b*w-IiPvR|ne|#EQ~yr$eG-vC7v4>JuYKM?1iy)p6D~vw zRFG&?q^&t`dMdT@IIn!cClLvB;oWq*l58I+ZjA3Ee@!|_pn}B3nG?(_d(x?XBlx+( zClLvB;oWq*#@3r6&R^*zk5A|&P(dQm)J0~lHC z)pwl<>-WShcb=Fb&W0y7PmGUG<89L`y}KK1zFLu3t++d}5 Kv(i1f#&*piPg-kOgt)_NKC!ymfx?OEKotB;FU1*!>nYgT~B_l$_y kc(PWK6Ac26Oq?_PU;^d^ke(`ABs;> hE(D`$=wFHk`ub+Iw# z`c|pcmE(ixJdS>-D}pyg$RpIABY`fw3eKyG3KHW}%r)zENv~#a>P|5h4(l%tcIYF= zH2+>8fiAoX&Z~ wy(4irGq2nhL0l!DoC_C{*xIPm0kta z=4(}|eLwM{ULP4Sbhv{+7hVPD)kOt~euLJUE4E}-39In6ik)e)xUsjFJbkgRKmuKO z6`WTW6(n#}-8n*!1iGA8_gTrl;y~d(vPi>`0 JY#Ffh)H7H_Fgi}`+g1rU3gB;YlR9Dn6q%^KafBd zUIogr)S4(VEb1jk7a1(@*wf`WW48W~UHx6EekF`Y^Ghbp5$^~0lnom95~v`7*&6C0 zKb$XyJnbP@tmy6_(1k}xxzwdUiu*Tu$dXrj3*0YenvkYnUL1T{6dBrH7Ee_}EKXkB z^KajBszm#Em4;T#?%CV8q^kF8s!EvIa%Ra;L849H 7o|OKcT`Xn z%0CUq^K-jq_rEHJ^lU4iWe9VeITG`d74h7ATuxoA!E=hfMxbkA-n^c3xyz_@&v~X( ziL^IGWJDVowym5%1&J*e3V5!RDW|H3#3o|=qbnlOY0B`fs^B2dl{Ic|PyT+TRnB9H ziAZ V1gzL}0#eLaU7X2aAL7;2>wQQcDdrGN7 zE0Yj$@8?^haFsT4#Dr1;6(nBv&gI#=zl_THl4D?|8VPh^2AkT!_P52hLT#k%R>F~~ zMgsHdZdcq@??j;!&E$z41;qXrEj$Ca*Pv&2P59B(E>DphRn?g?yTb8VpfWi7rPy4u zg?xXapyMe)!rWcWleIx5Rck8G{rDPzt__!kC+P%3?Y()Gh=$c(iB5@H$a6Ic3RI97 z-K>)5^{8;=p7R$G-`;vE?saS_M?45|5a_x;Ce%|^3YGWVbt1|oc`e#LXf98c&o59x zqWaS?&*?uass%rtBqGu8|B2n-w3N%v6?72j$`w-DGyG+knt1RZA|CzqS{yslTvjZY zSD=DKk_qKJ>#iBf{OvRmOCp|$WFakOMl-*IKv&Z<#XMU=!qkHuw}|L7?~Pc!rn$_R zK3JfF#PMvUJd=_e>h@}$^^&gKb8#SfOL=-}UI&4$8N)(6k2_RQuC;fF*wgB*c=1DX z+4xf~feI3hYZmf^_Y6}dw)0$^9}c|`qffPvvD@c%5a^0^EXdRLRH#Zd>j4oL9={WZ zYBra*_GA;NAaQkYZqMsO (ES&SuaT7932`9 z?;6MG`J}PjbvCCMQK`G6)CwL7;0_Xt-zPtV(M3WS-sjcy4T?-Nq)eRhBFQ6(qiETg78` zsG`y@-9&^tcYrZDu$e43Fq?xwSD;zJ6C4$;My%LNMAEmhjl$EK$V^Ey2~?1X^G$hA zti_d7|9IPp82uo?ko%j;Ayu3Ny4D;j;mMhxqG~|jgQQVsPaGq5)h4p(^|S&NBz6=n z?g_tAk!F(bA|mr }mPaEtRcDEkY7_Erte?5`m8QefF znv}*tpet_mUY`DS>QLR@+lN2}iIjy0c*bR^uihuCMMU1cNsNQ7>&rbIQwk)|m90gD zr)b7{YDl_zL{yER#K^ayzKr`_T7e1@uO5y5yyl0$r1LcbT?Hof@LWh+SG~^K*oQy` ziPGCTdq$_KqXq=GAfou#q((@R`towWK!F6h;_mC<8Qsi9b07y3@nK+MV`1$EvecmD z0u?02#B1Z3)Vda}f*4N3nRJPbWOP5u4W?@a`$E^>v08dUe*I2ey88nW(N7Z?&s0NM zXI5%~3KFO4HSx^+<6D)g@^m6zOiN(Qyx&lE_%W@6K-c!LhMsf()KG`h{z}CDy77#4 z S&2h$5wkjS~Hj%WUx>ME@HVj>1Sj%&1R-B{i}nbAR@3u{zoornq&SU)=NIX-=O z2l0ua84xW}7(4FQkq_p^68)Ez^T;aIX*Rbzym_Ujp2&o?)Z_Bq!;yBzKn01)x7&F_ zlGj$x3$>*)$0rd9bm9G{87a4t8u6~xlOG2r5vU-6caui$#-%fgtf?heU6f%slN{%W z 4?_isM9~>MFINjS|9Ae LkFG(8%SvyQrdCdh{0} zs*g%;bpKFSmig&!7%I5m(B7Fn>zfI6@f`OuUdIbGYD}mv?>2biAkbC!XprZQQBn2C zy_$$&p-GLLck0WFN#BH_g2aN0Sv}A1hN*&$qlnnOIFXUp-B2c(6d;g5*P*m|J-v4s zs_K!=M8rOu&=_{Op}cq_u0RC|Y#E&G2NLMQ_R8(*G&H^OTdP`f%aA3GrzB0%{pN+h zlIr;Wt>L&jf@Ws!N^5Ky=91gpYaOc~kiaz?Zr7rmX^om6T{7+BRbl8l5WLm=W@<6@ za{iHUC-Hl$RK`+ON6u=x&9RyT30!+Zs|SEU1qoacLvP#VQWz~N){_e|ZgZ@BLE>4S zdFIP8g;ZeVOS&IB9w#=Q4sIxSE;#BS(1j~|oCGRJ;Cdq`feI413X0}(-b`Sm=-61U z-+$b(+6jrr|MoOj_Mx?8^-|FLdAotJjlCV3$+GuuItX;(YAz>%3KF=+%t@ev1g?B@ zyF#|U7wxvTkUI|EbF6SfVo|dYb5w&|>f3?AbRKgTJr$e!ww4Q9e{>M&!u5Dg0u>~1 zU7_3ctMRW`+PaOrQ}#m`x^4}=AXk*gsU5L6|H&-S^`XJ9qS=#Fs`#!Bm2ed;^@l7xRr?veos1j@7bA z;M!g%feI41;+R%JeEW+?G_kjglfAHGWib+~8`U P1S&}27@*s= zE9$J6^lf_?dMw;QplfvD%%1-1OQ?6fQq!5^2qY><;D{cL@4|`^B+!K`Nt`2)s374S z9sJrj9FDNz$St*MIi8BqBU{Qf?}`XikifAsTD=_kKqy+nTYXz`2Z63S>xz43yeqFR zW#H9LIC6&y5;%57wNKTDBH#X2veDwA0ts}z?^4*4w|sdu_$9A0#gRKykifAsx9e$= zM gT%mvp61Wb* z?Rvhhop_bnE!WR$FYs(}?kFCib1o+;NZ_nc=ha05U3i4fxtypVfwMxLR~HF%InNwd zjG%%9&I)y2E1Xq~^M&ymQlC0=1+isPKY1gfr{n4(f%AHuvvF}>=)x=Qoc)QjF_&J< zZe}c(QoS8Kv=Yvsbh{e&A0|Fs>LbhD=^;=-qV}J)%rb@2s^5n6q9+k&e @CcnV0Z~B$=M*}xE)wW+o;j{pLj?((Q|NXb zA6Z)bc9+&092qKb-Yd>%#plg=bx}bAulm>La-s`op3)o3rjp|KKi#r!u}H@ZQzS6n z*XN|73un+e=MLhWBAnNS^O?Rr(+LTjA?BQUhWkPn&a(RYj4dQ^rW@VC$b{m Ld4UAFaBY@z^%*Ki;L0AF5&w505frbV z9QWfS$4o$+!2RMCajp}_wZXU+ zx!n3!=CZ~4RJ;Fp750MiTg0YUUF6nZnhI2qz}3 T0EbCZSw33QcM^vTS1u7FAsyD-JT zHSVY&;XIE$=}d8Ga%XvDcWr?Lx^PXsbG Wu4&Ue z;? ZPN6a()EDoEgcqVEArzbM*1ZYOspDkG3U*Qa{PJStuZ)g(o7ih*BMK?MotJr6l_ zU1aLkR`&j{m_Pzu_!SrDmk{vl2l&K%9IMQAuY>5!L+PmSo}+>UJ_|HDn0Tjna=44U zPBSx+K-Z-TC(P3$^QtVt#VH2fb5xMPXMx^?r|uE!>UWkyCR7qgplj>1tLE#(1=Q0c z{PjJ&=cpio&jQU=+j&IvIo(mN%^W6>Kv$o(AI$KqMbv^U87T(db5xMPXMsj*b6ge$ z_qUV5v{oMpbmd7J-_vP%G1Y4i=RfeCqk;rJ3)FtJ_(xnVPhabq5+aa5*X+)Lo=HVY zs>(eQQVjft5GqJGpQ}->`y% Q>=P-& zI?Kjy!UPiN>R;wB^VpSqG-InE#lYtZ6(sO^bGxe4I3}KM=_r4kQC1* WUb6a$|tRFJ^u&FzZ6>5AC!Q#<)GTVa6&x)x4)XC|s%RE-(L-)O|=3Kb;qd2_ow z|J@YzleCqK(&QINpzHL9xE{AGuCkWmIl}l{p@IZHZ?t;(_5-nPP;2>T{vd$_x|)?v z>bYE|l-m51XGh~#xllpE`Rq=b@t?SRtEEiRD!V`eUHBC+dMko2x8T=W@X6mEwA8GA zJr~VL38SOJXBQPD@R_E0uYpqhySj^ (DkzR2J=OYV3qeyNs58bE-FaiGfm%s z_;tT%8q`@%ZB$MmfvyrmP4hSUlINIhg(wC-yQm<6&os?7IDcG(&+91rJ})7VK-V89 zE}Nf56r#B}SttfRyQm<6&or%%s(Dr1=-p0UpPFAFfv$V+@0)Ax6jlogrJ)%3?4p7M zKGQTWW6EvO_Iw+8b#jnE0$q9Xy)m2DE~Z|6;MwZ4p7MKGSa3_K}aoW23c 8mEurck=lSjU?4p7MKGXE&uaM_r&We`un_HO#66mTU6MMF1D^1@?eMK1) z{FWXnNI1(!*I92xg%mAhweWNT33TDN`<&k?!fz8{=}Px}wmIuePL;GBe-#+Z2P#Nl zxuNgk%r!-`d0k||%%K7abdA2S#2i;BNQKnkw{0vRs33vmhTgo29260WI?JeEO9~{= z^(1<|IcRhq tW2Ks_>$` C`Z4BVA&|(=x1qm#hZr9nju?+J% ztx%8&1rq2w`YFCA@q{vJ*h^mXgWvc?1qo+4Uw1#Q(UjIrWa=4LAb~FYrm*vSefW(& ztR)r&_cRMeXQwu*BHckO=cpio<)7A{Z`mzocI_fpwJR!+K-Vu1N1H32 @(ARDx}6XNl7uVoTGvSmVdYF zQn&v^=5ej$)aaA~33NpyIBiZZSwt1?!fRi#oTGvSmVdYF?{sg(qsuL1#q@y!33M54 zubYA0iqX0We&@t;jtUZ3{^{FoLq3TOrJBpm(-R6L&=t7jUvuKGB~(Cip5uk(92F$6 z{L?)@8rvu~qp2K`KdwLmT^G~5H{+ctrRo*twU$`UQ9%OBKh1&M5zoj-Yy0l){uG7; zx*BYa>lvB2tXf*@6ve==n4^M(vo7f}IiV3yzmcrc_f;4Y=)$k2Q>FpF k?FuaMUrbyso{XQ0*>qW5 $}D zI)=Vd@b;)!y}FZ3{3%! E9AdP=aqLInw|$K0;o_xFjK z#k yG#$90l{&vOVQ&~ (;+LInw|$7m(ezvqPKbO-r#Yi5B2x~g=e@7%o%ruICDVqkrR3KCe4(HG1{ z-4QFgwv}&|rxZw_t8dvK&8vy?sf2&>C@0oes33v$nA (;+LInw|$LN0Sej)n5Zz Qr&X%m<-les33v$ z7=44T*L(4}dJFmU4{-z%==!)M+I(8AF#VbbkI7(tg$fc_kI{_yF0qXGbDPPDp&!GL zKv$uN!{*CoMb-R*JSKzn6)H$zJ?3_07!cQpq+dSqEO-%y1iG3T=gs%MimUfO^Y o(?f%O=@s})IXgv@Iw zBQo3yLjqk}FFiFA&MU3VQAa2S)>o(?f%TZ%b@ED5qwCB1@=5V4VMw6s_NtF&;O}Ks zt{42fOZcljs37626PNc+ZscrUPex2Q6NUu3@K=JIzp016KZoBv$FHV4zpai6*1^YO zr81K*q~E){T7{lntVK~l0&7>AFFN^vIGd`A%#kmrKmuJ;QWZ3t{*q076T;agtVK~l z0&7?LCV7<;!qcjg{BSa}KmuLg ad@A+EbbLRc7DWXKtX-)@^?xNQY-}ly zmWU&eK-cgci_8og3aCV}cvKB*QB;t?+LhKK=b&$kylF0@T6_pY0$o=suQA(gE=1p% z;88WKMNvTlYgfvKSBz~$hc}a>W 8VZR(!hsdNT6#$gMH?!9YxiNGd!w>wJ0h`VC_nCYs)4yQe15$H}3l<3<-3-xqQ;B zM7ix95!WaN)}p8&fwimKRVhvq<9x}6GH>*yFeK2`q~ld{iYiIJ^ml?{U@eLY5?H%Z z%eg+#NHD#=?DXHMFeK2m_0a?K+}_e^`YQe!3D%;hAc3_j<+hC!#)l_$<)OAm!jM2$ z+5xZ4oCnL&ZyfR$Xz*9xP(i|3Q`gy)%1F|rj{Gh0-Y_K4g}*XKUkZ71Mu_I^<(s>e z9V-rTWgvdX&G~Cks376|+8eHFL>I0lbP`{y6Y*C=u|9VGUMea`;P0A#y>3Srwkyux zeMJQcY#+bgMxhJaN9S*{;<01PfWK?%tlLpR0_%L|Z?YnRF8p0nXWfnp5?JRuf0Gpn zbm8xsI_q{+kia_M`J1drpbLN3)LFNqf&|w2)NhC1WJLm9_`9agx*ZiHu+De>CMy!? z!rwJ@*6pYufpxy~H(8ND7yhoPvu;NP39R#J%@6!0D-!6!-!*mC?WiDub-wdAS&={& z{;sLBZbt 2z!calN*?#=6 N#L(kqJo6;E1S5| z8(p}*+DY6j@<9}z*-UQSkjL@8KKv3NehrgqIQX6=DoEhBEuG&4L>GRE&q?6-EKxxM zzisLKCLp@-OMFfOzh{XG68LRP=Qjb-g ->4>k{Y3rt%LU^aap=2)VVANBRFJ?g|IvE2AL1H= z$26Af&S!QI=(@kY?f>iR+~cA+vOPX(Pyqq)!37hanE|4UO4KA*WO^D o)Cob#=7PE~hRPo17q`r%chjZMqnBO8;CwpQYv%GG+TjdP*|39J=Jt3ls3 z%DU)WZS3#=O(IZh$y+b$uZ|l-Z`RBu8+qeGm6Rhn+N1y3*@+S)u<{@MHhfp8a`TfM z?eT(+5`kJ+`A@EAi8U;-CR}mL(fadInZ}<>S#>e2T89!Ou(}-W?^c8;zv(z$TUOoK zi3DmL+L*2XWA6xK#K)`223Frg2@+T(k7~+hw^g*?kJCE57wtp>wL*qIr>~wintFE; z*}y7-C_zH54>$K*nDU_`N9#$I3Xwo9tS%>4^2F+$*wYgarRm=c9%_8phn*XaeT5Pv zun#@%+6AqZ?w{vsZT~yki3Dnu4;`u3oXj*Tvr5PY_7zHyz&@m^DsiF8>fz(GR&yep zNT628fh@gr_DIT2yhS#oz5 9&b#aONP zyig}fkdVg%tDa(|QyiP#OaEP;J|o>ItYl~2WACB_3G8+HO?6ru<>k4#TECf8&l1`~ ztu *CBzqU%H_riCOS2!LhK?29g<1Ptrt31^+S38>&>O=yyii)4q zv%@lsRaK>A1IGg;NZ>em+|I3G%Jq+Ow3wI>ClaXD#&GJ@i-#G;09IcV#{(ru;5gBD zwZsVJ+#}iAv@R{2NTAlTdqeg3`@@Z^6Bm+={ ry)lBI-iPFda+)k#~pREweoJO@!DtGA9A7u39Q%H{922ZT=B_3uB?g@ zB=C8q`DX^y!snIdE5zdS16HS%*K?F0flmhXc0zNNZsExStL)NymX3DHfkD~Yq1jPV z{atKVer9Oy+I_iCU%jg%&1jgtS;hNCuFHuMB(VBs^Y KYJfr=i%VnvuDr#XJSot1A2@>)>h<%08;!M;=9i)G`G{e{v z{ULcJ%_Sg_2x-2$cPm2KsEyTLI(Rn~+rpV>OYRVTe*G|GPQ? x_eiqF<|FS?qF5+ty)u3WJf3DgQpjnYGQ4KY>}v**Nz 6+nz~92@+T# zn6fej9TnH6G1{k<$7KTQ6Jy ~X#JW`=RgTdd;9%J&CK zkiaXS-brodDnTv0Lgg{Xc5(dWF>kKsGTtB9Qu8*DKo2SJ+^25aFKb@cLOPzVPMLf} zb&Yix6(3~O%$QNDuFi270|unm*lhb$n~mO%9+2(wxUXFJA(0a(L1Oi+W1=Pc^}zBk z5`qz^b!__os1rvyjPUZ|H6Hiv@xS_%6DV2h`daPaau_Y9jS29o)%US>PM}vZQ4euU z3%>|)SP~>g{r!ZxufM}cUOa)knpFQ^qOWW=i$JY{mrkiix;l*fgHwsP+-1ZT?iET# zuRE 0P Ae5sdVqmvP+(rxlsVS~lN zB}lya^jWoZo!yxJSqXX7;pYm;1}9KUyL3i9|AyT-wsk!bx7H vu)eoF@qrp`{MB(#G z#dx4(Q@0c93rTh(XVTsPucpi|32Fn0{o}t CtwEOW| z%wtyMmIR3fzd5dc7h^ZNMAVRt@fRNzabR1hb>o$z>aY%WW85sA2%F7T#BHGDn_qt^ zw6G yZgGw->qS6!7q>os4)i;@kjEFo{gCRe}-hJQgxQ|B5v8 zf2%D{FsAQ`zEpMBTBY m#m7^c_o>IzuD9OB6 wDT6krNjYk+e5YdywSt=092HyS{0$a& L~=dU~bEN0fK>?sCcmYRQq8 zla25{_V%tM8|c-kSf6s|OoY+hLnj-9J$eIzh#&6s@y-}f=0gb*H@>Y_Vn1zXJeSYT zMb4 =aa>SMPaaWoX*)K+l$%d_K ziZ{A bMgua)RJeo{`>N@ip4{{8S!O4lpqm% zdA6FeErGl`OtX)J`l|1@gS;oGcacb1AFci^y&s*nYEMJkGJz5#@QR@t*wj}o6Z?8w zQePn zy{+JUwP|hQCknRNV&+>uZkHn)W>Izx8-_8z)eL1fI`J zzkFG !rM-~MoP)j~xmCfzk z2Cg!A?iPCMaes0uatkL=f&_Z#aj&U=M|g$S@qAx7@<+x_-)h;gB1ZyGCZ^x3U0x$> zU|Xn#qe(knljFpg<4EDT*my0_JCHe694J8o&n#!x)n7y$NT3#;I`46ZO#eyrE=q6} z#g@$Voc9$ gg`DO6tksV?RDewos$ zhD7;jukx#P;YL;rTcf5EF>AtKQ$`Mu3Dny1Qk9awIo#-Rn|ZZ_h}>W5QU)ex`cQ%d z+N1YgiSP~kE~WERvJIqh6{Wq`xI(4s{C6pDr)T<5g2eNKtCZ=p!;HB9V_qc@F{|6P zl!TEofm*nVQXQ|@Lgnh`*HSLzX8KTq#HjEpW!#gYMz4F!2D?g5Qq_;0Ic_JekhpTn z#L`dd5;=hqB+xd0ZfBN?6!!h&XzdMJ$!scnhUcI0dr4D*e@j6EzX349|J|dA8R0hA zw<7+T+2c0&_bC2t#b!fEFoMx^&d;w0E35X$J9fO@Ph;l~GT*1P->BklHhO3hrxz~S z#0Zoif$NG%kTqK%fm(9p%qvcyS9n4q+Vi+y$uCrt_rBeHImG2-XE?Dq=9bJ)!xOHs zC6hR{q}ss zVwuF$+>L&(Fmlwwm`vj0)Rytg2KtL !mfxU}bc!r-z zJXg7G6C+T9XAw5_iuW!`0*Tj)pP;ir*?VO4WJ$TGuq~^Vav#4Z*qwv31oojxtoZ&5 zKY7S6%4R~R|Y5*U+7 z#EjUomDxZE67pKhdN=FKzx(4r>$u7^jXbBSY+GO)NZ`6F&7%Ij40eA|XS>*54}7M- z@f|OAg_!@?+-UBR1R+$5*bsJ0?7dQ+ONYFg4tX`Q!S_oh#?;|npO8>qO%SwmCt9L6 z`ycERyPT$1W?QI5`+mBWS97J?#114%Xg^Vhyqd60nYf;zm^Tm-Bxo;Fx3XmCAKxG% zN3T$e_DFRrOEzgw3z5e`3GKb=R+h}#bE2%wEgg3`g;z+>zOQa&$%?CDgT{ecw4bb7 zS+dmQowoD6aFozKwQgm}(5o9mJ_ frOQnslES(u)+F@OOT*_c-_j9&AS;RT_v1A zE!yALtt{EBqsv5Jp@i}VI%FDzZTg+?oFBz_pjSvx&O*1cWE<`;k;a^Rg<6yg(XA|5 zxAD4&10|GW(XA|*)gvDF%qw39wSffXcXTUDM!6zk1LHs~${Xoc&MPXcr65p3`6!*| zxv(X3F5y=RN|2yjn4tZ6wV-jJ7UkH$s~wS@#9fOL%H4@*(Y8N77<2?>q8`Saa*!f& z_WhiXAQF_16k|i*yw1N1aaaUuQT`Lg{5eloVFM+UOBL4=+NM2#Wp6)VtgU z67-$}% ucvl94MjpGGL8Duh`0rW6r&j37n}tQ~N>hS`sAa zy%ShDAG>o2M$jTq3)lH3PZ^C*7CmT;>*2GNF>Olldr&5 I $6V}<4L07yOLHZ0=2|Y{u{}q{9;wouEu#u$|=b=G )J&vyej=yi6BsdgzS}nBy0_p%mxyug*&XK4O=6D L5+uyeJY(`Nju%Ka literal 0 HcmV?d00001 diff --git a/assets/vx300s_10_custom_finger_right.stl b/assets/vx300s_10_custom_finger_right.stl new file mode 100644 index 0000000000000000000000000000000000000000..d6a492c2046d3850ce7c7d650e951701b93bb87d GIT binary patch literal 83384 zcmb511#}fjw6%i=2@oW>C%8L-R3(KD5+qn~_W B^EWd&o^lM=j-XM?M|KSzb}|S z!*GNmfr>jpv_^Qz7<-YRZRJw2AOAezM+}QV#hoD9Cc^g)p!4vmnqWi>66&bP^?3V5 z>-;`093f*Mfr>jpF>Io0wXRl-op;W}AVJ&8Rp;gfE5@&F`^p$dpyEytZ4+fOHMC-k zY?jK21Z^wVxIO2s7`}Hp$rwZ|shci<2vpn&qHQAEnesea<#GWJa+lBuqLu4w*f}f4 z`rOTB44c@oj0jZR38HP{R)viG`HOSQ_MNGt5kxCjnm^B2F%tEyFJllvcaR8F+zFy> zBFl(-jEJLiJ7@&a%60n2-&TxHIcmxnNTA|Q5N#9xJegy~XnMG>6A9W@u7yueS}{&E zt14q4fr>jpv`xHC9p8#k{OM39611&cM;;uvVl@4!vW$TQD((c)HgWDkk-c=SUL6?j zM1r=JtMtvIR*aC36axuV+zFy>B6Lra7>bc9WUvzn+E%X1w~tsc`gEx-V<3TwJ3+Kf zB)=bH#mF-;!ifZJD_5IuhpZSY2iK7?kU+(qAlfG4RY>fl^Z5HqxDyH5R<2Ez4_Yw_ zC2TBXAc2ZIL9|VL%2mLMk*aX86A9W@t|@!=TQRa8Y9nJHfr>jpv`sWCQr(J? ;>yt0R*ZaGKExnF+sc(?fN903ziNn#fdne<1kpCp zy-lPQqt1*qF-Xw1a*h4Vuwv92F-FEf0u^_HXqyPD|FaciO3ETJNYJ)&4UDv7j2JgX z#y|oUcY a6BNTH;A!Vb zs3(zJ>b(j0d$Wvz1S;+X#jpu@YdI3?jUtzN|Ff6aDPtgkiaS9uYywIjj)W>1$fe4$ z8~ygk7)YSvPEZV+fKrqrp-MP%sq*;aFsF=x1S;+X#jpve4LB02q$ZcDSJIX>Weg%} z^&Jr)YaEV>J3%pQf@-yup`!z2J*W|?#vzxgAID9l7*G##RNM)QVH4kL=O|g9YlNyf z$))P~<6{gN0|`{z35sD8-&+|`vR%TFP%Qv*srE`#o){Se2~^w(ieVGqTT4;0eZ`Sb zEg5pDmRgBH(J}@RqlWcZP6R6M1jVokyPeo@xoq`mgldtIOSL$!E#4+$Ac2ZIK{0IN zdwX}eY`t 9{$wVSp%AirS(l z HceG<5fv%YmCta}}p)X}C{P(^S0`tjMI4Ve} zcg1eEA4)uey&g3o`pTXG8iB5p9sf4NYXykp8*9k(xFmZMew!x*)|I^pRFF{birxRn z`REMe$0kI_ui8~3&^15!jF~1lK*Z}%SH?&y-$CyT69f0jUJ)utsCUKgM_p~1iubNC zG5VfgLybUJtrus_J;eh=iIGiZjBB#5R&w#gz-ZZ5Lj?)-uGsy-v3X1Il*cDV|8lat zMxbjzm-FV)YyqO*q4qL{pX{Y}t1~I^mh5w)f`ock?0#*<3w8MR;gh1Xm&vFR=&Jqu z1@lmn0CDzuZyBSA>^m>ZFgb9j>^q}^gnC!({`t=N9e6;!$ 75Q{kO~Z@Q>mAcBProQ(0n;1iGrH zxM1cE^A~;nH^> >htQGw^3wf1rYddXDWns$~9Po-FsQ=(>3eXau^FZarttX8z(^ z@Kzav-obOuS%EiY&4~&U>V359+ES--a_=XzqT3g*t`X>3mi?^Rrij02*lL%IaaEQj z-6CfP?vgD4RFF{Rirr@DzwQG|HhXsTy5X%g0$ux?oHi5X^cRO4@0BrpWtkZ9d3K;7 z+a;(Vp~^?Q?GrwE4ddzNMDw Rr1{EzcvSf0TV5NT4gM&0lb>s>*gdU8_X0 z-Hr+p*oJq6Hl4gCCUq72ejtIaWc?1qJx?G 7-9o*lhj zK37PfEB}Ur@a%pnpVy?%^-t92F$6Pvr +g` z?_iR`!5kGNuy5!HEtWsnnRn8x=mzpVM*>}AjD1j+G>{`jRF-(1&B;+g0{gg*(8eFb zo!d9hjGigWD Xrl>b;%PZjL}g;OEgu*vNMK*x5!z>5g!Aye8PTO>xr+q4Qa&}H zY;P+^il}U_He(G#1qmE;aD>Wt)tPGl^yp@?{y+j @gsjhFBgoZk ztpRNYIuB|y$n%In1^27Y$sQx?@K;~wk}XrB17*7e33NTJY(m?I?gzDf _Q#qqdSwLv&eQL z66k8&VL!A*!{qxxZP9pgBo!4T)VpGjy^haY!pU+^ik>UmyGWpG KVH2b8%62;v=qfe&5cCZ`%kql)21n$`HY!M{vdbO|&;963 z%>FNS{{snhed}}t`Zx(?xl4VV%yNVt6(m#{Y>(+5PCO!J#}d0Ag#@~=pXdm!F6$5K zt0k7122_wxb%~vuz^vuGnMR1pbJDiznRe=A|6R*kTA5K)+zFyJ;(MO+$G(VGE*0A| z&xwjVL9}O{6A5)x L{Qce3EEb!Qv(lJF@EGZ6@iL7LG+J2ClQpjM1r=J zD>};|D~4yD6BT!YXwN(+611&cpPwAIV*JQ+jvDr48D&OMaVLoWk>~8lzAU3WCla)+ zTqSP&WySE!bE4u-5bc@gM1r=JE76@}Rt(QPCo1j)(VlruBxqZ?=07=M#jr~T7~gfG z;!Y546EIG#5kxE3>xZYT7@m1fRNM)oJ@cGM(6(|#oH%X8@XT|f;!Y6lndd};wv{W} zgtJzRA9>Cl<8Jv=W)u~7g6JQ4&bFry`BR<~3EEb!;MC`>7@m1fRNM)oJ@cGM(6(~T zX>!4e;hE<|#hoD9GtY?xZ7bL2Mi;FZo_S7G+zFyR^PEV~wsKWZaLJ0{ndd~sogmsX z&xr(WD_5iDb_~xvCo1j)(VlruBxqZ?)ZabxoT#`HM0@5rV JE}iJ#U_QPE_0pis6~(L_$4@ jE>(_s<~dPuCn$zzo)ZaG!jVgr$DVmkRNM)Q z;hE<|LY36yQZ Rsit!`Qsj`+xsFn=5RJ-d(o>MmZtjs7X z?gYj7k>@0W@|;Mh78$uz`|(GfQxT}R6BOe|o|6d5b0VQymgG|H;2(KTMWEtNP>dgW zP9i94iG*q)lS{SdJ@cHXxDyn^GtY^HYUz_p^-Js#ddzd8;!aQuo4`CL5~{~SF4e#C z%yXjRPEZWbJg3YkhNwKJdZVZ{DRYw{appN)3KHsFi8asZBG4s!$gbFWo)Z-$)Vt!D z=R^WsvfmyOd!7>&B-Fd&ndd|TU2^;(B=$TfDoChz#WT-|1iIunN=WQ^PE?Rk?}}%h z6A5(5@t}~{^PH$4q23kGJSP(9lH*z-vFABaK|;MNo_S6r&?U#`LSoNzqJo5aS3L8a zNT5rOGls;T=R^ew9M{nEoJgQcj+chSp65gb2^^o%^PEVaOOE@7#GdCw1ql_;GtY?x zy5#tENbGq|RFF{D%`?x51iIund`RqhPE?Rk_un(mi3GZ2J|QIbJSQqhsOQ)-&xr)O zWL_jB_B M-ClctA^MIhP#XKh}NT_YkJZEf# z<;ryl+6 X+r?6F1Qa zqLr)Ze8bu FCI$E1(e&A+IntiEO)J@RIi3$?G72I#d`1tx? z8H0!>voq#(C(uRP6vHM~4qp}X_Q9@z@V${vRFLR!{h$>i)x&c#1`*L8wk>xj&_&x6 z!zS)-XdW|T-0tNWk_~pEg2cZokJvFXACobNkay6_oj@0DTQMwR@W?~7wlAvNv0+YB zka$1*m=$CC+Cwr15&s>m7T`{xi?%6-O?WNeTFGm9>1C;340ob}ME&L`tr)A 3KD((J8Q+Ld+V}{LBxZWZ~ff~bkR1&u!*lL_Og;kpZb?P8sS6* zi5K%PSTRoi{Yb_jB156>0qz94Xq#f##G7-k*{Jhv0_1;ARFLSB^|BS?YGW_?o)ht< z$h_t51iEOOV%S9Ks_A%4q7*A`Z>r}+1qo;Gt5%Hg**-D`5rsb`4{#^YMcWj^CN3r_ z%y(Bfu=MWSASWtFjF@`eigE5mRvCkc0S6l{cPG$A+Z4kliZ>47KRwyB{KoNoPE?Ti z7IDLhF}ZY68H0$#MYb$;C(uRP6vHO&JqY8uYW=kQ_Tp4dRFLTDeanh*C1;3?K}4aM z7X#c0bkR1&u!)(2+w*?@n*yp|eH4QV5}%vgwqp23Hj*)j$WZ992Z1iyrWiJ{ Or83wkd{93_UQAHysof5WF!m1{EZxCA?$B zC^{iZ#vmf ui{@_WVOWj$Upr_ Yaa@@wkjZ#K58xJP35D62m5_^qElexW6oYI4Ve}vg@ztb20`I@>&JB6X;SU zpG{CH+Hm )x21l7)`CmYLT z?aWa@Le=dzqMyhZL{O>VPM}M*3~Yj08AQld21f-6)kaCO?zxQdy o{Z;*?j6p=jqSL8;r4i^-Ej63?-cF=eA4dfV)n=?ao?;NuW>#qr z0$r-bX%p0X9Xztya@l(2s34)*zMFcykTHm;{=T)pJAp3Ma<&O-g>MZDqINq+1qs!L zfBycdj6p= )c@e9AffsqZ^}H9F^G_D2I_xk1iDmD#wMs& zG_GPK-@kWhW9P9^Tj7(~dnPi}VtU8+ZB6V$t_TkF#@*}LPYAfft(gTMSEV-Vr^ zm@}_CfiBe-wF&B_ri@9OSN2jlDoCjQ?v_m#WsL7VVJ{+3aVMyDwh4OwWDQOIXD?a} zMm0Unr&aYX)%vs&js9-W_$9j{P(ea%s}=z5-HH(v8$m7=+s^!83{-HxSZh-&gZ}B~ zLHj}%*1;ALr3hN1MrTMlI_08vn~H7EL$*S=#UKR<70+&w(IKfAxG!|6b5iZA9|Wyx zqobniq^ftRRg6cgpE)W>sFs1<-o^7k0$q4cdJNnz9-*qW?Y;rVKm`d}QAg)zXL#cx z(4}f^yN`neDoCh0*zS2DfeI3|29J))&TYp RFI%Gcof6V(Z@xgOVz=4 zPZ$YQkf1eq6vLit5Ep?iRR`NWdL&RmLe;4DI0F)>AVI74=&0;j7I6{iQZ?%L5f%%v z%Mw(OpjCSm!=5dIF_1u)s!_j>`#=m-kf4 z(M2n`h_EvzNT5P%$;d^y8{AXeM}-8fb|YHNT&5`Y8-@Eqm-@SBn*kLh)V7-I?1~{b z>@%l*p-Ua1Jr9}u>ZnjrSApgT VJpP<`W*L#E_FBU8NYN$sy;^o zUFzwv+i(~I6(rPCY0vn@7)YQ?y#;n#6l0)*gnGN|8NV0<33REq*=|!KfeI3;#IRfX zcvPq$p-LiqjxfeR0$r-)vwJKU0~I7xiE7Vhz!*rNOO@c?`)XDYyJd+A5~^nSF)u?s zB}kx4)kNQWkPrhEBvcJ&&o99 }{4z_#3NT7m*s;TXHGI0^;QY{0!M~?(5NT~LT zJ i+B3G|BG9E; zwf6WG5~v`d+S>N08WN}=p<3kjT(!6ebg3SIJvxX4DoChahCSylE&^SuFJg~!B7q7L zs>foFy&{1M5~@dL_c>+X*&5mY-gge68j|V{S tOUAfn_{@ekS&77kWoQG))H0>*En^o1iEOO zVz|b_ErQ0vQ9(l15>^b?czdh_x@enXxW@FIbU$cJ9~C5IEn(jeE7Kse0I?G2qHXJb zsN94_P;LShBxEgN#c<_6VkOW;+Z4l ;V0tr(QmM+FIWZ(Q>LtQhVDy40O@%{8!M(Ch?MkWkN 2o)q$UE-RzWW{hN z(4}f3*W4(o)uL!_6e>ulI?*-%O4WlJfi6{}y5@LUF=&n#DoCih-8GNQis4S6OSKGK zbJeUEG*=B3Bvc#4H6PB3;ZC4SwQyW>?yMLz=MEJlR9nk6ug{9%PM}M*)Le51snr)n za|cmDLbVxP^AlA&Q6tc$TAZ#qkgD}cGa9WDnG_^c+t(hamRaOj33REJvrWjHFq7wD z&E-S|3Dt&o%@>W8K$q$PxaOo<1kFiB1qsy`am{;;l|Yy3$++g$S_I9lMFk1fr*h5T zjg>%`>XEtT2y?ntG)EW}BvjwfH4iyf0$r+S>6+_o5#Q%JTlbt4B&f$qwX;344LxC5 zru v2`ZyvyN25?dLfv!EQD-F3 zrJgs>F=TbE=nkr@K$&qB&vnn$)s_GM@suE;u86(9R@&r0)sFi?pi5m5&zvwS>YOM? zi?Kbj0M=Ec<3&PUg;;Y%7C{OU>RQE`!LkTakf7CvbO${%ytprPVHu;J2P#O=>O+d* znc+nOU07D?F;GE*Rv%If&wMQs=)y8zkAVsjwEB=@c;;)7Ko{0sdJI&Mz#3I2P(gy$ zn9xyqWO$>HKo_ QO(0$tcI z(PN;31g&tyuTLYqP(gy$`O#6?>j$l({d--D1iG;Is>O&x z1qtj^YlIgnNYDyLIx5fHHWKKmbc6e_V1YAoYNt)>W4kWkyMY *P&KvZ%mLgNx>U;`)~vo2gA^oGi^DZ@z;(}YU+7XT zoLFZkSTRUJLbak|oikw(q#&VMXP$Ena9`+Bt-)C5epoR`K|-}MJ?9$WzR;ywwXPW{ zUaAyD1qsz!_MAC@1iDn~+_P7N3KFW-Z}-)%Ou4j*jw)T6Y%68Y#H2nl^-N`-!`i0) znfkkzS58x_apd{f1Sv?!*cR=1R7jvpo}Wc{T&pNl A^aUT^D@>#HG&*uvF zg)aH*T7>J_jrx8Eqfn7gzU88K1@{#9Q6VAUO^fz?&v9Sq^749Z5wYEm9|{t(jIjvM z@(TBbE?M%~F|74C@ wO(*f zaUT^DvQ1>sp6x5#7rJCi%_3Z_KG#v9qP8i!gnNqnsE| poY{~2LYI1*J?H5ofvz8=QdF?}QK{M>Di{e>u6WMV$9 2pmCAh~Ndii|)I1hBGnj!WX@pM$As2Ybx{&7!n9~Ba+w)2>+t{oM+ zR4p3oSf5n_kfLg58Y$F|s_yWcRtsQ5g2j1JiH(SDB-K??ham0zI@5|R4_klFqfkLY zZimn{lqHDUbLB?3ToaP6`o8TJ!wVJMuloC>;)(R5auK8;A>V@UF>?No#1B`#3M)CC zN8fpagnrLa!Tri#e+stGe9`|%{BTLa_(8}!n4iks?|0BTUb<8A4qDsa@A>!haKE~E zwn$L>kz#m0S4f~sK9v^X`Rt-1pLWaj{r&hq92FAOo~NVod rfi77aSTU&1 zz^76tP(ec0J{Ik{UJVI!(P#qQ!C31-mm+IUYn$p<+*8~~g@mlBE!v*RA Y9SbvYT&EQgGE5q8Rw#fhOsF0AY8jJR5Ek)tJ&;@@t<2-W{3E4WcV%T$v z=)7Y&a}!-^+tp4~gf*T>cZw7hgW8|}e@8`xx(cq5I~7Cz-}Q`|=u-E?)uvWN6e{Wt z(%2jBDeh~9gnD8;$Kh~a=u*$FtN-CTDpb_dPW>9(Q`|>|gnGL?$H;JB=u&U9$B0)H zDym$eJ|ONX?xR9Nm0hv*Qh(e*bp0rmVi`5Co>A(*BB4rD&(SE{7rIml?$JN@LPgaE z)bGbVxu3aef8;cgP_>WeND=M}U8=Tojb%iscMug-bJ7?I?kVo0LPFKlcK%$B;#e(! z@1r=btBWob!*g~5wkEv12AcHFdwCrR!S;%NRHz{F{`v*0UD7V~QoFU}I&)Nzz_y(p z0~I9Xv-|xB?G~9T*S+LF`wmjuM{Ub1A57bnV^OVFoluH9;pLTI{<3wL)fSDLP%fgU zcgZc(n2b)?SJXZdFR!N3rMBaWf&0~orsDeG`2WjMsUuNmuI{<(sG?B8W2bHVXzjPw zBBY!C*;kSFg)X)2iowb2KAtFD>aLlzO; fKLFXa=MY$8oMSoX!) zgK*t*>+0Hw9)phL`;%{7UHi-r7aGr=M)l#@-j`*WoYB1W2OqKh(EvejaIteff_f?E zGsf^&7kxwy$3WTtIP-ZPdzP;k_nu#qp@PKYWh43hrat12*+V7q>cklKPu)Je(UuT~ z3KGB0O2;!qq!J^3?I($CMJKX9PDgSU%rydCyMD>dCZ|p<7N71biK1J_vs%%S+&8!? zLj{Q@$?nG#DV0{FJl{nUM`|r#!zT9P`;J%C2y~6Q)Xus2MOtzGVP{E<{^u9AYeX;p zbA}Lx3KDlpo6dGcGKfdVn@b{d$ZEEyL TLOPxoQT3t>OJNF-D;z=hy@LVyCK-Z|tON_CF{Y8-)WhF7V z*k3F%X=fh&M`?x%5-(yl83RY<6`@6oN+KrWH2bMvM}DJnF^xc1sUe23bW=VtdS_ut z?7wu1h0N>7`@Am2P(fnd>1)Qf;e|y|^Q@9cUi${S-K!nHHMM|7plfQTr^cd_Ma1LX znI(~U>vgvI*LM7E_M!|GBo {Avxvlai^xiCWoi5WwSn9n;@l277&N%&QG&5opM$ 4EycJg?09$Y9NLj{S5ZB@;J5tYT7Uh?-ELa*%l%Hq{;#_ui6 ztr6(TR=lRUu38o040B52=ht7@t;l9P@oImD3KBaKH89&Gt1eQc*&vByr{nQdADi-n zC39*7x~|S>Yz9B6E?VAND~UtB;_*pY!}*)?xfv=*JQ&!@Y_PMYxI1xyBz%W?@nj3a zdDR_xGy+|R-?T6X?5ZJ3wO=oZ CveO2cLmxDdF szF z9Mq66nwXBEf<(gVJ dEJ-*Z)AE%eQEMb8OZy@i}E3N%U@%lvh03kmu=>o}q%o z=&@bRpBvQ`x90biM3R6M{6Lv7ez<2UjX+oS7LjK0Obx_fzXp={v@I!ry0{@PIUp@V z1&M@*I+&xHg^G&<21%mim=wH7@-Tj_ua8EctKh^Q=H>MD#rtecByrv^8BZy%RfR#m z3>73sCu(C(Y+Xm3Xf#X`EAFP?iEcLFUk4=D2y~U*+1VVGrk>~@&_WVl1|;JP>o(+N z2Bu=DAd#qQb8}{g+VWbBkVJ=SKD@@(2E5?Fq#A*)%J _MJS0bGNS94kSM|sx~&ntG9M9trl^Zlp7 z_|A4o87jEn#FcB98wS>p*J_d^iXTtOyML?CA2*Aq5$NjsdtLKvyjtRM&3=+-)Gh^| zFd&Rysgj7Hf<%&i%-lP&n%Gxrx+L~bPsN)quFnJeehfkaT~j_*HS>O|A?}9{m&Ek^ zKKydlFy0|^JcbGqNkYn)$0k=1JALO!BC>I6zCXA=PoDX|AdLXmipG`9L9eTc$gQI# z@$tD2U+QeYyBzuugbME0+bCpSo-4$L=kp~|I-4*5<9a=wbmc>hKv&G>lIHQJRmAh% zR5DE6nUY^_-GJ9+FN07)!kgzdA1n$M%TD|*3GchU{Mvwe{9Wc-IsvYWDGQi6?h6sI zpUSK1BUAD2U+VL6zdi^;1^1gYCZqXw4HNU%Qs4RE`ZPQuRXskp-zAMe*NbjB%sw}R z#p9#1B$201YW~wN_4&0MH-b<>B3V!>Gtst6V%mgNk~lLUEe~E+m#^M_N+Zyf^l*Cf z&}AmNp7})*Su*+Z4#n&9&)ye