From 0ef71262b9fe1968c341ef70ba9b2f95af63d979 Mon Sep 17 00:00:00 2001 From: Rebecca Zhang <168459200+rebeccazhang0707@users.noreply.github.com> Date: Sat, 29 Aug 2026 08:37:15 +0800 Subject: [PATCH 01/13] Optimize heterogeneous bin-packing view (#7333) # Description This PR reduces the number of physics views created by `scripts/demos/bin_packing.py`. It keeps the existing scene configuration, clone combinations, and randomized layout flow, but changes each grocery slot to a spawn-only `AssetBaseCfg` and manages all spawned groceries through one flat PhysX `RigidBodyView`. This is a demo-only performance optimization and adds no dependencies. ## Type of change - Performance improvement (non-breaking change) ## Testing - `uv run isaaclab -f` - `uv run python -m py_compile scripts/demos/bin_packing.py` - `uv run isaaclab -p scripts/demos/bin_packing.py --num_envs 2 --visualizer none` The 2-environment PhysX smoke test completed scene setup, created one flat grocery view, and ran without errors. ## Screenshots Not applicable; the rendered scene and randomized layouts are unchanged. ## Checklist - [x] I have read and understood the contribution guidelines. - [x] I have run the pre-commit checks with `uv run isaaclab -f`. - [x] My changes generate no new warnings. - [x] I have tested the updated demo with the PhysX backend. - [x] Documentation changes are not required for this internal demo implementation update. - [x] A changelog fragment is not required because no source package is changed. - [x] My name already exists in `CONTRIBUTORS.md`. (cherry picked from commit 9c209c05702a5b17f6e5029c937cf80ed84ed9b6) --- scripts/demos/bin_packing.py | 155 ++++++++++++++++++----------------- 1 file changed, 82 insertions(+), 73 deletions(-) diff --git a/scripts/demos/bin_packing.py b/scripts/demos/bin_packing.py index ea14401cb6c7..3f8cf063e2a0 100644 --- a/scripts/demos/bin_packing.py +++ b/scripts/demos/bin_packing.py @@ -54,6 +54,7 @@ from random import Random import torch +import warp as wp import isaaclab.sim as sim_utils import isaaclab.utils.math as math_utils @@ -175,18 +176,18 @@ def spawn_grocery( return prim -def grocery_cfg(slot: int) -> RigidObjectCfg: +def grocery_cfg(slot: int) -> AssetBaseCfg: """Declare the grocery that occupies one slot of the spawn grid. Args: slot: Index into the spawn grid built by :func:`grocery_spawn_poses`. Returns: - The rigid-object configuration for that slot. + The spawn-only asset configuration for that slot. """ - return RigidObjectCfg( + return AssetBaseCfg( prim_path=f"{{ENV_REGEX_NS}}/Groceries/Grocery_{slot:02d}", - init_state=RigidObjectCfg.InitialStateCfg(pos=tuple(grocery_spawn_poses()[slot, :3].tolist())), + init_state=AssetBaseCfg.InitialStateCfg(pos=tuple(grocery_spawn_poses()[slot, :3].tolist())), spawn=sim_utils.UsdFileCfg(func=spawn_grocery, usd_path=GROCERY_USDS[slot % len(GROCERY_USDS)]), ) @@ -248,30 +249,30 @@ class BinPackingSceneCfg(InteractiveSceneCfg): ) # grocery slots, one per object that may end up in the bin - grocery_00: RigidObjectCfg = grocery_cfg(0) - grocery_01: RigidObjectCfg = grocery_cfg(1) - grocery_02: RigidObjectCfg = grocery_cfg(2) - grocery_03: RigidObjectCfg = grocery_cfg(3) - grocery_04: RigidObjectCfg = grocery_cfg(4) - grocery_05: RigidObjectCfg = grocery_cfg(5) - grocery_06: RigidObjectCfg = grocery_cfg(6) - grocery_07: RigidObjectCfg = grocery_cfg(7) - grocery_08: RigidObjectCfg = grocery_cfg(8) - grocery_09: RigidObjectCfg = grocery_cfg(9) - grocery_10: RigidObjectCfg = grocery_cfg(10) - grocery_11: RigidObjectCfg = grocery_cfg(11) - grocery_12: RigidObjectCfg = grocery_cfg(12) - grocery_13: RigidObjectCfg = grocery_cfg(13) - grocery_14: RigidObjectCfg = grocery_cfg(14) - grocery_15: RigidObjectCfg = grocery_cfg(15) - grocery_16: RigidObjectCfg = grocery_cfg(16) - grocery_17: RigidObjectCfg = grocery_cfg(17) - grocery_18: RigidObjectCfg = grocery_cfg(18) - grocery_19: RigidObjectCfg = grocery_cfg(19) - grocery_20: RigidObjectCfg = grocery_cfg(20) - grocery_21: RigidObjectCfg = grocery_cfg(21) - grocery_22: RigidObjectCfg = grocery_cfg(22) - grocery_23: RigidObjectCfg = grocery_cfg(23) + grocery_00: AssetBaseCfg = grocery_cfg(0) + grocery_01: AssetBaseCfg = grocery_cfg(1) + grocery_02: AssetBaseCfg = grocery_cfg(2) + grocery_03: AssetBaseCfg = grocery_cfg(3) + grocery_04: AssetBaseCfg = grocery_cfg(4) + grocery_05: AssetBaseCfg = grocery_cfg(5) + grocery_06: AssetBaseCfg = grocery_cfg(6) + grocery_07: AssetBaseCfg = grocery_cfg(7) + grocery_08: AssetBaseCfg = grocery_cfg(8) + grocery_09: AssetBaseCfg = grocery_cfg(9) + grocery_10: AssetBaseCfg = grocery_cfg(10) + grocery_11: AssetBaseCfg = grocery_cfg(11) + grocery_12: AssetBaseCfg = grocery_cfg(12) + grocery_13: AssetBaseCfg = grocery_cfg(13) + grocery_14: AssetBaseCfg = grocery_cfg(14) + grocery_15: AssetBaseCfg = grocery_cfg(15) + grocery_16: AssetBaseCfg = grocery_cfg(16) + grocery_17: AssetBaseCfg = grocery_cfg(17) + grocery_18: AssetBaseCfg = grocery_cfg(18) + grocery_19: AssetBaseCfg = grocery_cfg(19) + grocery_20: AssetBaseCfg = grocery_cfg(20) + grocery_21: AssetBaseCfg = grocery_cfg(21) + grocery_22: AssetBaseCfg = grocery_cfg(22) + grocery_23: AssetBaseCfg = grocery_cfg(23) # A slot claimed by some layout but active in none of the layouts the environments # actually receive is never spawned, which the scene rejects. Environments cycle @@ -297,30 +298,33 @@ class BinPackingSceneCfg(InteractiveSceneCfg): def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene) -> None: """Runs the simulation loop that coordinates spawn randomization and stepping. - A heterogeneous scene gives each grocery slot its own physics view whose - instance count is the number of environments containing that slot, not - ``num_envs``. + A flat physics view manages every grocery present in the heterogeneous scene. Returns: None: The simulator side-effects are applied through ``scene`` and ``sim``. """ device = scene.device - # Resolve each spawned slot once: (asset, env ids owning an instance, world spawn poses). - # The spawn pose is the asset's default (init-state) pose, which carries the jittered - # grid position sampled by :func:`grocery_spawn_poses` at scene-config build time. - groceries = [] - for name in GROCERY_NAMES: - asset = scene[name] - prim_paths = getattr(asset.root_view, "prim_paths", None) - if prim_paths is not None and len(prim_paths) == asset.num_instances: - env_ids = [int(path.partition("/env_")[2].partition("/")[0]) for path in prim_paths] - else: - # Homogeneous fallback: one instance per environment. - env_ids = range(asset.num_instances) - env_ids = torch.tensor(list(env_ids), dtype=torch.long, device=device) - spawn_pose_w = asset.data.default_root_pose.torch.clone() - spawn_pose_w[:, :3] += scene.env_origins[env_ids] - groceries.append((asset, env_ids, spawn_pose_w)) + physics_sim_view = sim.physics_manager.get_physics_sim_view() + root_view = physics_sim_view.create_rigid_body_view("/World/envs/env_*/Groceries/Grocery_*") + if root_view.count == 0: + raise RuntimeError("The flat grocery view matched no rigid bodies.") + + env_ids = [] + slot_ids = [] + for path in root_view.prim_paths: + env_ids.append(int(path.partition("/env_")[2].partition("/")[0])) + slot_ids.append(int(path.rpartition("/Grocery_")[2])) + env_ids_t = torch.tensor(env_ids, dtype=torch.long, device=device) + slot_ids_t = torch.tensor(slot_ids, dtype=torch.long, device=device) + env_origins = scene.env_origins[env_ids_t] + spawn_pose_w = grocery_spawn_poses(device)[slot_ids_t] + spawn_pose_w[:, :3] += env_origins + + all_indices_t = torch.arange(root_view.count, dtype=torch.int32, device=device) + cpu_indices_t = torch.arange(root_view.count, dtype=torch.int32, device="cpu") + all_indices = wp.from_torch(all_indices_t) + cpu_indices = wp.from_torch(cpu_indices_t) + print(f"[INFO] Grocery RigidBodyView: 1 flat view managing {root_view.count} rigid bodies") pose_ranges = torch.tensor([POSE_RANGE[axis] for axis in ("roll", "pitch", "yaw")], device=device) velocity_ranges = torch.tensor([VELOCITY_RANGE[axis] for axis in ("roll", "pitch", "yaw")], device=device) @@ -335,24 +339,22 @@ def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene) -> count = 0 # Retrieve positions with Timer("[INFO] Time to reset scene: "): - for asset, env_ids, spawn_pose_w in groceries: - num_instances = len(env_ids) - # Only randomize the [roll, pitch, yaw] rotation; keep the spawn position fixed. - noise = math_utils.sample_uniform( - pose_ranges[:, 0], pose_ranges[:, 1], (num_instances, 3), device=device - ) - pose = spawn_pose_w.clone() - noise_quat = math_utils.quat_from_euler_xyz(noise[:, 0], noise[:, 1], noise[:, 2]) - pose[:, 3:7] = math_utils.quat_mul(pose[:, 3:7], noise_quat) - asset.write_root_pose_to_sim_index(root_pose=pose) - # Only randomize the angular velocity; leave the linear velocity at zero. - root_velocity = torch.zeros((num_instances, 6), device=device) - root_velocity[:, 3:6] = math_utils.sample_uniform( - velocity_ranges[:, 0], velocity_ranges[:, 1], (num_instances, 3), device=device - ) - asset.write_root_velocity_to_sim_index(root_velocity=root_velocity) - # Vary the mass so the drop behavior differs between resets. - asset.set_masses_index(masses=torch.rand((num_instances, 1), device=device) * 0.2 + 0.2) + shape = (root_view.count,) + # Only randomize the [roll, pitch, yaw] rotation; keep the spawn position fixed. + noise = math_utils.sample_uniform(pose_ranges[:, 0], pose_ranges[:, 1], (*shape, 3), device=device) + pose = spawn_pose_w.clone() + noise_quat = math_utils.quat_from_euler_xyz(noise[..., 0], noise[..., 1], noise[..., 2]) + pose[..., 3:7] = math_utils.quat_mul(pose[..., 3:7], noise_quat) + root_view.set_transforms(wp.from_torch(pose.contiguous()), all_indices) + # Only randomize the angular velocity; leave the linear velocity at zero. + body_velocity = torch.zeros((*shape, 6), device=device) + body_velocity[..., 3:6] = math_utils.sample_uniform( + velocity_ranges[:, 0], velocity_ranges[:, 1], (*shape, 3), device=device + ) + root_view.set_velocities(wp.from_torch(body_velocity.contiguous()), all_indices) + # Vary the mass so the drop behavior differs between resets. + masses = torch.rand((root_view.count, 1), device="cpu") * 0.2 + 0.2 + root_view.set_masses(wp.from_torch(masses), cpu_indices) scene.reset() # Write data to sim @@ -360,14 +362,21 @@ def run_simulator(sim: sim_utils.SimulationContext, scene: InteractiveScene) -> # Perform step sim.step() # Bring out-of-bounds objects back to the bin. - for asset, env_ids, spawn_pose_w in groceries: - xy = asset.data.root_link_pose_w.torch[:, :2] - scene.env_origins[env_ids, :2] - stray = torch.nonzero(~((xy >= bounds_xy[0]) & (xy <= bounds_xy[1])).all(dim=-1)).flatten() - if stray.numel(): - asset.write_root_pose_to_sim_index(root_pose=spawn_pose_w[stray], env_ids=stray) - asset.write_root_velocity_to_sim_index( - root_velocity=torch.zeros((stray.numel(), 6), device=device), env_ids=stray - ) + transforms = root_view.get_transforms() + transforms_t = wp.to_torch(transforms) if isinstance(transforms, wp.array) else transforms + xy = transforms_t[:, :2] - env_origins[:, :2] + stray = torch.nonzero(~((xy >= bounds_xy[0]) & (xy <= bounds_xy[1])).all(dim=-1)).flatten() + if stray.numel(): + stray_indices = wp.from_torch(stray.to(dtype=torch.int32)) + corrected_transforms = transforms_t.clone() + corrected_transforms[stray] = spawn_pose_w[stray] + velocities = root_view.get_velocities() + corrected_velocities = ( + wp.to_torch(velocities).clone() if isinstance(velocities, wp.array) else velocities.clone() + ) + corrected_velocities[stray] = 0.0 + root_view.set_transforms(wp.from_torch(corrected_transforms.contiguous()), stray_indices) + root_view.set_velocities(wp.from_torch(corrected_velocities.contiguous()), stray_indices) # Increment counter count += 1 # Update buffers From 44141a283cbd62f225bf8add7655fcd23e7150b3 Mon Sep 17 00:00:00 2001 From: Rebecca Zhang <168459200+rebeccazhang0707@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:14:24 +0800 Subject: [PATCH 02/13] Add PhysX-only heterogeneous multitask manipulation environment (#7375) This PR adds `IsaacContrib-Multitask-Manipulation`, a contributed manager-based environment for training and evaluating one RSL-RL policy across three heterogeneous manipulation layouts: - OpenArm cube lifting - Franka drawer opening - UR10 end-effector reaching The environment uses selection-aware physics-view indexing so that each asset only exists in its task subset while observations and actions remain fixed-width in global environment order. - Adds `SceneEntitySelectionCfg` to resolve task-local assets and map physics-view indices into global environment order, enabling heterogeneous scene subsets across reset, observation, action, reward, command, and termination terms. - Supports multitask training, task-subset evaluation, and single-task evaluation. - Adds a shared observation backbone with task-specific Gaussian policy and value heads. - Masks inactive action dimensions in sampling, entropy, log-probability, KL, and last-action observations. - Adds bounded targets, task-specific timeouts, lift and cabinet success metrics, and playback visualization. The three tasks share manipulation structure but differ in robot state, action semantics, and reward scale. A fully shared policy with one padded action distribution would train irrelevant action dimensions and allow the task with the largest reward scale to dominate the update. The design therefore shares representations where transfer is useful while routing task-specific outputs and normalization where the contracts differ. | Challenge | Solution | Motivation | | --- | --- | --- | | Heterogeneous scene ownership | Add `SceneEntitySelectionCfg`, which maps global environment IDs to each asset's physics-view instance rows and scatters task-local values back into global environment order. | An asset exists only in its task subset, so global environment indices cannot directly index its compact physics view. Explicit mapping prevents incorrect or out-of-range indexing and zero-fills environments where the asset is absent. | | Varied observation dimensions | Concatenate a task ID and zero-filled task blocks into one 95-dimensional policy observation: `3 task ID + 28 lift + 23 cabinet + 19 reach + 22 last action`. Process it with a shared feature extractor. | Gives RSL-RL one fixed observation contract while allowing the backbone to reuse features across tasks. | | Varied action dimensions | Use task-specific Gaussian action heads with dimensions `(8, 8, 6)` and task-specific scalar value heads. Mask inactive action dimensions in actions, log-probability, entropy, KL, and last-action observations. | Prevents unrelated joints from contributing policy gradients while retaining shared actor and critic backbones. | | Varied reward scaling | Normalize PPO advantages independently for lift, cabinet, and reach rollouts. | Prevents a task with larger reward magnitudes from dominating the shared PPO update. | No new dependencies are required. Train: ```bash uv run isaaclab train \ --rl_library rsl_rl \ --task IsaacContrib-Multitask-Manipulation \ --num_envs 16384 \ --seed 42 \ --run_name multitask_manipulation ``` Evaluate a specific checkpoint: ```bash uv run isaaclab play \ --rl_library rsl_rl \ --task IsaacContrib-Multitask-Manipulation \ --num_envs 3 \ --checkpoint logs/rsl_rl/multitask_manipulation//model_3000.pt \ --viz kit ``` multitask_manipulation_eval_compressed - New feature (non-breaking change which adds functionality) - Documentation update ![Multitask manipulation environment](https://github.com/rebeccazhang0707/IsaacLab/blob/reb/multitask_manip_demo/docs/source/_static/tasks/manipulation/multitask_manipulation.jpg?raw=true) - `uv run isaaclab -f` - `uv run --extra test --frozen python -m pytest source/isaaclab_tasks/test/contrib/test_multitask_manipulation_env_cfg.py -q` (`18 passed`) - `uv run --extra test --frozen python -m pytest source/isaaclab_tasks/test/contrib/test_contrib_environments.py -q -k IsaacContrib-Multitask-Manipulation` (`1 passed`) - `uv run python scripts/environments/list_envs.py --keyword Multitask --show_presets` - `uv run --isolated --extra test -- make -C docs current-docs` - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html). - [x] I have run the pre-commit checks. - [x] I have made corresponding changes to the documentation. - [x] My changes generate no new task-local warnings. - [x] I have added tests that cover the new behavior. - [x] I have added a changelog fragment under `source/isaaclab_tasks/changelog.d/`. - [x] My name already exists in `CONTRIBUTORS.md`. (cherry picked from commit 13296b112817ba89c0ba5eec1671fcf3fb07afe6) --- .../source/_static/css/environment-browser.js | 1 + .../manipulation/multitask_manipulation.jpg | Bin 0 -> 83192 bytes .../multitask-manipulation.minor.rst | 8 + .../multitask_manipulation/__init__.py | 22 + .../multitask_manipulation/agents/__init__.py | 6 + .../multitask_manipulation/agents/models.py | 459 ++++++++++++++++++ .../multitask_manipulation/agents/ppo.py | 108 +++++ .../agents/rsl_rl_ppo_cfg.py | 2 +- .../multitask_manipulation/mdp/__init__.py | 10 + .../multitask_manipulation/mdp/__init__.pyi | 79 +++ .../multitask_manipulation/mdp/actions.py | 130 +++++ .../multitask_manipulation/mdp/actions_cfg.py | 2 +- .../multitask_manipulation/mdp/commands.py | 103 ++++ .../mdp/commands_cfg.py | 2 +- .../multitask_manipulation/mdp/events.py | 119 +++++ .../mdp/observations.py | 112 +++++ .../multitask_manipulation/mdp/rewards.py | 364 ++++++++++++++ .../mdp/terminations.py | 101 ++++ .../multitask_manipulation/mdp/utils.py | 47 ++ .../multitask_manipulation/multitask_env.py | 61 +++ .../multitask_env_cfg.py | 2 +- .../multitask_manipulation/selection_utils.py | 2 +- .../test/contrib/test_contrib_environments.py | 3 +- 23 files changed, 1737 insertions(+), 6 deletions(-) create mode 100644 docs/source/_static/tasks/manipulation/multitask_manipulation.jpg create mode 100644 source/isaaclab_tasks/changelog.d/multitask-manipulation.minor.rst create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/agents/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/agents/models.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/agents/ppo.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/__init__.pyi create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/actions.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/commands.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/events.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/observations.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/rewards.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/terminations.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/utils.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/multitask_env.py diff --git a/docs/source/_static/css/environment-browser.js b/docs/source/_static/css/environment-browser.js index b40b0f503ec9..e2aef9ecfdf1 100644 --- a/docs/source/_static/css/environment-browser.js +++ b/docs/source/_static/css/environment-browser.js @@ -91,6 +91,7 @@ ["IsaacContrib-Lift-Cube-Franka-IK-Abs", "", "", "", "", "tasks/manipulation/franka_lift.jpg"], ["IsaacContrib-Lift-Cube-Franka-IK-Rel", "", "", "", "", "tasks/manipulation/franka_lift.jpg"], ["IsaacContrib-Lift-Cube-OpenArm", "rl_games,rsl_rl", "", "", "", "tasks/manipulation/openarm_uni_lift.jpg"], + ["IsaacContrib-Multitask-Manipulation", "rsl_rl", "", "", "", "tasks/manipulation/multitask_manipulation.jpg"], ["IsaacContrib-Navigation-3DObstacles-ARL-Robot-1", "rl_games,rsl_rl,skrl", "", "", "", "tasks/drone_arl/arl_robot_1_navigation.jpg"], ["IsaacContrib-Navigation-Flat-AnymalC", "rsl_rl,skrl", "", "", "", "tasks/navigation/anymal_c_nav.jpg"], ["IsaacContrib-NutPour-GR1T2-Pink-IK-Abs", "", "", "", ""], diff --git a/docs/source/_static/tasks/manipulation/multitask_manipulation.jpg b/docs/source/_static/tasks/manipulation/multitask_manipulation.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8c1a17192262f954b18ed3a347d92a83b0c393ea GIT binary patch literal 83192 zcmYg%byO7Z8}BSjcPQZ!t4kPwaE#s9kkfQZ1vq$FhY00?}2L_$hQMn(iCB_RP_k3r19c$-9+ zN#ThbF3^HhFJ9!+jXObPWuNz7!$cnqzi)H@^7N)9ckoiVq8O^;5A(D3%8?Ewax0$E z4<6aS(7fWF450tUCm|&!1rc2jdHqBnA_igbZG|U{7Q|(4BEz^qCXzd!EJ^kDm)c-t zHy-^#y%vqX`8kL?cx3dd6<{DIVkTw=gBSrKMgYtRTulSi;Oi%205bscz`O!@*!8Tn z(z6i=iOeE8sRQj#2K26R=|Mmq!1taRE(;&o$JjSRVIt=WGJ$nS7RrJYFm>uU4X%gD z%I>Uoew(i;>!$G+99C*reSF{j3&zNw!KulPsNHTwDq`_0#;WMh$}V6Janr>r+Rif> z)oW+sJKp_LW(;KiWnN+%?e0Hd2;p0wUuE?FVmk7*b*OcTTj>;r3HKQmZ;=A1qhZ@C z5m`0CzntYpR+qP4Q07$y=DyG*JA2)Zuk?E%_k#JTk_daBQAQMS5B807oc|PG!*STsJ(p^VX@)?|^46(m2F+T(FH|MB zk<8?^VfYE2D`3NTJnSYGN&512W9knp@RDQVS&8pQ7geCJCfcnOnxQDViDbS4Y(0y3 zqfS)u)&KUJ-p-}Ae!Zg{$H5-e+~?>H+HnPsWBENCy(=V~VG;2LnbRE5a4#wFw2|ZX zoquj+%QYS^{Zv7H?6-(<%49_AsJivI^4-ICfrAnA+gY@{GPb2*;d?_^wiFa67`9In z-RT+=h~bN2^dC;b!%0UQRzH>=)#U&{-AtzXgQtjL5s7+KxX;m2*oiej62@(uO!tY& ziffp#mPZx2Vt3Y04w$Ug*n_jDw3Y)av>{H^pp4upK>56VsRWHNS_}AaQ&@5`UPlc{ zNA>MJhAO3F>_nW-5_lk+$0wY(h4_tnDQoXK^&K$SOioXWhwG7i_o*9|B z9|3kY%_o|DHbC^JpKCkYDPmQrKVdF#>Dd(9?e>mFbo`*-oaS}Xv6_2Va66s$G)Lf% zT;k=*e?Dhpp#-ir7 z_7rZ8CGS0<=GlchvPOs55Oa^e~uW3+a8&`e+ePM zTp9WT)10eHZ*tt#0f`^t9WPG>;bb3)$XvZzKfKlmn@cFZ0-`>q5}f;N2ar@QwD*>+ zLwUnEZx_+eWZ~~RT~<&6iR|xrQ%>QZ8c$-azMMGa!3ORE-glsqhN-~;?OO|#V^QGp zM_4eEvomtz_RURJijxk~kv4+jk_y4^F~CnNA9x{Y>fB(+Z&1;6?u?}fwHx4VKs&`S z)?5LhzxMPXXebprUh<>r`pY#Z|8)(%Oc?8*eN#_^svrQ-W=_S;yv(%!(IevG%TFtW zF32do6?jQUwEbwoa9AC*{q}{cfNy;eQ1(amdx=YhOpS%}rU=5uQHQUX$}-+u)2&$- zzKF`M(PsVt=uzVK)Ol(??4SE|8s}$^541Jf(^M~1N5^u3S>LZ_ftX!u%U-x}yw6Ty z`&PLdRW4C&w=Cx{M55llsWIazychioHq9%pm>&W%vn)TV?#amuJ%JR@uc?R{1t78a z-N1E5=*gH>b(2u%SWlh9Tq`=X^$>{`D%)uiuQuN$bp`w;x$!xV_CS9yEF_k$^$n*o z)1)r3!+qB67$V6%Lr7TLL%QSpGq$!kBr_>hZM~`*I(xOt^kLfi)FzI65lcI^XuK19 zEELK)_oskEABm;j)$n7Q{q@px8kGq5-=H}_GTq2UuAWP}`!PY-nD82XZfpC!j7IPk zEY1Az?aqtskR1t;04$X6N%`0C~3sRH$>f z`k-rhQutQz0hF?wx4rX#xkZ1>YQV^E&)r5($WtX;eicfDD*W6ZYA`UU;pxBUTfSKn zrcC7LDknA6Ygo5qe-OQ$%_SFBB_M9nq>O2d-r5O4$8)PA5AVGz{t~i&YVhJ>x z<_eQo>UXwOM*1_6O(&jsZtBywplUmyTe4${2f^V$G|sxR<8E1FeE!__6Tt8eJzL9? z-n&;6q+u+K-fo~{49jN!ZgSetFKOEF(ww36dDW*rrmC2K{)aGm>Zx>VfR4v@ z+VYO!J;maxcB!??~A&r=ED9tTqVXq_nBDy|;#? z*HQc(|KvO?g#Hm=-r*n~(>$vu*>nZedm^N#^^kOalKyvB-Z42?MyH11=FOuPs=E0F z>A|iEU3({co!uS&cmzuUe;t%8Bye5EIKlzDb*Bf_f%28j2Zf2xkjs*;2fs2Q<*mot z{1p>AlEw%fXOVn|S5=?%y_htbwdgo9uAMtm0{A|05iF{zXl2ObC1<85R32ObZ?HgQ zK#+(LWH%Jt-N`i*W5^9F`vmZ+2|Fa=W&7pcLn0QBDox&ez+`*TTQi#Nhsn?F;G*od z$^orlDh;&9A8;p}SH7}SV!1#8Qnu7?vfM5sas`Y$MACl;8<5P?G2&GZcEg^@w)#i# z(c>-9^*)F>S>vuyz z9k>4pyiaCSmfw<=;B39F##q;ysb0)9UbfjCoVxXhp4g*|%;KRP`VJEPq@N*0VK=O$ zt~NFwN`d)IP-|<*dMC*Dru*K!ig1*OiZA2?07X9_7K@&_ce(6;`D3iY?B3G0%N_d`^x&bbLd`PqNsZ|VAMU9+ z6)})~MwX=mkE6wWT#T?b-p_(&Z$#lP%{w4T?SLJVezxL3R;+=B64`k+F%8YgTyEFV zb^^1Gf{RA-`ON>a?u7^AT-V`9ditHjdn~e@V$n;8TdNTi>%>fBSPq=Ci9n;H+aaA> zR7}#+9kV1XJvVi^63N}8n!VldYWV3W+?HYANSDZ?yjg}8GdsxBwX-R8$1%FDm00oZ zByT|4Ne#tWmIo9-aiZEMlY@I5pLEHq-&!yp^O90~h|6nMyv^5dm3beKp$|2jyya6i z@`G>iz2`K=^Qv#2+YpP+9wzDF`4J2eaJ$y7r$TC$Iv(D!*wwu^Wb#Oh&Rxxc+ZCdU z3g_S^lC=ZM5EPJE=q0oA9a}YqZiS8ZuU-8gS$_Ktfk+L`+}65#0!*AZE6b524e_Ue zXR7J`UEMkbv@a&HbZEZ)73MWa>z2SHiXMPpvw8e|@A}Ee6~J)m2Fp){f`HdrsXb<) zl8W;IRJSeM4%s4r2MOqkYWZfNj#=(RL*MQd_gk0@1MGcF8X>gtV_>P|FL2oe<=pQZ zR!gBiI$*IV>W#Usg(tW+;3>OHob#7h7LO;`-P)PR@#6ST2P$E{i}E4e#_WEYap2LQ zbs|cAWq}9F;U}u6Ifq6kLY!-fg(nup-y7dus?IN80Xe~IP;e8!)uL5-25oXb4aa$H z%6y(|8!6dhmn-PpPS_{4FF+h%T1($)wXT5gPBB@=TmjExX`HzRa5eUJW?(1TC#OFuFsFCXpi=_Qf?ijmzu>!>v8Y4YQOw}6UN zOrO%YbyZ2yLb=*YS+za4*y@n?-xtd>w1@uj%Q>A75qX5fDhsJFf2ZPD6UTlgm@b~* zTax%k=T9Nc-v-lZb6ZCOXA$#YGyZROi))P1adkN{0zUk%(-O|`!*sS|D2aaph3&s4 z0e+W)zJOjVr*aSDsEd2?f!V5T@O4t2>D3BUlb@qdP0l3ql^}^I((kPvaT#a2LJ^#h zmEhfDVh}q!;1*-T_lJDmc#U-G!M}V^U5OoSJ2^stTVlsqMzNAxdkhp%H6|QwAI~=R zPs&XmxJ+|BZxU|gJbv5`)V^agq4^!06eHsn6Ud_wU>{&v(CC##mHmkhI?@u*U!3rE z4)Rgq27VE>VK@h4OHG4}AWwBc8oMo2pM+)W7qukS&ZjUI&eHh$JmInd29sq#-||t;$bY=dZBg9leLTx(9`UMMHFVi<=(`p zdRe8oh}r8iL*ze!q+}f>t>^*1EZjWyp4RqUW7UXMwv$Dkfa{J&Ys|P8lvwW^>Oxqn z8&L^4Efrso1rRGazsT;U?x%|d_a~}UjA5BF#)C}b_Dac9E~W&d;4JR%B!9`eRpkSg zh^<}4wd!FiQ(+EV7?+;2Z>$$z(*S(>ml~2Du0qCKHsL(py}mIwdEbmdHo^%>%3I)z zc3B}j&YAA+ytRHnx}e{FewZi5to`^>66 zl)UO<=(s-LC2B_N-?EpKyBA=-rnx6dWjc<1z|79r=)}#Uy4+3LrJ@bv>&9~I4O%88 zudw)`>#zU6!gPAT$L_l?$vId2iX1N8JJPq1MBLUpZvHO@3z*MdNKoK@H>>fHZ%<;O zR>c?vD_nz}pu=a5SZ@bEJ0B?3#pmJycN<^9(o%8K`+?3{7zko!-?0FK&?TwKcGvzB z^S0ym_V+zhtOQ2!kCy~xQ5;sl@y0jXI=%fr$s#qcM=x#{#pGTH;sT#zz*T^Oy=`fS zR#f2Yb!IE zUMHds0{FDVbnIbM3mw$_c|M0IRO~CwMAZY-JZJ`G{zl~nkPcgMbDuqFNR8ktO5hMB z%V9S){&uE}Aj|;>Qzj#>a_K~~KlEH&76OqIw~^p}&c)2*jWGA$?Yp@YMlweLyZ2`g z9k%r4FpgPFmp|@X>CP*F$Sa#We7?{9vhgEVy`z<97O^&lQiL!)Q;XLCcdvUSU8fb8 zI<`wf&|kR1OIxnBZ`7l_8}NvAg@8O;*O6q+U%mKb$Qn>hpGsXYG`fsFIr_sXK+U<- z+%oFc!uRb1ZJV8iYE|DEXlV~%7!A*3b+zjX$U~}i6Mh(Y6z>tx`HuzLmaB?DGVqiQFe_k1Jk(- z9exnk%srF8kg+k4X+qG4Up1A(1f9&{V%xzT6ca*JbFY=?%^`}u-Xk5m>FwPS~^G^KCnj8b;&=0NRQ#yedB?dSE*rP$~ua-uc?l<6)JUeYng z@kPz?Eg_Y(=jK#UVl&&g1GhiK=W|)B;TkYVi0o3Mae&8BjU~VyIQeXziVDxR?z}fa zyPZ)9jN)&0>9{~AB0Xhe+z#W2S7JR5(|^c2_ILQM$xdv)0%+|{t~n+nGXMU+gU?cN zW9q=ySS~#Y(o)mmXY}$+fmSL7HJb?lZ$H0My%ZPQ>I8E}=_BJsKhjP_hf;`#w5Bu1 zTY!ltMfcbEUSpm!8v?I!;^MufHkfG4(zFNvr<9oh+$^Wz54vvZ3z~5QvXjQ#lL!mS z21CHeSjEdoJux~SgX`15aJ&gfzg6S}@ec%8t)<+9IXD?ox%6xun^^6q*Be*Jf$3M< zFJ~EQh7lh}g$FNpSt#fn=vT1Z*TkY?PK-g@q%~^FKgx;aNxYoAPpmr)p>py#1V~xU zR2hr-$;e{q<5;rx0EU}$v6rg^zSZ2xJrmJdwACI~P^A8t08V|TEL&ihyV?vAw43h1TGerVYMT5{PVwV%P9@DiC!q!V9ET2H{lK(;=XH@I3zC`jo`JxQh}7(4+XFg> z8FeFns&2r2abL}6!iCBea2lkCz2hW+2{y87c^~Y3xLWt|I)$)UhI6sZcdB=G6LwMO zsJXh1s_T3aLsrGBwt9uuV&6RLfcVz6*jR%9jmX4eN3cXYcJj6~0~4$k4-fmiFYMAq zzp%`{bE}jZZeT&>bt;@yBN=ckDg;*u$9p!ujx{xjir`tK+DLMQn~#r-+V?Faof{SG zq>yLc124(JZzTku+ez<6B1s5O%D3;JGna+;#hZ47wQkGO+G}tFdTM&(z(nUi{|_Dm z45CXes9YCkyVpH{iimPzW!H(!HiJ?2@hF>$;S+$byIa*U&ts2Qrvja_&#|f8V--1s znGDFr9YBdEhy+FV*a}?NSekI%%YU=q@ih!!D3J6sO?U1zouJCqW)6*oZ2OV)SiN(^ zPX6I9fz4Sm-y^_;DTc3iTexndcH6;z&VOXeO174s*kQ#%!|0?c56e4e%#b2w>6w?9 zoHpIlvBCxFkHOasyaKLyH0-!79?gbM4`V4dj$sX(8u@`}%&RM>#odI=?@8^F6V*;y zomgVK45bx6YAQwsVM{`TwxB$_-_F zy&B4JdbO5lgkwkn$x?NM3o@D**oCZ`+iHEzSiG?OGh0Xw%sqY1tt4%bw6OTtyX~*Q zTiilpY8H}-Y9Do{Bk_O_(PtGCP{7}02Z~$lvyLY{)kaOv*8ogC0F{-)x3{fF^iQPK z*k=VXX$w5_`5vNovFaY%f!$(%QV-YVePhVqGP)X=H=Gqez&YDD2-4M(E#$^ z^I|zmfn7JW5qZ?DJaG~|w32VCc_17Q8`!#b4cLbe101WScZq~29|v0wMual%Np_iT zsnl5+{t(lK?IQ_w^Bzg8^EI3+fPjb73>BwO-7)wldmm>G^{&g@(@I(^cn&@rOP~uH z&!Tj3zQ-(E-8i<(?#h;SO&tJny z;%3FJOG&j5zcrhzHV*BFIjw8mg$?AsOTMfGITct>nriQAH{v`4qnkcm+%H9;daQ(j z0OG)({2W9Dqfnv@QO%)~x?1`03Gw06)=AD~)NJ;1rl*YdD`FGVP@0Im#V)X)K9XqY z)bQQi)r3aPCu(;&%BtE7URkEh4|B}@Gbo0f8#_&HJLtRtibV7T*(!Py$-DoG^iCX$ z8{9)@?)_AJD*?37`v}k&m8mqQMLtYEt7RmFn~ex$6cws8*=@ ze9f$97LYkav~+)b1%y0ytXcVke?UcN<=S}#WZ-1ifrv=EP~j1)51cre_*gxL*BE+G zL`FNcx5Y&y8_NZ}3WPB>Hv#S42AHxj{qrZ1hurVCo)XtFuvZ_{ICrdBbZj9RPpL=d z8tK^$hWmOT*bRod-P3#`H%)Qu)FFKSnU+UJ0WsHopseaA=u@m~dN8yng&VvXFD$Vb zS4c{)>Xxv)pUC`3Mb+Jwvl8UF&U=(B@Sv^>J$xE_d-*74{TV7ssPdY{uaVQe%%AV_ z?F40Q9LT)PP%R9*n@-65?Clt~SQ&Gi)~iZ19O>x#4tfR1h;kXnflngmIMSo63{g>N z=IP5E2l4ymRMzjOQB}eBfe&^(D!aGy+erP;9tQ4MqYz5e(SM4D{? zOY&{U;Ji1xAs~I~ZL5e(!_H&YROshqf!=XyzB<5#T=x$JeS$sg8G(DMMAaZ}STo zO9zKL&b3>knwfqFcJv|Xd$gBrmDdL))4fjPE&0lov)7&)(D(cZoqa0mvAa!!Rh8G6 z#*#mph^Ac1M5h=T{WP&xn($5X}!jI$B3xvJV#D(IfD3ByUP{)m3sqakL zTumNiVcmAI@XxrVAnKYV{Z4j!=T~_*>KSArRx5^sbxRFSR<2L|=Mv zZ?_v&U^uwd4M|Lk5)l|@njHuL&&(5cXV%o-Hdg{K=X76VPCBe}9eBZ6-6PjX{Q2%6 zo4IuiUZyr9n2N{6;x=H|oQ=-YUgdOEU!{$os+m}~e?@eg8G&!;b>0^3kL$hyOzepI z=N??{=1U}HNZ`lT`&y6E?&l_f&^O)m=;Na2Eu(fQau3u#Cy!DGO6o9xuyT@dyoip0S|66Jx;beb7wA4*DRTq>Jp>{IMeeuPZr(U=cH<_GMDAA~6yW<4N zlDf0Xz6B_JO)P4*v)$>O58uq^hFqi<52z<0rbkY>IihXWFB1Ur*;?xdDL%3p>l+w$ zwToN)Vn|4RuJhxDLI;m9%C7`^ER(@r`})dRtka+(r}6DWCdN*IpcK+te+d^}zAcd# z^-NQ&NTL(zI}oUc)i#~Ig)FF*F}kYCu{OrGb;8MtIaG9{JJ@&=NjFtkMDb3wtxx(% zoyEHEP7T#wKpE0xR)Gp}u>wIDzqLqHEqC@VO z9g_1eXJSnm0ZVlqsRDO7c<<~Z)PaYu@}2foQN%WPXu>&hOh%MN^v=R&DFC|JYBs&_ z<*YZ9{6@gvw^&i)CTawc88K7c;P!si0fzgbPC}{M~Dcz(y)iSo|QcvMguOBQe-{ z%(>{R52>M+R_MzSHeIrxOyY}PP*CgAh8(Z;-)!3B$BnK#AgvWamnJP%K1W=$!NTOo z2@-J!NzUrsw72V^NQRnSd$X)bsluN5l$@P?a#QWEmgilUBweC;W%wg$EBB1OCswxA zVkg8i#7BcNkm%u-8sF|^uI#%+x002$ytX-tqu_8tC_ot+U)-|Zy$G+1o-g%i=6Adn zNoqtwWb;s=Dq+L`az~>I5Ly$P%MR|d(z|{Id|J2rJ5_zU$9sN8-QbSJ0Wwad=kSxn zk71lOiFCr`w3t%(t{aqC`882#PqYS-2c0tXdE1L1t4Y^IQY@i z8C^F3x!t*KQ`)Wxu9Q_eS?1m75LCVhr>?RFol<$y?7~nTz|Z*GW|=R<*%4pJCv%+F zoNGJx6x?&nDE%U)%h^JfHarV(506t;4E`$=#I>4sq26Ko0$Wv4&&yHP4xzTq!HE|i zLn*eapnhMJ*w$Jw7P69@N@0tog@MF?Ap@{xH;v~i`WwtG_Zq06f%!=X^LYqM#>%1C zxW2pw?0=M?Fl40P1;W%8%F&*ZQ1HT2(V<+jW*?3RVOE&@b9OxRoL^#|U1??XW{UJn z9=<{`5bEoFexoUBr)sB&;)t-Eve4k^uX70Vc$W7oYN2zyZY+iCAS>*?yU6Rk>`L3MBKnQl3PuhQCO;RICNA|4h9vUFGn%o_X)$zcy~+7BAZd(dDk~fp+1hNYCQ9?`%*K`BL;VmxcjyaOqIYR;3xilw+JpG2TI?b1_ zkZ{QPxJlY6v*w}^cvf^5)GURv`SVA!>(n`V$9~<-j;vTg*T97e7ILaZvbXJrC_u$zS4gaZPSKGVzGF6Qe| z3XuBWJ4oVJJTP*9=2L!}=%!+GfRGc!WZoO-FPOroG(24IrzWcakb}pgOS`p_{#w;A z|A45NCv_cOtLBs8-*rK5q2>z@y#8_evtMI^+k+Mj?5i2(Qv%DT7)iwEZlET%uQq~C z>Q)ckpnW+z(o6))dD~LLsRhw3tA#N>eAX$IMJnM1P=J1EOdC(mP0G5cO1dM76$Qao zEtoI-;HqzNXYnZRgf?8&0iwr2BYg~X7Zm4WQ_pv#dP{nh;VTV8q^cWU`lv z3J(vJ{c})Tn=2$iwvXAa0CoCF%R>~vIXeUvYiK!PdQ5io(S_%N9bLAINy_J&YYTng z&Y(Pz89E;JZw2I*5(6W+HoLct#*ceFW*{q3q5Z9q&PG#diQw|?Iu%A#qeN9ux+LF< z3vh4W{$=|o*>(Kr$5z z^-y$I##pLWtlN7B^w$GUiK>IOClvz4Zd`XB$BWmd_1qf%n})Ayvd)5%#WK54f5;HQ zv*(p|?hE6Yyd{w>M`x;#=FdI7AJGidE(}ggw52+R1&cx}P{BEmQ#z z;Q>qb@R>&sYx+)dXceYd>J5vrFn^j)W&Cs4|4w-E3HtI%_aKHnD?Fr#88 z?X8OnV!pp6h#nn#Q1M>@vL=Aqjm?0&vrFhwqLwuCYh}Y?P^cPr@_A2pf$OsFxXu+| z_>G*X-TuoYhpta5o_?DQQa>|P*of$J3F5l~6g5kQJVX*z9|00$8Zg(77B2gfV7Ap7 z7qvP?h=rgax>C%a`7ys1DnLs|CTtMN!~-@?_W0eHbg$!Vg^+aCX$LZHLiTVZC*p$N zXUYiRW&fxoj>2Bf*GlA#VQ-!>NO%Zx2yMiO{5i)z@>2y=Hg#@D zTmet!Xi;PiXcu`YKyz$fG(>abVFEtCg{&V*9uJ@7Yw_2gt7yWN#T=##`PqSlq;-(e zI{6dw|FZH=a}p<4FsGJo=w5X|V663wRp+=4mr=kzy7@eK9xDdVb+AZO_c4$suANsI zb4Q){QwKir0DVR0s=m%oVOpsdZfNmH#n;j%_4ewLj6O|qi)ue*^^*A!BRaGW;d&zR z94`}>_N1v#wySLpX0d)31a}}JcBB2(zgA*mJ6=pkvG+ftr~3n~pmVgHl})-P5d(yJ z%B6ze(~=&;+YGmj+qJ}s@;Q+wyj&o>Jk|tzzf-MFLUvQC1QlsQrP>`vM7~iEBvm`A zFUHLTEtfTzVBlG#>J^h)BxMTR=t&#rrMxAbz~)Mjt}Nuqxs<&?{2PNgALpvaCoZU_09={9VBzJA|%o;g>{g*2a*H30>+ZbkMGzNJiEnq z&sqa+k^WhOZo}-+%GlK$xf6I{Z6VR`gbfz2^({q0!JtP zbuKb?Qk4b?b6}k|r?HglO&M7bX$h5&XVoE`!NTTu0hbC(Ov4+!))9GHzPhA$kZd+P$RfeaGA1M?5I4eOQr42mr5uNDW&4cQ#R9;UcxBTz6R&QLLtWpJxTc< zv~Q$b$h)Z|A@9>jy)su&kW^0C?uv)+er@DYjBGv;M)R-Z6nobx{|RKZx{W>`)l6W0 z>6;t<3z#(DI zvqU2?*D&%PlP&W$%ub47_7PyAoMt728ARL?GMmbR{BgD zK`;eCkLx1pnoDROmqqZzd6wZgdvpO)!@SK49w!9j9>0#5$+nK|{1v7J3H zsX@E+LEEQ~fZ`HKtkWM?@W?h;+^)EwjOc}M-o7$g^AOg)pa)cP7`3WTNVSCMhnL2F z-SIm83V%Tkex7f7x!A!}o1(_+zIV)xTHJP+F24aPD76oi zmDu45b)9NmpAR!G?vym&<6 z9<1#Ae`mEXi>T!dOzX+t7$dkgWI*#4~FTyM5348i3-Ezy8u{Z}(;bGV}z5yh)njzw~!{#lS{F6S8 zZw@K4s@$00o(r&SnfYJKTX%_CyRLwuY45s3?QPTOC>_>y0m`q|9hN|?N}g+e;kr=Q z_+^~z>cZ?oo!+&;(=vA4c&uEDIRV=uG9u z(N*C7T)dE~*(e7fsj!9M4LsE)fNC&fCd-(Bg6g50da`UN`H?>`6~&j}`rk;Zch+SM zL;10v^_wL!u)`0e=jn-eo=z18P}}HCG50&C_xLq9VtEdi%4qi)AAj#sa^naG&GA}Z zwCtwAZtD&TcZnWok!Z-Bpta6@AWUJJO3C$K?h>VLPZrM&;Wx&bQN85b3nQ8a zOJ|JO`?T)bys4a)C;BNVHgx4V7IM$_JO&PxcLl3&fFLgn?@Tqr1- z>8+YGCQW;lKTu@buu{10_om7XTLFG$Gosc#_TygMQQ7vpkEL@q(^xUt>5^^il#=gbQRBKDf-!!) z!iF3=IdcbooJG!&{w8X+%BycK;XH=uc?QEq)6GBR!I%}TL?~e26(>M zMVf9$cj&VB-`Cgt3LMD@*N|wuXS8~Xy1@_|!r5KlPQe}jB1}$2<`5GZTe42H^Btlx z{Y*BjCz3|1*^!1LUP=!RrBchWJ7;GMc&`5^tXvX2wN_iG4pcT*EmNts1`yMRi(LU| z2HGvBpHO08mV=?9>ck0+L zELl8^_!I}3hg9`BAAec;9(|3hf=GKu9N9;Q+{u9wpFAq=*bdPydS z*zw|z`@hKZH6%UAYw5AILdTW=DJw@|cR_uMDgofr`*0L4|uuTNR~r8k4O z@(N(oP~)@iA|+6rWRcy3@4Ik7vSNEZK1FRnCTG_?#1b^-Pcz@C)a|bRQajF~;kx8t zwp4q?(aKu-c$(15E$5)`0m7_hC@;<<#{+2Zpj@2qPL7V4JSTexRG{JJLXB5|GF!lg zRBy8#=sh-WtMtHIKvuh)SBu-SAGgDv@{24#izFpf?{%e0Bc2VNW@NgBWDxlq-Q54B zQBLy-|qh_ADNSVM689%lsvdqb<^866#YD`>ZR8nYMave zxBCxb7xz$o2b1N1Aj(|+qRvrxQaSZC>+Wgx4#u^kajh*BtwMv!Z-ypGjOORsdfnre zP0gQQNW(s;?tj-lEnIDweHK?1TsDAPD-i>k(NgyRe&BZtEl;SBzXGt6^dx@o24A6< zLX$IR%M~Y2Q%CSee^|pKd4Uy!g(W$?&F%Hgz%}rXw===WGDo3)IXLD_-URsn^eJR^b%BcN`gi^Vv zj(WA8B68E`zu8*Rp7v&gK23iJZC8I|Z$PSK5yXDi!3Hwl_GoG#lZCz`T<1!c|nQ4!mn6|7hz*&(~V(kRc^R1c7f>^o+e+NvV= zG@8?@7JF1nd*6|M>@y^~SW|c<=Is9%&^(xvSAj_3I*5JU~3T6|P#ahI$-)AH1pP z|8>nbUUX5CznUCuTlHj26rA}~@j_UpU+YXl>KmtfJu`nC>6e_oYXfxp{y0^@=kuN! zru`oO(2RtS%uH^$RY7`??6;`IrnfN=qeUYX)!Q%o_49>9Om1pI*mQ?^>N))g|5%7Y zX&wTQe%=7J_fI7MSS)m`)<%$}t$;EU`@brT6mkkbmAloobIyXfC__-EYuyFQ>qW;{ z94i|lMVkaL(_av&fh}KpZLP} z50i9;E8k^`okQM7y7&oZ(LU?cO(fa2IQ(RJ&VTAp>mJ;;3}1Rv*;X_KsD1iq zq48_jXI>4_`m{;IpSPbyW`064FJ`$=CTkt=47ZMs4E3b{ggyslz5>S1P7*_R@68UE zYQ>by5fvYj{sVp{1Fyszx@(+0jiFIoQm zLS1ZrrP+%Iq|7rwK6l)W+s1j)W1S{3l&othV1l0NVBs1SfjagTfc&cU)bwO*|A+4n ztWeLIVhC3VB~U~YR>w-XN7leyx%Q!$QFJhhjBfY?04w7v8+g3jrYpbb?Qu4TlQhuayUG4CXRI*!+ ziKwvCMntCVF37as%^}5LsVbWpH|hQ&`ce5L2k&VxiqFB;);zfc{2#XX8vPhDI6Xqj zbwA@pmv4^*&QlCZ*_=+m8HAN26WytQ5*mea7TR%iQxbop5>m0u{n+6 zRk&`-0Gs#3vF|-5vpq79(Z4U9%HCYtp^C*sYxdC3u7Da`5qiDNqeU3dd!8#z-XtBt zC#T6^;j+OCz1#rwwiD)mY7W+YiQUpZ(e~{K<(kW)MA0Qo!cf`h_^!I$>wGpj+w5UA zY<4$XQ6W4z#f8?4VOdI_E$m1-98TH?J9)`HIBbxh8?9-g9rt@BhlbN6) zOm=d=0S%?0E3q>-53C?P<1JD7X?$L@6nvr~^$KWwC6c76q7;0%MxH21TO_F7x?-OSdinc7@-xC=eV_UHaHGTq$$Z!|IqR1pq4V;&x&T zT5NgYrxiqd^TdF}V37A6x0Cy9!wf{Pn} zBi1+Mk9*qQhb$@*8LgwuB$^9W#T@kGp6K9gsoqi%;JpQCZBvFxnJ%+@=4To}{6kj5_@(#j4VCa4DTFtf+9O=ASM} z#t*jh`a-mIoIl|nodxPMM|oi!N&CC6`|TU@mWEH954ZQ?=c=CriX|fJJisH@77#O< zxqguloKmVkP8#ZxK{#dftB{#;lNiH-3k-LJ<0L**?(LcTk`<=+*&3?@w5{RIfwT8N ztPb!hE-2ga@pUx^@0S!fY=%a3)x0lt@59gsI-?_?u?l>x243LR@xeU&RRRJK# zGY8&ytecTy}yq-F}D$!=eum!3GnO$0QEN>UG8q4j~MgM~ilE(`yAHmrMbc z1H4MQ=Ke^)HA!xaR$;zF=l(;nIFW7nem$ zj^KYJZ-`Rv}*w2KMP5A{lQvEXNV2CEfVIZ?-d_Ioq~!^~}Wlxseoi?-}0wF4`Z z;QHI@avhayZzvq?$nqu+OU()LJS_uQZ`h8!(|^sNDHPN5Ibm(^dL7?mO&9xFI?`q; ziK^}7mCI;=9QZE$k8xBzAotNgg1phUCoFb2^XTxZPSL zL#O!nJigabl3wgTo2QOVY!g80}t8ZD`3J(aU2VH z!3ajF*3_{z`^7?AA&pSrci9cyewiS+^1tiFU<$`m6iI=+mcpQx*VA@)?OF+&SreH@ zzaV&tEyGsYhLbxEG7Uc;nk>D-9A~O#?u6_Apej>jr^l66lcy~Wb4U4RO6fV~fAM{@ zQXxM4LU^>S)e;xOpouU7n~(wfkJ|h{RHEH)z1s}0PEgrMldZ;Ab_jeMffYddr7d*f zNm`vde~IJ$DbO;Byt2bGvN`=Q3*w*~Y2N?7{-A9M>KygMIEODJDiHnX_DG%hy}JQm^7y?Ez#;-R-Hyp&k=cNGn}K;wxJrR zqd?sb!Azk7yS^@wh@xw4fXCSuQ*Qgt>87hY9HoK&!`tF;ZMJtWC? ze`b}GVl&FbRR4rmd?Z!5hi5msB<@(~25`=R;a?uHizURXupM&lUjZpt=6Uj{C;x}8 zuZoK5eZ$>DigZY)fS>|O$1pI864G4)(%nc%4WKkbN_V$(cMT}rjf8Y}2%i1>pPO@Y zZZ?Zqti_tI_Vd0^e0+CP+>vut+KQ?vq&{B&0vclDJUfGVmLr-5DQj6XkY0Gia{O*? z&CWI-`FGJYDm}hUf?*JjwnkIeojXTVT4H zJw?aX0j&i~ZWImaEHR4OATJ6A08a9n&5Sz(b zz7C3>eHN3GBgw;{n)vjZ*BWXn8CB5-7-lOn$Q|qQiAe7V&dkZss;mcLb;7)~okRQq;-t2q@A_xuj!1Q?WmNHAx%s}l{r)0+|7l^X3 zjmD7$HEj09v-{FAW5SNm*C)3o5Dl-;`UMHh?7Y0tMK|3mk10uWf-WLVM;`4AfA*D^ zEs4oHbek2VyHc#)CPVl!9;qwLGy&KWIucSU0Gs87{9Rua!Oovh><6Sa)hpGDy0}jqj!>s*C=Z{|4WLgr}D)c%OOc?W3b#|9v#Ob@8F_5!&UnUGQsF zRk;1mC*J1r!e7Q?rcy@0!ry;@3TI8Zt%LyihranmzfaadolHh7PVDFg|A2r=+}7$* z@esqyV6PeSpQ2kFfs0XCKmPD@VT`vu-uwq#9<`)ZwBxnP*MI59F|+M?1byFm!HDLp zk81p3ocO*s^$~OKldR-k^#Ha`jSdbPrA{UWbx<_6jKY9O8I9`Ys@d9{6Nu`53HF+> zl=`o55&ga;UF_@#)-BV%mq%D{CP*s=Cd@J)zZpQUe7810xhZ4|PLeAb;2lhbRJE96 zX&tK_fyp<*v3$s$Q=`<6)md1Tjev}#U zJ94gxFBB;hj6D%g=nQU!C9-bjIpYb{iS)BTW0@Rf}Z-T)$pO zyuTy^j-;6GKDc#x6|%6nR@vwm)N;U96lwsU5z>d$lKH zS`>bn1E<5j06W^K(%ZRbu9P`3svCVXuqFA%3r6%m6En$zmKU6NGjC*32Y;;fZUrcQ z`63W6xg?Dzvd(we3Pm%6-+`^?kG?Ko^HdSjqO{=C9W~{f(}GupuFIUCv+l=%ask}s$vR@!oXUzev`2PT|5{GBc>T-QA$9bG@1%!S=wVnvUXK6)n9zld! zksO{M|L@@KSOT}-2Pa?Z3=({wkQB#Qt*#zQ`hi{vXwa7O^F^|Gz8&>yvSh$Hqig-@ zDoF%54^V~r%dM5fkZw$N$@&aF%1P%#?WbA1OG!E8nQhusK@h5IDEXzi$gT9=cyy7@x z8%4Z@7Pm&Khts#yUK?pFvCU*ZVkCx!9YIOACk!ffS(KrI)nx8MLDGMlxFoyTK$mzs6ux)@nBaCSJx0uO3TI47XQ?} ziRW5xmfFprS=z5Z6Kh&o+<2EsG`&w@ML6)OXqQTtFhhYAV3wNk*2+VKb;(>2J_fI2|_)>fnhLnknAq{040R1h(1(Ql>)`vV&2TrtqOn5=xbu?f5 zc^Q7fRwmHc)^imXH101A_K*2dY6ss@m8$?(&!7YJa5ov6A1eLe;`!Ut2EoK8+TJN{ zp1OjLu~Hcw%OD^~B{ENh)<{bP>kY!~PCQh^EZ+t9%5cx?I_+gDGW+kSCM(gY*a})cowAp=jbYVBAvG(zzp&Wl5lnsh|>HRo!`1)Bog11Vz zz}-YyOo}eT_~RCR2UmHldinQn5e^062gtN+a@XHL#@7#k@dqyr{?A0p5e@nW8>npd zN+J5KRc(p)hxR^qphd^V`aD_HoiVu_?k&7%7}*e$|gVu-H zxoX$`?4^YkVNmz+cu(M+4LdL-VIr7}jF9d^j1g7v_BIae`+z`c4(VtLp)r;f8ZgW|>RaB*xeR|7C|_>M*IzuV}MqWG{k zLz1SC5m;N`Y!PRc4pzZH5almjAZEh@ySIG=a49vN(DLw)rK@0bFDX=l2N}iNjvcx{ zfyP59`g^c(%%h&(+caEkrW(oUos0U-_vbhm{=~Sdd44DBA7HEK{8xMb$|X=2*kuLq z_`Z`?Q<^=x9$@OEyjG~a#r#RdmYg-4?-dE#n03Jqr~nzWFJ2fF!WZ^`=+=YjuQI%s z5H7_!06V(!^k)ts=2&O?K|CPLiFI`tmS?sH$1){sjCn-wQ@#ae1TB3z3}68y=E_o? zz%Ko}2u=pflSb5eh;AN0e)#e()^Eg) z_Mh8l2Qp|+>Pc6VB2JfRBg~D%94^R`uln|CM+`S-gcy|J#N|CagDxAvQ$J5}j|V-5 zXr0mr;0Qm_j8Sb2gV) z2HO49IG62{orQiUFL5Ymj(lD=(EhpLRc0lSf z|KX*$Qp(CaXcv#VHzFXv+!W@J5FHwT%C!DW9$^(LZpZV!7x27&>C$vy35lD|5BAJ7 zWnL5(9w87oq;liX#33f$WKktvX}3bk{2JN_OqTG@r=0balQ)Fbt7qg-LefU|{tW3J ze1>aRH8K5UWU|~3=KB(bW(!mOc17a%2i9D7Gj3>KrfIY$!^Lve$O>pTIGk_hM>c!3 zh&R~eOc&RG04hQ?4$`%uIbZoaPDTF#(dvf(Q}|HQGT=EGQ3Z*a2i*A)mYM&l1zdJ6 z3eDXv=Pkn8&xrYOQkG>;jIT9CLpS}R=HoA^LpPsJ5UbgxrA>r;^6X6RO!3R}s;IZs z!5X-s?!$>kj&qL7jiW;xB-lMyl^q9`Os}{jOYt34HT^%|^IM8de7(<$iH?kzc6V0v z9slJ%ZMi2lqEdkR2P{gWo3;xI%o;2!w%UGWvNtlaOp%jQ*sgg8jvjSx(rIbtS{I^mG}iH12}>WF<_D15GNI?fb`; zn@7nG)IJ#8mFqcQlrY(h8Z=u2_APgqHgwTV+%l9~eO03{mAheYo>Jx|o`xV}e*o;8 zrKN?IB$Em!8wt&X$@s0(Kwyd*;rwCIKfs<(xp}b9kv~<{WAnukXW8*p`OSgY4Bnnp;FMPPj25!{<4MBh^L7*=70Dg~gK_CNpuXop}V12PhY)Gt~P{{&?UNvuWogt@EN_d@S@bEpKo$>A8uivH03u=S7a# zwdg4mpo$6wIX|r5r|dwLL|7j1r0tzh$0h<7F(2UJ0({CNHBFtT+=x3N<{l>=rveG_ zAuS7YhA-Qe-~q^C*nL+gd>Y9Os7XsT=So>TSW4ZA)x%q5=y4fwIV@KO}aU{~&ZTu)eq^lqjc(Ee%qT+lX zPUD-d@OyedUQwxUpEQK+b8`msFR-*R#_tEmI=6IUZ=Qyw)&+aOI(+iMbp9Cnv0|0t z*2e-}X6(gB+#Qp^)|_y{o|6fxnhbt(3r?mXE=z1{yGI9@z}q=yN9);5AqNM>3)<<* zQVlFDH35;?J}oQbWg=mLHK|W(L8|GJ5w#2&WwoAo zjtayeINN%NLW;o-YP`9ljhqXTE*v8hjZ1@$r>NcM`nk}3%OJr$t(lDdcn9LSqXy-9 zf7@wxe(tG16X~lvnaUXPCioNmD@~p{Feu2MJcG;Ww!md>b&!+zmK*e6({ zW-pi0Yu1vjoZU_0#f!IqFOpq|mju6FJFH@_@?}2mY%ijx z#(_~4oCku-UH+ z;B&$6O9L289M@y+d$R!bFjhk{VuR(vVmAk}lk^!J7FePZrh<31M(9kN>&!$oYB<}lX{GQo#nm_IP>t`FB_GL17)ljl3>9e6j=Qb4t?%b!)KApZrA8Lmk zy{)BiI>tsU;tYE}NUjwU$vHuN8+cmkk=7bLSGw0UO!FV76aGCVsi@utL`oap;)6nK zH(>%q2+m7+0f|fXs<&Rri82g%K@|G?FUqRzL222}=W>d#?=`TgEw!KDQ$m;PUW9S} zA&`a$iLn`ekBsQH)*nX^ptQ<}chyhoUCq2oPoG(vM-C-|d5P$y<@_!&_m>%zg6BK> zw{9ND<=!bu_!I1yvPH|P`zezOdc5MVjqQ2a$??px-WH}@X$Nf28 zkx=Ts1kza|3^rDean?`0+RBRW1XiQ3^FTCxB6dHF52q^U(nz}ulL0GQ{gdl0Xrd=G z@(A_=PL+gL-HxH$ruiS5Uh_m76R6gEiRai@Qwg@4lKjcjV^-WRV-OUlmdsZr=Kf!8 zyROz#s@M zyhbYz>kl|sZm!@@(33g;yo^YbXZbit(m*KSI_Sy$6~e8>)|Bs?rTKNmrs@YM|~3_$R|*Z3*0 z%I`cC{SapdMNY7uxbhnlM`psQ155t_9@+890#J)^8PaA0yl}^ZrlG)}V^d`Myijs4+g34j;RlG`q*8kI+`%XuV$t`AI_a_~#=j zq`z+GpFk_)S9Ab7lCFK8$`fc@1P4qJC^0N43tKXk>r9tq6OKOun~Gy#8}DiPXI+D< zni{98Lpg%IJQo%)wnO!^9SpV{$N$=i`{$gF7)CVq!wrDNb#9cLcGLT@b4eMmg1!OF z`7-@a98ufkt=)D|R)~I0(#;!r<=Ah!X9Sdls?G!0FRf;6AuA4Ksg=<`L5)ZqT4oW^J z>#+_$O7)F2?0&yb9Axz*G7Lmf#$mZ`j<)+^6e?RHM~=>^TMf$_i!phd4il+2FZ7GL zIR69SmQ8vVxK+sb7p{(eFbJ9^UH{FC#dK+K&g&Z&|Gg)$Vd8pBSdWq=7+o z4hk1u$e&M+=ILOb!Lub~1DE9{4u%UOWEs68V7`oB9edm(HvXb4eu-wfsUKZ<7SXf~ zIUDiUs)FNfy52^u9nt3kciz%j1tmqpl+WUvk%B^>{S4<3*!6#(pyUfJ?kt~MHs=+F zQ6v$^fRsFY`Y|A5Vyiu-WBxcifdYK9#a6sR~Q3Adu`-d<4I!bg6KbU5H7-;;oO>q#w7%CkAA zuZff+L^=;j4b94)a1cHSr&DgQrK}t0a)}ODxP4hoFs)#%x8SFWwN|Hfthz?Itkcap znNEw*1)y&st9?n`ReGKY9KDL?8ID2mIe(nDY6_LXh?3q;6{;!nG~TP!`5oFq`VW}) z(g)#tLF`MqXBnhn-pFhT5Vb3Q^{C)sKKnVnq#vL})A|9J$#^g$pIu3rtGJ3bcgwP! zcO?ap_gLXz>Pw{1R=Ar2ta2dXCOrCnkSy+R>Q7gZH)AZ#17c21C2x`{leRZ(KQbDS z@^Ugv3e>l)txPJvvT@m}jBj}Q17qcQ_`9b~hOGy$thTNY&2G{k+Vm?TC{9&Y@1PK9 z2-xIMr+Q5{!pAgc3&(W&{-6fDySj~vAwp-_x?v60wF2Dww|1q4%n71_pfmd*wNKgHHSxEY~N>#wvB&!zAW-As_MR83pf2#c5y4+ zjs?^hiLl8`m^yG<#f_3a#17xAHwb4%X9&5J+xi1Dn(~jKCVBRdOMmWm z6fqa+uY_q|R+6Zr6Y^0sX8o&+;HO=U_753Yr)m2opZ+SPG5vj;A$O3AK519LC|-^GuakGF0=ZCGtt@x#{;R8 z6Ue*Cs)@SLkfg?@d=S3E+?`mp{<0g0+w%3-$D-Z}?()cN#Bk7=t!lDu} zIqtege{UQcA`iQOtRK$!^QYq+Z)`jFt6DTCP!IF|$zqBrPgHMxW)|tf;p7umU()2W zT^NR_c{(|sxj~kgy)Vz#)Z~k;NW)(h#u7$N_@zbsCmN!|;VTo+I*|>MO1O5KlzIte z#u!}r_PO7&M+sZa!letHnJreO{&yBPA2OCHMxij_`42BU0^?K^7&SyZ{}>p*-7Z?z zCQrfOqVlyQER3QL@foBK?qa{#ts4EAFQG?2$d5 ziWz;E7uWm4!nS(q3%)t=`*#etCKh{esxJ?I*0FEPIF?mKs5d>-MhM+(%*F|FA&CqH zMx;pIZ>xiDM4M4xLV?C+@op=ucCNNZXx$lPjA5DB_Id5SZsw_o)8SOEd5CDsmNZ=gI^FDU0y z=2{x*;^nLeBkx`u|YdAZYpq#0jYCf@9j%n6Ovb1=Ox^B z=$v)r7@bs9*jJWi@l}fw@vDSm97tAa{9YmFy>^6<`Ph8IFl&+atr?#f8R~2f@wzVa z8CAk$OgGgHkWU$n|DyU+184ngz3W&+3zV;{jQH2z*s6zRajWw=7Pv=U_Bjm9eFGi| zA6&dxTtkt!2qV{K_-eZ=W;1b-Lv48)$7B#Qw}oA4k!`@QVqy2~M{olGZWFZt#j9WK zd2)K&z<1LbIEL%N1ygSf@bzE_Jsk}&$)j~vIG#^etV7b5mLYPB-RoXnr^4SuW zr9UQLFe%4U9V6uyyBC0yRclNd*%4ZJMK2uY*PM971PM--u2{G3-DhbWD53b$e)Hbik8kraXp^`c zuafc!Urhl2{%E=6do_HYREkC6)Wpzm*8vmI_PNkCfs;oxdrM0+gXsxGO8`GNBetib zL87e@X$b3T}`J^<>1_g!G zJPMR2`2Jj>BrJ&+kv!=7`v*o`FV@TmT=p*+va*7G`(lBnw3P-h7E?z#w24~vJ~_i{ z^`T6`=pQga^lKPwt;xQ5V*3kIo!5PU5osZ1f9WK;rcOFNIThU&)j-%?8IY9?M*Irk zLlwS~?A}_T@S~-kty9S6+0hrcmiv5+!fFb+5Sp{F)RQ9bvYbP%-xiobZ+>y841mfi zPeOs1iCuO;OEy);hZzMe8O~Dro42G!c#~~XFznDfxdBidHF5LPrl&oRyvHM{qmX%kBZ%C-FiEKWb~6(G2N*K5h#7LmZh|S5AV-TG z!sJS&bshPfa+AJb4$$^ax{WfTSO9b5(N{pV=`pyj8TX?gcu}$TcZ^tHyru0BOmw>> z2;#Zgr=B;U0oa@@00AfO)zbxP)Ky=5FdcTzFRa3{)TRe`kNd{g7m08Z!(9yic8J{F z>IgTcb$om977jtxlo4&?`}x}{xK8lQNj>B=(bSiBx00L*ed$jqb_jFM;P51Sw@aN6 z$P-@<&IHC#bk1*iVz(oWB>w&)?WXaa9(1$&b+Uq-mx7fl%cs@cl-tpA#v;Qv5A`4n z>^`qB^j5cKHXOkiT$C9zYcrmAC|8S-;jR361w&a1zR))()IL?YP$0H*2J=Q(KW`Ql z`K6T6r)S`ong&{!r7fQJ%+%eFeM3=sLt_>kqn{>5IHsN5P0YPlny-`AQyWNJa~+q0w~f|(Ffv)T zYv*6 zb_lNI^75RcVzY$CQ&@Ll3>s*24`XTqUVnvxA=dlgM&lETf{+P;Z7lWNe92dsBb#CTdRNX=5u@yZ-uXvPkBhSm$XatcV^C zHcxG!{*gAz;r6+4Sg}fB7lMn&a%_IEC}XcRN^mgZhwqU(W+B>0Iv;+_*eMjS+( zeWEX0I6Frws z0?m5MGkC#AxTc~ac{H3bbyhxuQ0gRIe$>A4a`Y~oQZVUBo8-0a5$3o&^5MjvnG8Eu z`l@{hJqs{gHme_;QLXnVP*NJAUpg2v323SFl^T46&Z4X~May^m3p^!5klsxWV8{o* zNO_*}p}Z<*dmsT8!9ISqw>0{j@m-_gd@CKXoqS90<{X6FLl;^H98S|Qwt8OtqIiTo z$5zzs_B4rJ>dMg2vK~MP`f~g8-lqq~i|-~aJ~vkT&vgZ zCK=e7gnJk|7mW0z-L{* zSJuSwlo!&4adkX#)D&5jWA7yLoUe~$E73PP3~eRGk+-EDz;jR=Mc?&#` z&<$$Fq^|g~B|0x&@%SA0MPCGQWP;!5Re9<6M7KTx2PEUuPuJYDy7{|-&1OBo@uoA* zN*3_?aRPHf5g-R!4;p1Ls0C!rYo7Z=ssIRZU?H5<<`cDYQadIPVQ=yM@1yAT6P0oX za#3iE;YS(DsuT`zih89|bo33<3Ll*Lxw$FU{(UBME^xm2aL#wcZsVmD6J(zmWT#Np zPkCxci8nCfdj^|`&+Qp{oIbNG@P)IHy}LR>Lj^P9t4vH!)SY%S)7UG_=G-&mCY^m( ztDXs3&EZ`a52Q7_kLbV5#A0ECMyf2&iv%3)X8U8M{>r@*i9NOq_JECmt34=Ve9zeA zc#`l+PxNrOFAHfyu^oKGIpG3{7Z`yCfb6H^>~uv#dPl9IX4%iKc~Ut>sPV)vtvz2= z@41kDb_hOw_G>y_7!22SUUOM>jLbd5XG_~TeTN$C#V)X(kziwAt`7k&Zu4}4ODLEp zYAx}QX$Q~nmB5kHR_zVQi{wq0q<7OQn6j83tG<56xvYCcKVFG_g#jouhp^7DSqjJQ zHOi6zpWGq~Ciq{CBIugPdVQoJy09$SOdR{XIJO2x9J{aO2uiwLqX9scH3Sg5o4sPu z8%6N-7Vo~UsZbY_^d+zc?Q^f4ZAZyf4w{Lu+U>Jr?}yVMVtOxEJ{<8kthHZRQ#l;M zX&iP)?x#%OKd;YGfAsz9!*uG}!D1$ep@%n)qwT@4O?ntmPxPWU)63ca!ld>qi}s?aK7EX04Hu>G|GCr4?jlgp>W zMN{7oZg;E)O%^YB2csQX8b4enCo5dO?s#ZmU?_c}-s}&wopaGxeG>gaCxLB9)V%fm z{axeVLbMZHgDZ8lkwYI5H_~4RR89B0nPJBlZkf1g@;ZHME+o1?jo%IKpm02;bTXx{ zk?f0`^tP-8_OI;_^yj3pfx5oEMCD~^oyc0{Q`3l8P#f@3#10{!iMhE*p+HAMN&qHY z*nIJH^`E1s{qoc^U8DkeF1zyRYL>T`cl9Dw^v_VZ%*%?t@w3HO54c5tSLCw|!{nTq zd{1_noFk>V6AX8jR$5gSF09O$=}3cY_Czn;-Zt>jO&WPCxQ?Uj;rHtx4Bf!?)7lJE zDvj#jF)nq4YC~oAO}@o^=9V2?-4kzT70UPGJ`&R!nbXak-YM+c`rudTJHi^C#z{jn z&U^H+k%m?HPAh|A#yDvzm{3JWVDIUBsJZrv`1BuLmRL0??Rb+^>fxFqrc(0XvWG`# z9Z-}N(dyFqBLHPS>iCvn6!GZN8*BNi(4i|q`3BIjAvJNq=JOTMxb=}SUg&=4N25U+ zWW0U1vQ30OlAQ7?3QjzCIt-d0;zHVA6@dEK_nNv2wolYf{Cq!GiSpJqR9t-1USAjh z^bQVvSKdeoLuE5f?C)8i1%LmfZgqT#!ipctZ=NVf&uF6#mp@}eO8Fdfbc>;HD`DQX z!e+YlFU_m0G;1zJut$7GrzGjWLcT*m;s&k22yl;D0?1h~JDlhWffKeT{Ce-h)y||v z^DBGPy(^I82%;CAZ!Zb!lH=1CBsNLJhw@43yGG_+BG#R#+vPDtTTlWdazDv${vn0jl&Yr|{j+vkn5Pq+W>pxT zSHHa9qGukoTQt{(sLMyMlJ0jn5Y}DAN|)Vj()=naoQ?gMQ%u09_9`+y$NZH=j2!IR z)}`$piq*zI;qQK9sF}ZFU3EkEEU9p&-#9nBxX68e_EU_?a#UZ6%lFGsbT|NDb?`s{ zud0AGRBvr>-ejdR_uP7MQbfI+qdhBjL&d9NMnb}Zzw*_Kt~te|8Lf0IjHXK*xwmTr zo?jtdnb&p4-v~%7U`4-gKdXr8UQr&-O7BFtDPgMBsJU<-?pS8#n@8ixg|y09Jf4J* z!HM624_bM*HL!|H{_Q*@lv5}uHuiyric4@<`k>jzqai*hgPm(0xUmoR;ShbFyA~fK zzF9%oD>u<@u^O5hb9O%#v#2>4h21sx#()EfeDNWuF@Mi=yTp>?C2ZVlIgEu#o5Tn#CWqd#j(y7H@$?s znFqF79Vs!}rxq8K< zs&9Yk%BUAiili6g6y@3Hkqx>y;qG7QLq_~SiMlUOLD+qMh79rVn>R(V`g$`dQoQ&@zoXCCvcZw+YIOrlQ1L*n9PaAV6ij(6bo3thJV8jwFj@zKmPqzbk`;OtJ zY0C{Fivz}iw$)_NLRguLAe(F#G z!pdAiF+J7r_GYt3x1HKQ5qbtI7~<*qONzJs~xz{;}#eVUfs*So|XYClMzb-d=0{Lbsi(hQ+bw-*T% zjKdU`ZL5HqwTM|3OJl#GAC!v9I@)U@bCk?w$PA@O(_&*Rg}|Q+FfiIE6>2xs`b+a4 zus&75rf%s(Pe4DRIQr;L5_#qViEFh?>AqME)G;Q=qF;20!A_qY%H6qA!un|3sLsW{ zsC6m)KT9we>w^Jt}eR%i6P z8-vbLOGhxBO*@=ZZq$nTlef#{yr;l@b7$Q7yRI#R{C@OjqVd(Qf-Kq&U!*DDaZ7ur zr-SeDwi}T0cxepk7nveXTT+wz5*Sq442Z21ERpzAWV(VbgEsokK`n~jqmcUU3uVOZ zPSN=#7_iitQ1wi2=1@ROk2!s9YpLgeixpM6se~;-?atlmS@-| z#YKDRmWdy0{rSici7h2X%-|;1Nue9m_GMaBuOC9I;OLBe>KbFGXenC>a2%4Zf%iL+ z0Rhs2RP@GAGmL;S@`8_jpIrRi!A7dm^zg*3Z1n8gkzXv3f_c6HpW7H|!UUOTn)h+D zDRx1aDe3yH!RXT2`6>5hUIb$AW%WKhAl5_K7y`-l+P?u7>HckTM#NNq z$C1=x|ElunnSyvN#dA@=`LWn$FaziS%30m0^VwX^w@!I{YDG{SyL#BSwV)HE=AW!- zU~Ny_@~SH}Y*{9PUO$2`jDXyl%*T~sC3LwOLPiCjVa~SB`xE@b=xu_5vOd4N5YLw} zKbCnZbaL2-EXzf3xTzx8(Z(3n0+#mR6vmHJuE5*xLSr@{uE#HQKAwFm<8lLd+0n)i zO5T0pl(xGpJ5d7Fc-(gq7h15r(}pTwD8%}*>}k+_3t?H5Z;A3D%Eva?7YzPLwxXV& z@&3x?uy%G>+4^UA;6s{5Q}uI`E8yod?^Ij5A=@CmQOt+l{BhsrqRSxA#+JhEqY$?N z<*M-K`8ACYY*u%%?R-z>60tevp(uwODKhG{W<>MMV6KGqZk#x>+>Yu2Vsu4jUq;c} zJZQ%U8S#~@snehip+&SKPZjITC8;F*`|S_{5t5^p`9WH$ppgjC&<05i6`Gd}H@zaUQ;d|Odmi?}ZeL)&;3$&Y@ z^zXA)O!4SHwU(%EI$knP*IJqVHdQM(8(OSO<`Mps2&+4+0t%&}-?!9rGwzpTCGQ?Y zRBoS(C>-=X{bCF!3xRBu6K(S|eAd=}w|;GvW&6$>?Sm5vvTV$bmTIW)@?vI7mX+0g z;XXO*+iy3Nz^?2SRvbk73r<#Ho-1|mvjWk)h?5H=i*_TRo}^TqIpK%-);xlvr=)GM z)dH>Kptx$!;UW{a@}E`!FoaF6a1sBwSIKnSFJ^Es9ohF^GP+Ms@|? zWB=jZTGBD9W%cjFg^`^wEvRvUMp2c=UZg=~?T%y%NZP^4W?vsFgMCU#zW|Z){zuXv zpVsIlhDJ=mpga!JnV<90u6L*vH~73K&yiN~%if%Wk*43IBui3HHWzfyk_*kLABy^Xm6zSybyjr~6K_?-swXrZ-8~ zlwPw>VynP_rPlzM!Oh~Gz$5z!7L9tsXh8dk`p2h(=^Bx2RGeormAh~eF}WzrwrN_Q z`S^H!6G8KLG2c zfl>A6{C@ABMZYWNlz~|BdHCMKT^xXU=i5m+vGuv0E3c@xLPBX!A}{vPV3t}5)4eT~ zB>8#Kz{ZH7X5%6TZ39!@y-s8ZD z30MHx<`c&}r9Cl5FZGJ|s(5Nua@(dnc<)36(3Tcb5?|4OU$Fk;$wujE}Pa ze~&Ci+X3JmLAt;R*qGxFZPuHp*$hxumzVckUHQ;Qp*vv7*qo^c*y<~)UQdrchmbwY z=9L<7hXM%_qOsR9`Y@W@l#_ci&&Fm!uL~YY#8I6IfXe z=||tuX-mr9bJ@&L&JC=B(iq#fbiMyU>pv1P{a4Nbw)aPbpm7af_G3=@sAb&qyd&(SdmeEq%^p5=^KN)S)kY$CQFgVO&W09|N zpxkT+OMG6R5A4<^)#^w;OHQ^h@~Yy_TPW!3>pNK2b?Ts=z8j{LKV?TB)+6Wvi$Nne zb_f<{A=s_bWMx63PJQRtEcN^>+UQ1#(4m*_8W>t zrP{6Q?{>VT_X`e^BB7!>bgoOAY0uYFqZp<~5zkZ9C!d+2L+9w)4jGxk2}|gS?c&r$ zIBoEGBx%&~%US(q7wk+3-Ce&z5?)3W%6C>`t=sbf619w;Q4k~!Xv-qe8~~uX2wW0+ zFw^S$*(?~Jh*OUSKJ7v5!RWO1RT!WfH2@XUr;l)E5mdL{T!=>C$%DFbG@uu3etvJk zg#425JCNcQkHl)+-h5P}4Iz-5ltu0;{cnj`WBQea$j-&vKw(hL|E zXM4hLoYGt#n4uExyU9$`A)c=>*PRE_W`Tu-4L>6^-t>YbA?8Ej)rbyA>!iG$HtJ#|k|_QX@`dhsaocmAjFh1Q2?Fne=Zl(@+FYBQuVvXA;oWTl$H z(r0b7VZXf0`pQ4s%hS=G!S_4h!X|?Cohf8g-HKO`x$R*D{~^cwY+i}CXUg6J+4R_3 z5~_Q!PO}p5(&je;&6mWA(Cjefvjp*9vO(gD=5d&yr1okW{O(8Lc&;eD;Q7^U_p-$f zoOTN~o~lC8in30XVXG;daJN>r7L3cMd;5wj{teTCc>zcVNDf0)JWWD+W4QMngoE(d z3g-a2GpJHP(g47qLvs#V*Om(3EW`j@HBsnI;P2Q2PhCxv(3|Lc0L|IJZQw^Ocn!B6 z{yu{0K$d(GxE);rD0FD;dI6^WTPN_hed3?xNDZ&q#FwguzC#wq; zcgLao${Z$9#IZajCc@rQlx9vJ_(iG+z^>hKzP~3e0`rVyaHY2M`-Zch_~*RO`uWJf z0L*|=v3FwhOymVUmh%P#;%b&Y8EOOYzodQFCUiNZgbIx0AJX*O3JqOS>Ku?v&7ZdM z;WWFV0(^<}x6#RO{)(8bF+7%=rx2jt{pqIvA|wYk;#R7Uss$`WHn+)l&QFvD&A5*S zeQ4@jnM5iQ?*FiIY6`9{4&6#S+;+mL{v^w!8oXR#j$8w=*VMI(NO%grI&R~KPGySJ zK_1BXID)r~O|ry+E8iPtEk$jqv*p_Nm&c$P2NcHEjBShbx*$#Xob}qG6YxcCp*ALk zE{^{0XnX08bl$Z{y#8IMX(2KkKiyUps%7j5shSnTCVBO(T2hZR(!qnhE>SHs+qH7J zzuxLG)-L%RAong0bP8l*0$PDraH4`}_@n2Q0J#h!c8n&R_~j;o@jwQEnxkyTSpn++ zovG)#{vwUC0(KkF+5SI)AK#eRv88far>cf4svMa$<`j z=Rmn0^QCua^8C=4e3^^lJEh-D;3Ggh27%M%ixatPqraEFVn27MHC5d0_bu63mly7b zO#t(4)<`ScZcydMvpQB3>>rF89$KYU)7|x zIzbc~9|w_8{4mZZQokj`PX}Vt^sJQO**{Bd_Z?)A42lo2-Rjb|s*0jm|A5`fF=W?o z2pGiAc=%o8Y{vy+3477v>3VPoCnwDB)pB!Vftc$M9mWCLxHjqi9>T3Ozt*5OU?tB@_2)SxNp~(q^~^(-y(Uh3$)P5|RA~(00S;9Kx%r zqDI2&oJBmaHIbNhT51B`qgA1{^w*QCJ53-fD)Bk)wmegV9o3D_JvUkAz8vb1joQ_lcV+?t4vE&22 z)VRI-%Txks^fK+-H`-K1neGm?zg&sOgp_GO;UxQ(&N};yLk)k!>Q~|i7;57B8Xux$RIPv z_zsyTc8OOMpW;>J;A>Wt+etpF%!!I`y9-pT0<&L)va>m_MaQ6+wH#{q9h?=I&BBPi z3(@!Mz+0N7=ZsAv1g+<1&_RVHBmV0dF>zM|FXz7o>kG+QfQvRWtKBmqsR^sC%A=6k zL}@r3Yx?6&@#nCKk^fAM!07)AeS-BkZ0A?cx8WGY-XG;G9R<*>QzhUB>}XS{@)Km3 zq|NT^^8WoSB=KetGoZE;aHWVUz3#O$iitQ9Wl;Jnu!9uUyk~^d*Zv=x-ZCu8$Lqp< z#*t7`=|(_MO6i8-M|XEAJ#=?>NSB0&ba$t8Ne^Ap-JPE2|DJO`@QDkUi-DQF_gd>- zlYPtO`y*c04Sl><*8Q~)d{US21?mQo3otE<A=)Mhxb`Jlx_n3i&T%X~yj)+1uxw$2jg4$Ol$d$05&R;0 zH%S+L%-nX( zxQJL;kB+TvR%pol)$G6#kc6hM&1+MT_`m!lj1{;Z$jOBzE(t9tK5 z3FU)^h!5*HD%FN_b8`#2`qxc!not`V8;23Vv8_6QqA?qbpQgw?@j0r|=!)?xV3D9& zmeeRa3f}0BNKl+pZM8?7fa}C^KU6zO$`>8dDX_PDdH~x(-Z&7;hjp7MrgIWPhX*LNG17rnkkO?drtst+V&VblKRq|KkLnWTMS1 z=4Q>cL$i&iO!&!MJAHoS)B#2v?%Ebk9`~jjmSUnhu9X?qdz^p30r4mPa-nU5?SBBj z)|p5oXw6h?nx{kwGk{BT@6*$uhw!`y)m!$)dw&=XfyXLANC|Q0dnd%blkZb^AD^rE zV)Ikq02Ep9yqRwdZQROQyyV0BNoBwjbQR<~Ij%=|q@b=vOX>QeLF^A9)+s$#TOqvS z-NJ7k+5eymR9u3XA?wp=W2=?f5rqPY`ilXs&}?v2Jm%D$&x`-nJJ~kLeP+)r*ph`A z^3ZjOvTPz=MhV{M55f7X3>*@-)<9j@^h`Yany^Te*GpfF0s88G^fE?nT9sHgDavuU z$3D$$Q~HwxbFk|KMi%WWfyF7=S=9Hn)HC5^odWDK&ggXmDr9Q9vAqJ_6SMjS2)i_qG ziEFc5U=552qx#RU_8!P<@Q?H3cD|bz>mW}5x=|!nVL%*=jIdQrb35z`_2botK7$47 z2SvU-lMDgNG(X<%e*l^V%2INHRO~a6;L6V&?3)E%nFg?ge(ZLF54t8=V=x+aW=H1G-;VL?CCuZ^1d zY%@18DG#GlE1Ok~TDfqfK+Ptp$LwsV1K`gF!eT$XH626maj!j_(Vp-xx0|xEFivKA z$hUvUkx|aKQoFO9^th0JOMamUO>-|Mi`Jr`jv*icm((wZlDNQ^U(Emm_+k9(J>b$x z@^@&Aa7KYtg)=$xN2${lrp;+>2RNTQ7EWM9{tuk1EIMDK zpMh{4k{Sgk|I3p=)MU50wo)p#FkD}mq}J8ve1cQ8^6NI|wCp2h()5*0z2{KWqP?Se zfw3*w@3!R|Jt^=?gGpHz2)7?_ikC}V3v;UdMA~F86TE?ireeMTFV8DXaK%I(#O*DA zS8it{Yf@$dUOOt{Xz!Rm^LkiM*!CDK1@*hjD)0p7!JX=D_gWg=MuP*$GQ$*TL{^ zOhV7|yQxPG86Kl6`UYW|5uT}uzNz=BW!$>s+pt+y$^$5M8gtcKE*4)7kF-y(XAaV= zn%m=WYsO#YhQ~_|8!e}E_iP(@laRUae(944FNnW?P7x8ygClBjSy*3NXc`5ertVgA zFA$IOY;tCdP8QSFYjp2BH80q^f84}4Ph+VWHKo0uox1|-#66~dFvhm0WvxD_Q9orG zNbKPB@9oW9b(cuExh|;@fX~&6lnU3Y=-b7 zr)=hR4L&F#Y5yLfiEMICVzT)B{{MCeqK%GF{V@YNo(6ji>p?n7Ho9M~0v*ER{wAb` z^;yOyZa0u8?>6b=)xDaVl_`F%Gs|JK{8bkVEHw3LE^p;EDdfpJjBtvdVl&nv+6WFh z9R8k$Aj7qsDw*TinrV54Tj!mc*}(#-h0Nez0Yj)r#v9b^0f#6#G1jxFc^&Gl^*#@M zZno>-D#H0tJ8e*+v2V*<_~V$AkoC)K6NDGM*fHF+?9BybUIHgR;PH;X z`cE#YBD6-q;6M@=Ql73mUD!uhVv7DuGLX#Y(8R8!R1tYq8oaYfD zE3$MD0Y=SN3!ux)OGxR)*%`siPfukcs|OjkSwZnk)8Nm-44a_e8ja45ZneX`!gzb# z&v~>QaFE{jgbFng#d9*9R#a6veNYz&cp7ztQD-F<)s5)KehsO%!hdM^vFS}{+=Bq9 z)MME=?|8XOIx?mZ6i3Z!q&@e_Ffyr6QlB$uF6#hg8q%@NT4Xnmoa6lxbF$8wzJ>3h zU6Ifu5bTk~uW9O|mUH)B=OU*lrvmyVz@=VcV#L2YhaDo$a3++NAgl#u2d2hQyyBX2 zs(Lg*auVz&5hM0Z{gWhcvU+*)K{nkHhU9Y|)L~=(f~Pxmt|Dj3tejtalx8c1{~Gf- z7xE)+kngmIbi>km-p4CoPexB9m(5x7Wer9}-NMu#*PD!c?3bbPxsw}sc*iYAF1@!g zW|#>lPRzPjY^nnW48u$A;5W(d(8z39vYqSV6kn=7(Spo(0-vp|#x|3le-uJiTXVr0 z6vZqguj$Ks(;Ri5u4Fh$BB2QXjaFtYM0$yx4eMcHfnK<`@y~gTKPvT37CIN2J2_eY zqgvhZE&KS-i{S%QD2^09<-(GBXZdSSPdp5=Pjtz)TNEpDVfV4f%TxtQ<=<3BLEk3o*+yg0;!@_rr>`YL`wj_#)r+8%-X# zmh0gcsfkcJ*)%@Nfsl{3iCd5G9{yPke!CjDQHKo=6QV9I-?HlIM^<6Ouh_AB`O)qa z21<0_(d@{l%%2i6gIno$65Z(p1BGRv6+TmBG3klHt2Oq;Pt}TI8aXwF{)DiQJJ4cZ zNa~4=GTn-#Yx(%GKw!>Zv|lBb8g9dF?M>(v7QS1JT+uVUa9BxCMooasnr*cyY)p;p0 zQjQP1up-|mO+=4`tRpq|l%{-)eiDK3$?XiF}iB}*KQ{zrsBX1GsrN$L87-!p;m&M$@_amXoKrjCHzEd)P3yv(0 zH?^)UD?)`{g#dMyEwUbZ!pMh_M$hhHN?rFTB-J*dogC&jL%h7}6Lz)_tgGpu*nXWj4fzJz zFECUNVwmQ9ex{aVC(}S-{H1GJg#GWBt1816nU@6a9PZ2lrUztF+e8s z+b-9r1|e6L1wS_SojP@WO~--zaTMRKXRM;L_e|berTJ5EI4Db~>CMaEhsK5A(Dpqv zd~$6IIlS@y+q4i9?yTMMV#ZBBjUK;V*#JW-##ICV?1-(ZhKsr*l#FlK&pv!B_?A^f zp13xXLI1}TUqOdE*F3?4d!7o5>>ml-Mai~`p|uH)7^s@YzBXq;QGK-emLr^F2GRFK zuG}yF2mNbQMO?MOSnXmTd!?a`j#qud+7BF=2__lxyyAW35RP&yHIl+iQkJGF(LYQk z<;3%{8EkYMv{sLqp(T&KGmk^?vAvC)J@d5NqP6g}?5S>2M;|7aDRYgr$AXsD0`?; zlM^jo%k%4Zj2Fqmot|34JE7>ddRp>MAUUCVLy6qlZ)LCW{bD zH2w8$!CCl|d3v(X=9^r>{t~z1pGp&kGfvV`kTQgbrhEYo=Gr!OoF~z{UYzfSPPRth z^(?t{gi`B?EgZDu$d73g{Iq_}xeWDEqlb7 zOhmox?>D8|R8v~`fSoiH`p9t8aa>fwxTcCCpP|R%9eEdI4?xFrr2$v)e#xqsLW%sER1ClyC%^?nf3+ikB?-Y-hKu8VZ>ZBW z9HY`$$YCT`fFEXNd3L<4(}fgTDE`~2eFin=K)uf^aslzotnzkSOUrqHd1P-LE4xJD z)Rcwn{PmyOELZ78T<%Aqd>z0i58U4!0GG?m9!Pas;D0l{dtx>}X_uJ4W@7Pc*19%1 zT09CDBGA!2;n{1rP?w4AZ9m?#TVBaDlQaoLA6p$ck*;T?NEKHl-p<~^0X+Auh!TxW zNA7GR`O*c9j5kWF_Wk&T(re`@BbqYijGKp*7(=Do=3@2ZD|!n(xj=@Y-}8RYsXAtq z=y-UJYW&UcAMc(^pJcL$)&x|Jw+kmtghFiV1FL>7VaEIMXe_4fB5oDbqC&K-M}J{1 zZ!reQw&zuWkdx64kC|7xFfL%nL|qvKbe>4%X1!?EnDe7b*@eB4xygFvRIUhZ zN0ft>i?`olc=9{!wE^3WBmo#X?K8ti%7F2UKHuoAS*2=lq8#f*NAin`Ny&c&#tW_XTE zffSxmt_goa-{4QY$a#{ejOzeYZgH0lW2#vM?e&CNuNwO^*4caY0v$H9eyPLSsn2mi zQlI0Es7VE8xvTlR!xjh(G{()wo#}rF8qPKo%vWshdIL%4 zWOV&W?4w5X+Ezk{uXNFq%UL?f%t4Ur`c`gTl8QHLC*)%4Gsndw@9S$b`ay^c0 zhG8!FzJUF{>?Pi}y1)!H6SKqZZ}sY4D8YIJVen!Y=DUJiEx^qe`%z2{rl+)4CV?Y1 zkX>eP74SP&_^b19V+NFzhe`IX{ALyH`6Ima_kULDPV?Q(fJ9m(cBf?2y zxGa4r8<)MY5+Q+pUC}h7q-4QyMX>Ey=R$E@P7D!FC8PgI3z z--fgIYTjFUt;BRX%&evHrf@=2S3iqxM{D$GD~Nj!{R2qMeT1frWA{}jtHX2G(xT6m zz7EZL`CJ-qB8M-&;fKidjd%)(fU1qBfUiTn4jcOk28>KWn8T`m)bNiz`vtSjyBSY{ zA2}rzIX78_nK=Du11Pz=feUYE`=`v#*}8pkSChxK@}ooy5Y8gkld+?3YG^SJUUp>i zs#x3;OAj9YHw+mce<#;il>vwe{{$juE@OXRF1a_2l;6j#nfd|F)F_7Sh z-~#XJAy244i}K40*h^mJIY08%0r3ALE?#XamdT75Feg zR)#M|iP^=FN6gKF8v4zjK~!hb+?(dN5FsSMq=%>MYddN2CB5b44LOeZP@#k!3%<;P zjUVd^))A{`5jAEfH7C4{K*J1Hqbc0Q_L4+M0<-zP`N3{=QC=pR4_)8iF`)^t%v)V@ zJJdaS+>Z-%npz1R?warUmP)iGj3qXf+dBXgDn+x?IPFDA8_858+mT3#iGbJuS}ep+ zErk-=j4laOW%c->3#gKvP-37bO*Hc4)&|CrsJ+{THi=b;sSp+*h)J1(tD|xXehTZt zHX(E66Qq47dy?^Vrk}Tke&3MyrpwIGe;^CFT^CFs`woI8jvh5;JSifA`Odf&iV6a; z(5+s4(dA1g@!LBQ%M}dp)=)seb*bZDhZ#_RG77|gi*|;Tg3t392Hku8^8YtbV{hW} z1VJtouO7j7XTE#ZJ96@< zPdem~4PQ%Wm!{9&85;RD$x0lS_R5AgIBk;|T@{5&nL%P3-OR4GS+a9ev~ z#x&zBZ6`Yuk6BMcR)^Q{j4;8y*u-z?ztEkIp!*3FP@8F1&9sn;n?oa2&U`r-qrEWk z6pwl)Le#PDsK?9_C$}%C4X?i0!y9SdR7U**+`*&LHgh+k&6u@o2f_f*omJ|Ln%{|g zgGt3bel8p)m*~9Rc^T97N{q#@SQP7V6jlskTJ1m3DyL;88Xe<4cX1Z44b%H^WVc{yIZX3#|LDlSeG6egEp?S*!eS(Rc}-WEOPO&gTnLn#RB&uzF-W zq%6PdmE3JehCulopY)}oQg?6#zW3pFQ$N`q*u&_Bjg69#H^nhnCz&0eg7LDphG$yN z%Wbb%i$x0U^7ZyBO#0QFWSWb<7opD z6GuJkzpfCgmIpk2s-l8`qP%`K94QRA{pAnKkidgYu-;D-UPrCg)=3T_{Vs4gctd=! zRWk{OFAjy>e96>jdrMHCitW+aS-2KovNO$RPbS;E+8{CkasH{p>Ibika>C=;Z#|)|^>Bdd1WkkfIQa9gT z_ezEU(^6-lTZaLH1!YE%mgMP&&|Lje`IuMq4tFsJ6PnVLF%>wg)N*6wWNN&0t&Lj= zUiZpNDvl_M+*1=O=7{mGTINiqdn+ZGh%E}5L^S?SUgFyfN(2V~lLp)pLelb!)?!8P zbmfJk9K-ifAw~<$v2Oo$0LjhT-keMgg>G04j!4Ts>6DnJ@8=qVoJKMyN~DFF0-oGr zw1F)V1trx-Z1Bq+AW_V8h0IYBk(S-)sw|xa64r@_=H4wUVuP^PpHOF!!!5T8rm}bV zKpYZ>)O8f8u-V% z8-WVno{3$MTk5WRG(Lcg+U+!pP=aCVP3P;j-u_u9J|0RWOIjPhmuIqPFtqTw0x&S( zOb1qB5na=IVk2Gh=J{j_7ZiG;B-DYuTIB6Ta+1joseLEaWXi_g-f+{hpr&K5TtKC*{LQ!7e zrE*8{T?X0J3w7$|9#JGR z%lPL4*_zRuI(aR;uYsZ08ub!1z?bb%{FNZc%($0AfjOdi`$u78gr1RHTC{pK*YKy- z*-AmqnlpCE2Lg01zW?raVk}B_uZp$arD>+YHMO-R;hrY(e@~tLtvlN#+5pd0)Lnh# zPb*5@r500DTC0l&3KE#_s`_a8Wp#W%mfLb{^3!dIcNW3V-x9H{47`UL1x5cpY$92Q z`d0&2ym_Ch)!L%kk9tQV?nntUmY5e$SVkCKj1A#cRK2|S6S^{eq{tflI&gO*!gKk> zuhE~8|3t+i>q`2v17|eQXxLlNazC=edgWVB#Uh()*n)Ze2`xA4HssGWCA!#r(v;Ax zr|kNZ^w;6#AoA96WMT{Y=Gm_S1cNJ(%A+~FD<8^=!mrU(3z{9F0LF0Zw;`bAj)RP_ zzi_&q?U7KPhtE1zw+l?BM~&2HJefmtotrI$L*;Ue4K8HNhCKU$rcOXhMMF*VQDtZk zbCG+K7x{MyDn!T7tDX|c_}T%6`m)~;toktl7+jICk!q)%Rc-qf*kj|Y=GIpeEXp4g zQS?6%s19|K=gG(wRxg@-v8q3gt7nL-16+axaS_k^?w3V+x3na4pO)6XlQBg2@lFfk zPE*;Or#l-EpiH)cDwFpo6C|I+?BNkCE+6)KzU%Cyq{Z z|LNS&xf}ajdL{@#LnG6P8tZ3o{)v6D&i%EgcePErt~=f zRFqGCn@e*pE}8a#53jY_jqHWZ5;h$#yoe=}E*2ppPhnwl&bSX*M0%`mpZ9hB`TqXe zM?vX=yq)Q+#=+x5`J)hiGpng}RCd#CX3S25w}#fw=eTFpwdj^@B0U?M8aW|?jYk=) zd_#wOc!RI!(xcMey)pJkvHZYRp|@~>|EQ(zu4W&{s3SBmcu6l6pVPV`?e4msU#y^7 zVrsU}B-^~m^W)DCpKAy)og~m;OqU~mH^et~1|@87n0@!e44Cfz0XvKt9p9DGeJd=**gcydEMA)*h^mY^Lmy>jFWz!m(PO%V`GK5BQ4n>;^I zB)Yp(XHEOWaMa$Ydb1mEsi;a&>}L!XQ=6PVDD=?<<(!3v?pQIIUA#8s^%j&1uIXbz z?XEvAMwxHRKdPznkb7x-I@U*;ro*`%U zVl2Vg_uXBztr()mUgb(j*Uiyk+bpEnT`vEu+VJa|*(cVF1*&4-ExwzgUmFmyiXYj;mD%yN@&1VBm$HhF%zWhbBtTIrFGg4xs3`uuBX_I>5x5;nT?;?KZ%z~^b_|%oV;;t zzNVYEAYAW_SM0ixFCy{yn5ieDVm@unB9k6*^{%DAG1SN~av5;($!#V4(yHVs-nNK8 z<5@=Cf0`;QpD4kANg%$Xv$eqUCjKsyPXBJz`Zf!zY44e?pb+}T_NxK;6u(Bvo(-fr zb%P>6Oi*uoTt6BGBI78`m4R$_sVEuV#Ez>cRgAokW8^lnBr}7^9EKF-4K4Cwk-!sW;6$@xnRKo0ZC* z_RcKG|Gm4M{2a}_N59sw@zuXH;y=!{A2A)DBfU@G7a0EoY~EE&?28pU-KezOCOT+3 z9p|?2#-EV~G6$;n*^+2TsSsv{R7FwoewW&`4G^br(G2hDUivSR#Y8gw=190;Le;kh z@r&j#BA3|p((vZHw@jwTi-4#kV*LV17AZMetaY{{qrG18ce5sP9NyON9$tqV9$QAW zu1bI{H*WZyWwsqF6O%K#rsosrtGN-N!@(2wl~0J>V_6H+e0@ zWE?~=@PP=6llyoW819yx1ZNFBnZsQe*4yS#xuOcUH??c?5&hF+SCp@bDX0TAC)Zto zR}4*oGN+IFSNRz+;(vFZBw}DUAYmJq7>zn+!7_ue^E-;!mq!DtZiC`!3oKd*t%k}k zbJ)m3Pd<-9YNAs}@{iF8MI=gp`1^0ha2 z7BW*D*vJ9;jPgL!<>!|i3CMStE@zpR$E(W2m*8iB-G6{kgzf=*8E?^Q5Bg{RC!<%u z6*CwfB-spn1A8frmIxfjfm0$S%3ORvc;|n9Z$8pf!cF~9@m>I*j|#1gA=$4}zqonX zGTGICRdV#{fn3L6Sx}6y3-DIntEh*a8*bJP88>ml8gO>j;T@b|s5X9N<0+Icn8C#v`KB}}E%^P12l#qS(-@HH@0t1A z4-tv|jPfiz#lHn663^%WfY0G4+hEn+n2CkJ!Czr??30v8QoV6M=yKIo2 zy?Axfe6C7-+)%JvrznQ2WLth~VMBgPK`p}aSMYly{Kb*LE)+4{TOj`X{w}Y=G2^9a zxi}})MXVM3p_N^;0wwCVlsS#jwW%L^n)FvpB1#DQU-q@}2p-KZBU2KOceh6) zfVW}z=(Txj#H~(u3YK#J+QNiR-qas?lX};guS_84Xjj1hs0z3&#S>&WU4GXsSO)7N z3wmlHGjrgUssc5QD4(=+xibvpGA7aSrrJ98d`^xrYgKti@|hOa85@0>YRDLk-i&pM zg4(aCN2LM|#PsJuLp>x>d8<;)Ic*vFFNbz*ozQq-<6wN6=P+4L(5|23+p6(4a=bhj zq09m!*1DY*J%_@ZTPtGX?NoTiWS=4PZ*A^QK1omOq~NC&CK#d9x|o;@Q&M*P(@Edd zGpv?OMzZKfyeQer3!W;%NC#@PpHX9jK^rZ?_>RN9i%H`s-*5XvF~nZ3=sHT=b8)Ob zidAfoqib7}Zanb@QtVONcl?2oi@e1Jtdjn?(8f=4Vp}PqZTs`#{?z2Hm+T+s~r?#;fd%&H%p*`rVl>YVrxXrKHGzVsYm{HXWJk9vsPbMSE_l>y*@ZHz>eG(O}=%@z-g{uTXa#8e%UU zTYf*m7`6`=*6y%_C4nsqg1E79#zcY~lC2HZd%VxTl z5P9Ti)M3RJrg|3I%jQsPi+C1f$7J9E67IaLkA$SX2rNDyO|>UHXR0`4bIy`;!jDBD?R9AlAX8hzTjmzPw8 zSK35h(_TvlHYb-8)T@sUpjA6Ah{`V?*^vz|+`4ur3NnwsGqI^-;w*~V`c@bo6`u8$ zxHHD(?%OxW-1nO=>j=ntZA`=n!o$&$A7y>cS#psm74e-+g8jGg9$jI%&puZ=Pw%&= ziFg>#Co`SD#l;^55){vpgXM_0T~#a;tb!b{WdgQWgK!~zv$d~^u273Ue#u+mO(UYq z|8-Nen{UVD%z0MsXzZj;GOzPxC*Ce9(l>0Mc9K2Knp(`L7@lLF2`Y@;jOQ&ldbnTn zdSf9$e!9B4pC%#0HvS6Y&@1|ZwSPc_kdMl8varL_9qB=9WAj}K={Sng!WI&0XZ{37 z!R>|Cxxsf5$i8m2A9FE+fg#*kbSyz2=ppA<&L`!Z3)tKFjD&SRyq1J|1cVU>h7%A5 zL35BFc%k`m5MVIP2H80u{N7lAi`ECfcdy_2y-$Q;>%si!49y`QjUi+w5-oO^CWF6E z+#81ZNFq1cB&6!=`Q8_PPvJoS zl^^Z46BQjTE=hw_&ClLQ664fZ3s}L-%?I`FD)=8OvTqZ&RZ=?DIK-5?bMF0rel_J8 zHQpl}filPpUiZ-xhXjnFocRw|EHQ|+i2`xCC321ar5S+4wuf#lt% zu@0t#`qd6rLQ9w_h?;$(tJA;mXpQeneNXrXUl?1b$2K{qH3>2#CGNB7L4@;tfnE%kf>*|Ub;0Wb`KN^AA6BN~#%h)-=3(ET@7hdc zXu~&U`>Qs<<@l1MOb9Q!7{Sh>pd^A}LYS%X%H(oEmsk(}s*x;9&sVVbQfS|qeapx6 zdp@;(3YGPnH}#FrnWdy2+8~DG4Lr5Aa=kbHT5gJ;PDClvQv&B!d?%&W)u@qSC#Kl` z!M%kI{u6gG(fuDVxb5^0K;QQ$LaI8>c~lmnM6ns2oS#JxKg~OmpHez}?6D2bj)!^7Fm4_sm|7Oq7NbzItP$n>g5;%Eh%F`WB!8 zX@zeCKwJ!29>dBmq!1fOgsg2 zV_rY%y9MOP_=yQ?;c*_KE>IlC^IP|sIBvur^E{=87_Zy6g-#olF*Q17JT&#ZC1!jz zn1f2hZv1L2RckFp9zVa>$T9_IyE!GuvNpNyN_8Qaqh6A}SP$>k(9zWElfa~5$NS40 z!Pkujq*2DDo-(@qz&7}d2gd1c(~{Jbs^J7R^|W)SaEQILq;*S*zR}_c)QcQQDyy;Q zJ8knXqg(F6sOZ?>lMU^@a>f>yEGP^bQOLAEx)J7Ck2-1rZ&iF69K}q`MPj;&y=sN5 zEnNvJ!+ss|h&>R>ZhA)M#KAX5&2^)_GgYqviKE)G`z~Ifu@j^N0wlrNIdA-BgG1O0_NH>A1~>^Sr+}v_ zDpZ$vy9kZTCVjzZzNOI86n@d^T4ubq;K%jg5~cFIqu|Wf5iUmiW~$kHuIU;^_9kBu zu_IFS-@1#0>a5D(MC8g>0f5;xrwXtsoOGsFiqJMozO#BzcUu3}sChGQmXBa4BD=;J zCM!$Owcb7in>bMQ^jO>FB_(oNHR!9eJ^Aq>fW;9bX%ALfJaA(&Ns{wZ$`@)!38!N{UF>*IJ9^uO0&$s899>GBHn zI#c=lyb*TrV^GXOE!gy<`U{Totnh7uQWPxZ~6eh zFRjJD#@HE}rLeqVcfS^Zm0nCER7r*z;Ji~+BoE4XZ*Vo=bRGD{YUnFAX; zWRx~vadIFX+s^qrg7D1{BF_hA4~a#5IrgSx3hTkT@F9=*+>6-vd5 zlj$;S!slX3wnHOG_C0UvuL;^syKUvJFS9Tl54Kj1)`awB+s(&jYHa!$C|?&lzrH(( zaOG%`F{(k<+hFCDu9{%4jikiXZFVpG&Qo6HC|o~r{MPm&cxK(Qr9survf4aqyc_BBk3VR6($4-RU!CVCOgAjy({HR21-yr>pm zi}Op;kR{k?aF5NC%9-5kjP!M9*)@F_%_5Q(!pqatdwmb<;-)emcc(A{h}_2x9%Tt1^g#M>gfFTEAy-{=NWlM;D9f@dP>G7gA3^JU zra8;#JzIj2!5Qdv*Rc7ue?y$2kH1ttis}Jo|A0ps*@e9;EeYhOM1q5+1O3sIbp{D+ zh+2upAV-Z8V*ugF75U(giwhVztu3K?T|O`B$IIJmZG3c>6L$iJam&@W3(HV4<`gCT z;!ekAN)^OnQ@LlhcgU)D?x_WE=8Wgep=^|r4q|8F8o1v zck+#HnLOVbMPzo)WOAQ`v_PPHz_MZh$22oAG`Hdto@gb%AYD^5skW7h=FS3#SM%9D zotLFTs}cF^u}O*YxHA*81gE*)*B)rikNUY&J}jiEDKA@Et_UHl^syI14ByC@E2g#$tfs-y~Kx!4FVUD?92*Z5`}p;9!@WYPxobDdg8y zhM{i<(JkE-qti&Ybs-edUvBPE2vy9vhGX4dK73kP9WJvobP-|oVAvx3@H8H-zzr*G?d`4K77HCSObQ^j0#ygm)?a0#J&R=DZ_05 zyFUzr2DoKfFbn07Q~ND}lQ#CT>o=ws@2I3~kGnXPoD#7SU(U@!7shO1jMV8)NNQ_& zYMmNXV2(2KiO2a@>G|=+xw^IL2Im&Jk3476TAPZx+p?(kNZv6%;5fQ$VlmAR*HN9q zAjFNedcpTX*_67&iC7$sLljOty8EYH-s2SNvk*s%z-SC!W&9+wkB~R9mEQ?+hj7 z+9c=1OfP>|4eU?5@o5%(O-AlFz+c{C=t=cfI`K$;Eg)U*Ket!>K4|rn+kb(RJQt!H zH;5KVWAy#63%BO|tXAe{ZxIm=ZF+0r7M3Re1jy3$wjcM6UWdtPNXhd;xizcGbH)a# z#wJ;<$2bZB>zc<4#~DpI-V=+luLL7xsZ zZTz^JJ0wbk7Myle8vg-B613#YQ%}8PNB@ISBrB@^18NX!ph{CnNHGz~nfbd36yf#c zK_EOAZelf0rml$B7%%IA%eW>6$n4i1V{7J6xzovb9>{BH3QR}HHGp^w1$0mEDurp z*2yx5u$MaoB=HK;xDe)ez!ju@fIyIwI*e=Zm9^ZDv7j(M&8+~;w~Ag%_P-Fe{Qh@4 zR=f*!p!nEN@I*=l&x8>VwD4wr*!~u0s6{Q3Zr@D>J`n`O{s=F!e4eqsTf%#d3)8h@ zV{6WTK#;g+=NLO=>3^~cR0%-;vm!>#pWoDGI1r1Hn40Vv>M>C))?A+2fsowg1Ec!L z$f=S%_sk9U!&^w2Yj9%Es3&#s8RT~ePh0=PRI?Q|=SZ02nPb-$e7K!)_%=VrRlxXb zqc+_Nn~kA5r$mdOLn=0OdB^dm`-rx-AAQ%iIsh409R_HB3xdS5HxQoFqEsfgKIT%# zrNo;a_7fi5_+M!OxN+gso|ygY4W(K*-rIqHz-SCZj^}1hfmd?hZjR$o%^P#(t31&^ z*zH18@ZKgULq_LZ*CJRz|6-E;+@8!lW9jGUkr3sRjmwz=AT8=@ zVSQa?D#kZpSFO{Z^dVNTB(=NEVVh5`u>uyVP`d430m2==;t6hWfRQmjuon$u{!B{Q8R&o4SS4B6wH6#OM_h~(?)z+> zx3Vex-Ov=-Ua`aH0SaGq8yQ13VHHBi&I8ApD2v`ugooOK#yzD#NV`)thRJBkgdttId`S42YA0H@WdtGg6J2Lk`o)CLW}vUSIM{9lWcX9JS`cbM0rb z-q}sIIZdZ2!z@AtybAh!iBkFlhY!pv5461hv>5r;!1(0I-FydzYtFD1+R6lDWKd#S zpj^jr3LM-GLtN?w=b@maZMB?#7D z^j!DsGhN10kxp?njNFNfvZr3t2e#(~=>GLoPbw%a%8iyrsfg$flYUkTKL&$nDSaf#H6yX6V{&T2@4PWu1Ncl&8aveywyCtKd+#n^uls*WcFl zH99?sOH8{4XcgB7iV$ow4x-u?or#O47-okuGnG}nW54=AgB;b( zns1({ToM}J?D{20%9E|pQSQy3e)>qy#)eps3@AJ{lb)8K6|g)9vbqlXvD#yStjV<^U1Nx-p*jLV zXtNLz@DB0JkDQwZ>DgDHuNN12Qw2tD&UGxKaNiiV*vBVyMRhdChK^kOFb~#t!S!7yl7fnYc%PIoQd+^*HLn$oAFx2W!o}@HDjBjjTHqIxEPr6cb2OoN$_rZf3hx?GJUT)>hMrHDT*~$?BnP^ z0G=5f+%3^i6NTgE;4k;lbViM--bG_km=jbYt0o#nHgnW zzD7RHm}CW1FBP#f` z-*RsB+88b~Fd8>|1w3*dG%3V*P5-5q`RjxIgOS_O;?~DjcD5Ifk6QmeDujQUFHR1R zk0-4bq&vW(7adW`52<};o@Cc8E-wrjYdbxtmDP8A2XtAfpM-}SlWQgLW|XFQ|Km2yJM`6Y+QS(Zb}_>k+*DEzp&QGS)1c%wDbZCF_v6&u*o zH~PQCgECSm#PoRU+$U~6G@s9pwOLJ=Ij@k&sQJjt`C=F47rW(`h>a+Tji{<)%IYnz zDWCgZl57)RJjz~Dk~}T*IrVoA479o4hxM$cSmv;>u!V$4M$&Sf@z19vCaNf_swK~M zbL5V;Xq5|Wapbj~AFh~X=Ug$B?o)G&R`T`uooafqQW~GJY{DJkO0${ZaZbI5VwhL+Z}Q=cNy22yRDF4WHHNCvuj{{+lOI6qL`v@RqFa z#)y$2HK|6=)FJ+Ql%-;n91LtP4pXU^UI|Ul)+pO<@SCkPJBCpXv>02G(aoA$lq~;F z2$b2y8MTjw<`hf|vRB9v@_W^C#&ZajMy4IFLizfQq!w4@VW#SKoA7SbKR~Jgym%Mo)Kl>m87^`s4anCgPN+qSv{2<^csJ<}@ z?fssgsGc2Kgh&*7SQNxwZ1FcAHVOF;nCV9vxv7f?WX0&$>b3~V zkt>qnyn)6J1)D@{qfq z22RDOLT|ro(cj5=iVpk^V{aYW7 z*xmE2zF<1GAtW%G&-T@8ZF<{oI;t*ggRUVAz&Z(riTc(U7y>y0?3W2%xDab`1qC7H zZdwb6OYCz+RIiU0WIo@d3nZAWRv}31lr4{QX=(IMD2iXES_G7}!2I|~q5NBNIOA!Q z{wtTVO|y|{RO&_1sx6B@{{i@!Sn*m3Y1!h9xmdAZSc9!CP9Cl*1cDo4b>v2$5>+A2 zx;En0-_BddcosXD9q&*89q#Yte0R|(AXNeCv%Vky%jM@|LcA)^gyC^QuzR8iA$-RQ z>E=jW<3^S2V;1BX($Lb^-i63IR5Y_RFDRJ`ODx8Qz{1=7&ZT{8_f@QIOTf6-X1MF~fmlW5K=$FQ|y4RTmBN%rI z{{b0X@IRh`5pb3x$fUqA07$^#eN;q|1~{XUr2%h}&Cf0E3bW=|Wte9w^+N(z#Jo;C zj;r%*)V;)+uLU)hTn%aKV%^B?#DowkP=OCvA;**_Fly!hZ4CY?ub|{Tcv?jj_4$(- zZo}IclEJhsc!S#w^%YOOfCGcAobw|I^1RDa&F28WITCJeigB#gJ!FnZN|BtKneC_( zdXmF6>^$b>s~H08PCl!s3mMFadh6!4T!@7wNbCV!<`8K%*yCJQH z;QmolzNw%I#-ul;C6r*WOfB}h-2cCGTgz=INuLeG0$v16sa7e1*!YTj73bQ@NE|auQHGTqkq|f2hmBELmqVGtt#`i z7dFB+(wmaY!8XQi4a5HT&zO+6E-qIECiPq1Sz+1XsY`#p!sR94bPJNvnO<#c>R(B0 z96KR*;r->&6bKLhy?GT5|D{81#=-Cd$1ASalU3gGxrC># z_WHNVsP;q*d*t|lm2}TH+?kkVGYn>V6anjN+fCUSeLh5=e`8(+nM>)yJc%c?+%?95_QrSHDeEsSPEn$8?2-UunS{L!hglwS$Lf!=Tl2 z^DFj_q^4Opl`Y~G-kxh&i&tE;@=1LQX@=hI^^yL8<+*{q1i$2mIiF;fWWbg`wh7CZ zaH&QsQL%zUoZhJ^oQtedqY6td>!#E6XDj^EhvA-Utvg$%W50i_NaXXY9e3O7d6p8( zsl}-Vnx#~}=B@h?Gr2OM*pH@GY?-tgpRHw1ORJ;t&E6kJ8_P*NXuC1k!dkK=d4tto(>TM^#51_E?lJ7nGiVu+pvdr8^NvU7N(NFT>E!(Rh;85j8tKeBepvIx$@(H!in)JlP3Qxcb3@21(Q&L8=sKq&Icdc!WYyn?DA3Lx2wk z{5DXwz}joE(MD1iP$2!Z_BbbbpZcf-kdJEZ(&zCh)ClAT4zeU4Y75wcWu!4mnl%?G0z2g=ebF1pvD4fGP@y9WQFVFcdXB`>5id+Zj!8;XX_g$q>Ak`nI zn7NMC66FJ?(_vGGCgf;#(bw#V^vAF@FC{mF8h)>o6;W0$4fg3@rbG5{`^S=RPbFFs zpvQZ!u*k`qKRqxxD@KIe=*x-ul6xY>FSj@+uJ^0pSg)wev1)c0ta&T8Lb97u7;4## z0@q{^{iR*a!h(Fo$SHiNGX*NaXF4_hp`^x)KArS0QMdhmDEHIfJ3V-tB)Yi(3H`t) zU{;~TlF-qxpeTWbGQy#Iw$j9+2{`H9qP8%PQk&klTC8^e`rcq-BcsgW*&yH=nYO{I z;%itMCkGBj@)X%6eMFW$;X25@Dp0NZ&bv_-*%?@9MN>X`UG$A#i1BP?BLpx-6M!|4WNO(#b_oUcGVxPQbSyD~r8n`+sOe3KS)9-@ui~ z>oFP1$gW*})1H>;wX0U?7VTl8L!g z>N!N}xM+q`AU=EE-x_ix98zwlQ3d=JW7shCP()+?RkNSU=1Og``o|<%x;w1ir`9ux z%Gjub)fL;uzOvnB0-2b0L%m>aArtM|3dOeVm(qRYZAT`?o8YeivA8k74Qdq|HA8u@ zH*;&>iP<+kojqWo{t?0isYn_~*lVBWw>udifZ_cQm;#^BVFI+Mk*nHhQjj6~RpkVO zpV5EbZogQoZl}WTWg(k}z_;1Cc_cZ~(TT0!T7Sj&#R!nmf27aEpb(n2{eI<-i{@HY zHnqek)@Llu>J%l|VY4)M&71hB3a31YM0iU<_Lo$Mh-R{Lh(z!uq*H@_Zn&@nf6KNC zde;{}IQR7afqH#W^ioJKg>RGhF_w|^V8v+f!~ne?;)33E{}RO_q9{Z`VgE^ng(*_&q{xKeKvZmolkiw2oGodCFMOyW6?>>!NipBQ|3RoWWOOW99{SeY`R-c32=ESALb~;$rno%0sA;%oPWunoroqYYSDfqW ztQ#@Ee4EO#<@2SlX20$y^6Nj&r54_zl68I0l z4oF73N#|UP4_5&4Skgf8>pbI&TJ80Hi*2KTfFEww;!D3|ofi&0J2G)ZMAq90#U)#> zEY+l~g7pP>f|`yUB6q*73d|A;Q)I&C<}Cg`gMkei3wyuZ{aBWE0=8G?z(O8jD=Z^*u8je#^2mQEksau>&-tUlk{<@HS-5QnE zSsJj1*Cx$>t;peHAr|PZoGGdD>J1n6OF#VB?Hytd_1tj>!zC9*is$yHpwmM;W>vRu zf|2Kx+@=*~G-&3&SXFhmlL~sVSf4oeJD$6nCWH{D9OScx+l0&s@%)WYI%Ihul`QPD zbP8GIkZMC)ez9*xm9YEBGNx!byJYNl@s}@QY@XL6_V0_sCEXH}`Hlag9uMDuJ09C-$qD@XPKYT#Qm@MD^2Vdnp#Tu{UI=lo^+0OHwmz)C(X z`+z^yFZ$z_SA~(?*+-ifAzWe{`ZTR3)BzV|WbDeNDm=L3uJ#IQoy$I7xl%cgLHzT; zetjO*JZ{lMl~XLpjjn6x%|h-1KhhnQ>9=d=DjQ#*j4|*Ffj+Vgr7s{zs|5;ptDvGYv=w znF7vwgvC}oGoSl)i6@Nch?tJ0v_GC6xMz@_I8^SF<02=#&UAV9Vuu%>1BI=W zip9UkpH(fAKQOhvj5ykxUlB@mSoMe_IrI3)R$B(@bwAoU1q=`>I+WMU?KLH&`m93z zI*(tZcuIT%#=iYq0dZ2uGtod1!#6RA4(HEq`dCiSDF;F5%N4KLl5};s_&7ZqC-LIZ zlF*{3ocK#8&kqKF$CJFqbN4*-ayY_RvWIRoIeWCRXp8G-3GF8ds$Q>g_>zKZb#Mz~ z4T?K$zaAlIp4IR+a3XC=VF5a^ZU8rUvCN!C`Z0!Z9cZBiG`C&lZJolpFvcbgkCLW4 z$1wJUi9e8jpqj+em36bSIvqOoX38S@)dS~SNT&Sh|3L3qh&+kbDb%V7GfE*$ z({u!;T6M<7E!rohw{$Y6M<1R@U)ATtjJIbDPPL5u9aVbiRsNPSxrv6`(&M*U?q64f zr~r{$H5A}PNitkJN*yYW}9YuFgFY8LTLTg;;`O-^T7+l`Er=}(U^ zS0@DUc>B8zXO1JgJWf7-nYNI`4CbQaw$pOu0`hgCYp91RrK_UkEQX|iUjL#e-lMuM z`jQ1IbGR0YEc3&J#ds4gDWXx|_rGCdjpcb4U`VlyH`g5Fwis`-a!BUPWximHh56le z?LiTWPQGRcvQDh0ffZ>)Ru{JlcU(wE?KDU2TEP10fv(mcG%L=`K|<@2u$%8EIG31@ zQpoX+-z#O1!%QMZ1kmm4%#rj>LAfeQv;Z!|`6UeHIU!(y0^H`hZ6he}@WIi*;70_y zi?uXBd560K3Sj*a=wJ&XJrmN8@6ixlFnrhv%x927(l;c}L~0@g)TQ*np|K)Br8H2|(AyyalINcICHHXdr7tftyAB>@sM<>)UB1sl4HkxW5bG;tN`jT8VtT zYP|faX#XXzsL0Kl{liB`NM`(E;aN{~O@H3x^hDKM&m1zN#qLrjkbA0DK!*}<8yd)> zPk4gGeb?+Q6Qf^ZNp!+iSd^lD3Xf>2dui`t64mMP?%pz_Ryt@sY$f!e;)!K=xK_Uk zxuVGJyqI@|I7U*g;Qbu76>{F+#tPy0=17mwE#dxjtU38SBBzu*&Pb~Hd{u&@OUx|< zLY4rs_o*{MBj88u@9`G`iqJg;$b-R+-4>24SCf#)YVWt&8V=3)@1J>%7jqh$b&ZvY9wE8 zif;Z)K6ou$l!1OWs+Yz-kEHa<>cmk#x zYq!CL>5lJJ^7vY7gbSdFi^W#^$cGO9_9p=o1rCD>i4kt(Ym!pYnQqdM5On5PFd|5c zh$wLJcO>Q-5ETCbpVT82V&*-|R(ENh%gLU5_X-^U9=~G!E5)Io{|<*Mw+03BV=+IG zZ`U&@hG-+IEO)^3@t*3_P)gG4m$BK|=9z)qiGyz{tQ{&rk9X@fSEd8~BEXv&h_(?C z{$<&elY2MAeMjD^&n;tXt7DaVRa9Cq*|!(@iCjmx>KMHx2f~>t^6jY zr&d2p?EgwdWxJ~|$-+|nuI_vWPfn^!8*aF=Jw+@)`wK51Exm!vwlUkS?z@QA!ZJH? zk4^IDA-bWPpe^MsAaM2dS<-#(f9<6(jPA zi4U;yRq0Vc=_ul>K24-{v#mMux-XT=d*uhCdHFK~O*v7;v8y`bX|T`*zghoLLXn@< zSFF5Fd16@3=gwjz2#l{pNA8+TW%|N>2&0r@7&QB~hwPSckO0EDOes}ZCPvRUwTWH4 zFQ0%KAZLqM0QTty%cBl34}|*nz>sJ25q*%FE5P zdakfRqU+wGw&e1nW=NyxM>Q!)^wpCI3?j4fT2!p|Z@)Cvba1%0ep(`Tab0=kO@92j zSyPV40*a?QQSn5=bwk3lag*(X=jFJnkcsj$wjy1ZWFFb|fyW8{YMm!@NURRL z5mdTRfDHDz5"_YYD?sJO_W?kNj~f8av%EZtHUu$sP16P5kWpKeySxY5|*H6e>O`wR);MQvw+U#LEA^ms-YR#Lkzfz5*-@3%vQZT%(0CFmjnXc>p<>P_ zOJg=28~-#ix>7oFTfc`fzVGprmpd@8JaQ@+D{Ub&wx)4Twvk~vK!wsI6$ zEmxA|Lz=@nZiijFzvrc$V>N<%FoC}9+T3R2C&)1nhbWu8*=~~T$d1uj+V(pTF(Xt60c+%` zERYikfqqmpuu>bO=&E$hA%FKjJu(61HHzl5ci)q~gzW52jSYZKOF1RXd6v2Tr{|f| zFPOP3mX^($`7(Hd>^_qucgxS39h98*uT76n?$#VrE6%Kvqp;EZ4qfyOz2JJ{$)4VF z=8lmkH?a9#efTI!p9Z2yfu}<*F+5obs_Ni9FY5e#rO*hitOz=xl|Hs|%2t^X^@G}_ zuiq~5Td&9Fx#i5T1jOzNPWx91a%Q~XGGg6wIUNhVWvz8)^_J)U`{q%r*`P%8uvUHE z(z)qt(!ndwL7tJ{Gu7wdNCsGn2G_b~TyUE4kr0M){&^3H9zjjtkO>Oo7+R0)S?Q@z z$i0G}0|ygs6)HKl(8gLNYxdDUCR6Inw=Q`d9(c+d5oWJ*_?>pocuodyKZx(16eCxw zZ)goH8RvSk&@N$`p43asj^@m=>`{iMoAuk0Tzf=H0Va_eI$)|}_dpn1iyljEneCxk zcBBnr_(e5niPvJ{%`M-05XwEU>CaQECpk6Ywm%-eWXjxxvJx$^(~OuO|^&?j7lr#_qN75yfGMuG$*XvJD8jqvY}`!*0re*5;O3cxy2F5DGAX1 zdCR4){K`ZuZsQ)MC7A`uJ_w++aJfw-g6O|cXS2G zCsetL@IB6hUL1^>^cI<)!3)S09Oii-UsOSGks^*!e4 zZ}?^KEz;jv)wnsqHg!-MtFu6hY+A8NmaLSdnd{LC=>$VnWcS zDxx%jLoi$jRtU}ztYWT(uU2$9ll`CYc6DlqABS)I3Uk zy6B*{I;IRW_SXr=WrfguCEy|an+j(Ro>lIR($7vL3F@JbZ`FVNjs=e9-59lUY@Flz%6C9s1roX zO~C^QU0^z}q#!3$aOVRi*reb9f4Wx(gUIshU{$hYZ7wjH(x=qdE!e(OTc7} z79$g7GX_R9kTZeit`Y;7Mbo;$NONt@#ZD1?m)~EFTUQF5j*@?kkt2oEhaA(?9>JyH zI#Uo7ToG-0k|yAeu~*?gK(XBT`xDjKuP&bZpHo)j*R+WNnuVzWls`Syvt355SAFy~ zrHMo_^p>w^aAx?|jlWNf%tJBNEKqRQ&piFP!>ja>ZyIk_=31~xv#szIirn0yX&c_F zS}@#xNX>A+xZ;jYjr;03vEVxR>Hv#44kuB^EB^f}<(_`3CHvy-cmK$8WXe!8X<+6?4EAK&+qu3T*^sC&tn zACCR8xcXjIVb1#FgoI~WJI3zOS8Q~oV=MhL#!vinCjOHYHl+3JoyJP3-E@sO`rCB|Fsip(z8#Kk^<4^A z5+ny?@CXwYSo2P`R6?p*o!ZU>nw^nES8G}{^fyzdtr)Jz2o=WR8o4lGZBt*f<|ebv z$53>W{J_&0zDl84TnRed-Z;_9o(@gN-NauSJe-j8hDcqR$?lC~3<=x6D&Wuv6NFDs zOqtu0Q!_IS|FDzefUfh-*;m4d6$_P^4sJUN&|g*{tps!O!OOtDYEt_w*-v`i)PB^+ z#b0Iv4=ZN$O8bYM6dMHkn!8hYYdQ56nI3E}NYZc?ijm87H`65QS8#sycgEZ|vS~O1 z4MV#>Vxr83b!!*HK+%R$E=lBO60(rw&GgLS7Le96iT+cS0cyO5@TgO0oiK7l^YFT9+6 zY5lpG^$><~aq4P$mQ>brEMwzm^xlw_ok(K8d)eCalaN~3x}ZZ-j%<7_UWUiW*b3`t zcQvk#9AnQ?LLAMzkcLrS<#@lqFv|&eE}2mbl; zytB7KEs|8bO5c8tIIeO+;3E-{$K$O6k(57Wjd@ z{69#Ql$5l{|5|>93F4?_?$osMQKAk#{pf1>&HY3-B}z&*yD0_tv0k(6=_@Wmo5IA zCx70&o&V{=@M6!t$LOJS7B7Ub2cGsfm~#zTY5xpFOr}+1KqdMtKbV5|%pH2i!bvz)1 zJd?_Z0c3Lj4Q>Yi{Ul{lL~b7f*N_wNRSv$Q2rFs5>%M*}ate-)+H~TP@4N@x+^& z1Ch`HQ4Ka7Yv_CR$rj2DeeCIXmJt!PCoyHeBzduovEZWJsa+iKg`I>=;ttWE+#Dw* zg5pL~(-FC>^+TumURj7ivle{au7)Z@sZw97Fh9HaWz~emco1^RUIe`>{nD5Wm}+;cO%f~hx9o^PnZ1MqN8nT+1QDbnZC4#)a8n>ND4L7bazg>vG3;szHgV zuY0B+k~L|r&&Kda&(*#r#&N4=IoQZ{tNV+9dVexFj6-QYr(`l~KHN;5s+_}Guvz-Og#F)LwKc%bl5Xn|8I8?vr3|uL9G5?7@%N8$^ zBidF>M^s_7pPs?^ThCKzmbv72Kg(zXkN>OAX7w}rT45uFD+kPPRl|)AVey?1)Az@@ zu?j@sqo@*{2?)ji7jF)lubsdwf|osDy)QTb{5M+s*Ys$)2stU13UHzR?VEW|bs?>?d0liIVK6$YLJ^baV zb_%L2um}1?p2949(}ZVV9IV4V(TB!}b@y7VWVDdEy!oHU^ht1X%l`8Gyg?>rT7`-cDl2R@cpc_|>|i?Y~6 zuL3IQPtz|TM~7ZQ8{X)o%)|Qt^2~B$~dBv@4r+1|w z{?>lry!v7^wYQ#Ymhgf-U0uM_w$UR$jTN)PDg`DE%votgc!m@f#ayrJzz#H@CqgJ} zU9y;v97mrqA>qeQpfv7e13{6NG$4 zgU}E7e8QK%4H1j@10p95xfk|4l8l6q@q}1`SY%1VNjgO|5*X6NRVHS0v!;#~f~l(% ztbhMTiVnO9U1J6?Xqf=ze=<2FhT1W1^TrbxE?a)w2Bnp%{fcs9>E?AfE2ViIv<~D) zY+ZS_@XOf<%(+Gm(&Itx0*TC~dmHvm-E8q8*NR$7dRsQJ^5?N5`N-sQc=)Ibd7vHZ zi??t`tPl4hJgX91k{js@rXeYIHkGCv_61LSmWGTZ3(LidozR+l-FJb`J(I-Zu|s6?!`8`p`~o%wK37zn7*aurzS; zTzKS7tBvJJHbOWzEDg(KpvZaV#)RXAEA)tpN-W3v;()yBZGTS_cG*UmVS()Ii=A=2 z1|Qov~!3B@29j1(6dt7z0wCbHvTq{*qcPe5g}fMQK12ChE> z3u^*-GeI*G>T>}$LlRb;(;zj0v@j}5CQ@4uct`4@I|2g#THsQ_N@t>Ra1CHE2IFSUrS8orRc1nv=)OA6~iCQ~>?*lgh=P7Lu(N7rj;rW{UNlJjY|JJ&LB`~Du_iM%ag=klMc-xD0~a8T@~^MN@vsGPI`S<=}RUsj>%r`Hk$3A@2zc(q^vIS zC7xMoR#n(sT;c6^p-2ujsEpYJbei~9skIHhzGN_lgNz$EFCXnzDi|0 z7ECwj1I5^y%HqnOjyNQCsLt*tjOQ4pbBo)w-HLv?{5yY*nMk`SIuj6mTXRK-3wX}F zi)f(BPw4J6;2)zQ$gAJLS-^Kd9aPiPe*S;y%%DUAT(Ti6EdOdI5R5yqfY%f_DJVyf zZpMS=u_aKGar|iy2%N}tF#rfWK4Zh6vB)$qYQ58MvZ{I{lNkh+XcN;gh09KI1Gn+G zg^h(49K?gm8n(Q7iOh5LZ*G{N0348|Y>DTby+)J?Y>>8s!L+;pGn=&fz+Sz3nohz&C28|hM* zkyGv1Ju|=Xo(CwcqTvlX{YpE|p6+(U!@nF$mGQ%=E9a0X%lnse5)SQz=m ze*o^m65Ow#I3{P1k-#Ig<83T(aq+~NH-l_6dvU`IxppnJC53|78x42di9%i@koV;j*K6YG_SKgHA= zkvnI?Z_pnzObj{6BW|^c)$*zCqnRXP&RB?#Jw?V;)sRn z#G{P`3G5eR5m3(RdK9p~DjIB@2!^cCB3?GBFCkt&O4C4?$!P#bc^Hw9B+c_w0(%%? zi&s%A@OgU`!R%oNBb4GUpjoc`KUU!vo}equa~%$eb;RqJr07W5c%c*w?g`<>&+Vby_%a$6+GJ-E7G`DAFrPh4` zdQKOV8WIelmnEl?fvA~LxA8K*c$GHu5j;%6RGM0_@c0?4$)-1`^-CWTeB zrLh)fK73TW4aaQs-&43*KZ<2H`H+L?QSi0uF+W3*dV%1N264uP;rPHS=%&B6{!2Ov zw8@l?UeiG*@L2$+52~n6^wFNNfeiLr%T}%~lC%Vz{IuS$sb@mf@38SI;`%$K{Uqm| zeV+=gc5qCiHf}7&G#JZ^y&icAzC|w_*A~!)Y`3ZO0^YuB5^e(_;v6xA#pV%P8du_A6*eg#*Ul)1g zhnbL)eTKk@xt76YkL*C2yt!;Q5RQ2T#RA%tc>RBl8O^fwn-y)JOY1uTS)hf6iC zD=_DU%25ljz23j>ZWbvVY0+x{H zI7=_eB@TEQE3i=82Xge+`{Rb2^C+tS0g-k$W}jN?RF5kdbcRy+L)8=H$df!fFW*{V z=WJvUO8UF>%L&v)qv0!j|5cZk*YGI_>*sX!#GlEE5O0=9-rdc}gJxPgigfBC4_&hk zRn9V}nXfX$6Iu@3YuZ*mL_r=D+MBWYdQ@tBC-l21l`7M< z-%~6~UW8c8m)(q<%@vSm|IjI2Y0#*9zYx*ZVq^8>xK&5BUdtE%$0TWRIO@hAKh%}) zoNvql9%;)q66+ExKjj{8!tut%9!5?xwv5-Y@`IZOJ2FF+OTaHwU1I&MOYHOKN=Ds= zpo!Kug2m`dXl=}#m`jjfh=b-&YZz6c^M(zaH{IM!_}gm4{inYANRHzHq0tXdij^ftbE(ziSFrilg<(+m!G&RcUdnC zSMt}&2&ugAM%a$p2=Yn|5VfeaOn}x_mO-O|Eac-z%}$F?}`TM2~nMEYhVk9M^dXe>{gr1WAyqkax`?~5t9Pkkg7TH4kEX> z-nZ#Fn6`uE>n%r-Z1icl$FjIRZ?qNT>ggd~L~s2zj=5hd!3P0PyXQ9vd<(C4ojZqm zb_Dv}*3~0(ixX1RS_6ArOI`!lALvf+S!mz)LFh@4gmHHh@p?c<_@-0|t&Nb#ay7fX zd*pvWmQ<|BR*A){yziCAb+boaN*i^m5+)HjM3qP@kre%h)L*X%<3pf<%tKA?uXEpc zT8mVD^OKfZ0eK?O9Q>@E9vEEux&jD1g!o87@MB;|n{m+q-$z_jTiM8Mh;qyfvxgUBJuK=oCs$BAKQzQozNbnVL5x`gRW<@{~@89-!0HR40 z{}sht7WMNw1ML@8HVo2E342wD=W}9kZ}u4-sbebz*Ci9{POn-$UzQp(l6WL+TaD)P zhQS3TbGrpcQ3sGyQ^nKk);)DM+0CuA80(%CqK+Ni7$+Y@ zBlB3R3184%EH(^?dh;IkfX`VIX|<%b*JRqR(3a22Mtkca`uEmD1zZ8`P^&*rT3!sI zdrF&}yY6H!N|@$OpBEx^C?fs8MX$pp3z`LJI`J?8b-B+jL1(ls7~dlr4RMsR2R9Hl zFqBv@2xDClxj{w{1e26pl&Z!_FGI!|gV6?1k4l?A^zL8ihwbMfd0eAsFaIIOIs16u zYKF6I7HWBVH%2{f#*>zpQ(Wd|jK{Zqjn`#!vWTraPy-F#G5 zac_wrccOg z{puUOguUNxUYiLxzOY;URd#CKLI{^8ID6Nai}8yx#3G#Yi0md+OPxaJv{;~E>~whQ zP!G8zrHtP? zv?;^0-HUZS*{2+z67R+n>`ta! zLrEH*h2IwL84!W6PAi-%4?oupv87#V)jp<8c%|-}ubN@3y7CggK+Hx3gDrOPEroZ~ z6##CW0yizjE%vnaX;O(_jEf*E$`H5S*IW7{3{1E^EQvPnz!or73(t6tF+vLf zZ|_!tihh5BFJ{%=v$ZyNWw^5>C$7oHw_1H2DM&B0e=o8O6`aTGkA;x-n-1N&J~-Jx z-Ia-sSVpR8vN|^$p1;07(M9+m=Kini5}a~@$uMHsh6zTJ;G)!d{xXRf*BkuQ^`eDY zOC>wUy_YzNLm1x|EiGsJnh@j;YQhlc?|I&pg8~i!eQg1dTpaKeKqIQ8Y)~sGWPRhB zz#axb5ikQGc6SE)>UeCy+_@-R1*qy0&%VH(dQ_QWS6V7dULJ}=&>|Yr*A72@U6IBi zEI^6gw8F~T(z1Xuiem{wx2jZ7J{2_@XLrqh&~Ok}iUm|jS< zpHab+>A}qGp?8JU{c?$$-Cc9$M~xSu~=#!SzCFrScCE8bDqf9CumX2`uccZ8o3 zEXTj`mDz5XEqL*~{%FX>g>A0nRKehx?%}*!si3xCRjgsl9mt_p0Dm45(buQYMCYZ| z&cS*r5lCmbf-bcJ(lG==Iy#0Q`4HrZIeZ9;Wo2OXr!;O|2ybAmgl=$sMB(5|b3usd z5KL%>6avZ^qHiEa?GF&O7Bd{jE&w6{-+@qu1KSghETa7I75sUAr9_#|MkC)OnKvbF zyt1zgen^^yEX@xDRmNH(ZIIo=<%DXW@S8bbFkJHD4k=eF^OAk=(mJ2YX^vI!#r8do zAe*37X>~`L=R~%j>I}%p8DAYra#GfnVKM~y{LyS5H*Dna(ZQ4W-5j(1Widff&+E`S z|EpS2miNs&GZ*3x+-MI@!Is<)DH+k zu%fAo35}y~;)_DGp6kDD?i-BnVle3L%Lk$*q3*^(VQ57#MKxxe3pT)TfwEH`8#BsBNc34q3{tfD|77ACNU*@0B|zu)Q#i4i;UwGZ!dwsOZIW)@l932_Dy)p%7#$={3rqy(#R8w* z+>T}N`i0jNxlMyBKRVnGFHw#DSs~-bPe4|a2HA|8+1kI$8Z=X?S79S>= zSM1$CtkLK)Ja?5Gk#_kD^-8Vw9{lRCa)|QrE}8?xtTu^25-S6Dq6I0mJ&2q;31)o= z%mi#={INA!M-a1remKHiO%*O&opc=eYYRb>9jv-QL#LMJh4o`wIP#vtTW?tsL{oIG zkGI>65e3((7kCn~PZH7#vl5 zW5esx@E%p3H5b=VwnK?*{I5Fbd6_`MpxCl+qV8ovJtalMTf1k0P~I{?klu>bJvOS) zQ`COF{%Gk!UqRof+5;1&5}ndOG!CQfb5b9AzB!>DreP4rABiiVzH!0r(6rBClPI%YlOq@wGa42N2&_+*yY&~+u6s6gy_NNA*1K%i;PrbU z&w58SPN(6%-;3NRak=M3WA@L}jD!WO8zN({Zh=8M#y5U6EsN7!EpZ&Y3!-gioB;+I z;e@-`cW$cQXU0)hW7qo-fjN&PCh)SY+I6Ueg-w@v2)o`Zbh%AZNzf>?+@+($76TzV zRB41=x216y-(0D!a6P|0APppMjJ-1poK@ciV)=0$^6FKjcoCwoCnYj|^(U;_^RSg9 z40&^04ZnvM7ruHJw9gh-q=o`FklMc+%RLu`$FqwdIn4`&9yWkv>RBB}Pl7$bsh^~o zUS^+ef5Q88dRHHI+(h>{e{$RP?=;LfeNZpc!4|La4*s5&CLMnnjWO>Qs$eis{8Ui9DiD`v+$*PT zkjU-Rr(H41en#ac4szvKX^+waFHfD4m~L{bhFdQRerP-7qr5 z2j~`_C+U>4N$PZEw)MZ`CyLEIXnuWGiL`1*^_y)kH1^GH(g7yP#BXLP{cF)8q^F+X z-9(w!xKtx#>V~jo*Q3{$6CI@M>2%2|z-x<4$<1ar=Qx(p?mVT^Cxc7{D*7c21-u=VR zrXUGr5;dOsTuASV@SbSi2#xZV4m?{sw`?0;W6gLfRq8*PC~o62_4#aJO!{8&i@s+C zsc%1AnNCau_Zd82opExGs-oSDw*Qf4iAv6yhWf_Sv_G37sZ_+vm&1R3|4}ouD>bU; zW!4sJZnI~LSmX1Hk|}9bm#!ujh*K7iui7ZMF(`TJbK~H;yyWxP;I354X4H?9n;Z96 z$&pxu4r`-bj)p)YyIz$B2Z2z28se;gNl3uO)PRRL~-&JzeL~(Lo&8m@ZfB_XRBe# z6)oY9zY}kuWcY&)UmGc5ioEtG?O7S(KQKs9XA`(18@OWzG6r4S*3!FZ#HHBY%0ge% z`qFms&TXbB8u~Wgmul+bJ`Vg-^aiWp*Ic9JuM&_y29i9D5BNON)N*WHiE}y&b{ega z!OC?e*}mozx&c_uWF3l8x15!MlJRVTd2Y3Yt3603eVq))tBZnO8~qPE<~KG95^*SH z%53TJ&!V#qrovb{HO`m+`yWUd%~uhrT8wEhrefD_mv>WTPSEP_SC+r>Zp1O|+!D#e zv?C{OpjGp7!P%*`@)13|la zM^C2E>&H{#8IXW^{<&YgN;p#ACPpAL*XI|;)NppOgb(MN+~iO@Y zaD;6^&S%5BwGVGl0Od|~F}ZNe$0p2_z2Du4m;^-D>^KrX>>(FGM-;;S$H1s~+{y(x zN7wrG^zj@;JtcHzvs)x(I{@)S{Rw5pRyn!V{3--aXO%>P!iRgtiusG}>>7eFQT+o%=dTkvJB_;G3=jj%ykQtYE|Cfp2WFFYAfjtLhfxrz zz>M*&&&bUE4VPYt@?>L-NvR!dR0(S9;oE91dGs9(-AnNP($p_zAHo{ax26vp z8<*xQ(wbXZ`22dc-{@$&lHp#bWIADrm5@-l)zBpBjx6V=@2xFoflXv0{zN}82(4IB zbkAp>3LKGZc{f*Yar-hgnS&^wKsZC^627i2w>dfW{G>n0Z7XBS%$%{Bvem=R$8%hT zxtH~5DR8Ts!OzNDuLn{Zd<$#YFxwbH`=P^fAI zcJ^1qd8cNw@RIyG@3Fyd4nT_hXr2sUZeec&=#kO;4Rm{h++m zRY6c<5hqjV822$b#$u=Ew(l(lDa`svc^0S9z6djoDlM1Wt8ve;EAqOo;KxLh$9xR6 z&!0LB(O61s4mDIL?qe;`=1p)dFrG?8(9Cz^o^0+Ihy8^p?L^iB59z!`*c4yU@^$(m z>XmwbKmLR)^;&anzSXud$JyEcEu5N|c(^JZ#Tm=);j6jatt&IyF0~(+^Amb3QwxO_l4zge{ zYCDL8G&l>BHUblRtqmT)?~uS@)VMGRWFd|>PnofY$(|!HheLBLAdc&*8Bw<^IDU`q zW_oMIo!aYecdM^;VhruN$1EQnEOv^mf4(@gI!FvHeMF-jFW_iQ$==!7DW_n_jZ)|<6{0X(ukLfa3%UL8 zbInczo?45cCPHaS-U+E@3`p8~=l?yuk<7C&kv}H=yakbmcL)R9+OwD*KdFkosconI z1-}8uuq8eF&tC`;!~9ExY)B|HVUU0D%rx5Ypi%eEz(XbP6{o=kGnTkw=^`nL2|ai6 zxTgtuxn_Yu0m>WUC3X<`Fnl9ZCIsgCFuj2UuJ!$YiQ~2#G>Qo{CF1DdXq^dkVk&gaXLZ&gQg^+OEtKO#w#xL-|bgF1l~~> zr+nl-uhi{ZB7^7DEAO}HiRioiy3=x2{oSGdxLz}OdnonZffXB0U+IomHJV#XRc~Dg7Bq9tJv%BabK=C;CHCfCes%p4HJh8##TX~jspuzO zbzSV}@8g_V^~;N_)8ix)dq>hf-A1B4*vN;d+q&oYgI9)5@1Th`Rk(r1`S(TY?&j1q zPS!*TR`zmR+cm|QN_=C{Y)^8SHf}Few#Xvz4Z^1HdR|%q5ix|64x`{bz~j%rp+Ot8 zP#9K^5b*bOTJRNZBVibAGA`_Oro~fW?Z_t1495qEf(Ms4GjSRQyW zUmRLB+Hgn!^N%HGbZ{}b9Kx>Ky*vD{H+UB!^?!0QkD$kTrH^I83WWIjKdp?SxSD~A zGdaU8i2uP$1W7ImGri=i+R_M_SbfCL5J$e+uf1VJ1gvoe=9X%qYgD-&<;#Ca4CAne zn6DHtGIzA)2FI@0iyl20dLERadjvQUC07PT2F5shPu6PK30@a5CM^$;))sF#91*uk zoZ2*w$H~g0VT2mCtKw{)2BTH!8}qgYcd;F?CuQWkPqe5e?DP|&I)w-Qka(hc>@8B6 zhWCMx|BDAIu(#%7c+5nAq9m=xPF`v`Z=OWaVxKnVlJK#NE173UgS>v728nRtJDU+| zHjUZe5I-)zwQNSFs>)-DJn9SMXW=;%d&`y^JE}BWwSf!oL`sI(x~JSdg?@zBHAcUa zYWy%mMow|mUG>@?Yxa=yt#$VMAxo#Zp_lwD(!Wz8f3gM!hQ;$CFfvQ!FNdLbL*x(} zhZZF6sNQY|6~0j{gx7G~hdv%Wj0}Nr6@wrHD-hhn(W-HLN{0V^!2l5+-pJk0^$+wS z8GnSp6#*Rt6xIWWl5ycs2qx@*T?PD!Fw~-qCH=WkB!Zt4h5Hyj+{7Pnh%DUUw_mrl zLaKLGZr?wowr5c{Vm15GY)orsTg*sDM-c67ru0_*;A_yWPN|&4N72fBO}8z^9?8ZA zes1>%uEI=xgU@B5ad&$D_YJk@>rR!Anp{^|Yy$!Vvbj!gNDz3iuiAV*HBz8AXg3}5 z{?psd$56P{D7Y;Cf+r{CQRI8a{f%aSeKUQdTfU=pYZ+`swk1sJL1V$!x4-gb_Gzk@ z`C5#Rzm-?Q)c#5RlJcjimV<$c!lSUTnXsC=F_q2u{&^koycSwHh-xiq4MmMimb0y( zzpn^+3KLTfsTfhnbqyr(1XFa9nr_(12${Vmf6T9(lQghC^7(@|gP1|_Sgw7JcMYk# zvs!kw-2!8AmTS17X^Xdwq=CQsRG~)a4ZBC%)gaD&>>eq13`BL9wX7Z(pFXsxW4}ZwlzU9v~T5fNBU9`J`^U z!SGe!FSQ|se{^(^1psypzSKit+Y=mPVv9jb7=i~Pj_l+ol}|&1zV9xPeBy@UpYkJ< zibj0-D5MllC^thVYg+036twB*nc77t%Iw#%N+p(8l*cmEDoVT($mu7eb@)pm`Nqah zt^j#_z>JHpf}zNmV<6A$d$;ZlLlTeJ2H1abdl7Nh@b(FZ=<`8k>JAzE9xTV3Z}jzr zyxxZ{Vr+>>fzS48ujGdQ z`uFC=HLKsdos?tm(Av-2NDM6D4tx5C7vZB`%R?i>jObeL5v7EP%E z{#nQam!`R8lK__q0^oCl-K9EFo>k>-%i!BcA ziWaGnyV0JMv!uWZ+BFqGmtKuTTkWYbLh1#Ik^o%z?te}Z%#3dF1d0j2W z?_ksfJ&T+1AeMp-eHP6@ys8wb)ge!L6#ofo$)VO zn@(mf(AHMZ)_L|uHMqrkkLZaXYsV?p%9ZL04Udg0^;Ma+GpSjK`-T06t9)enz)O$a zA>mdmp3RvL2$DnHj1HJ6fDeU7c216o8p&!s%~+>D`>8rJ1}Amh(HE94(p6?YYN=xY z+ZwB2CudCc!@+rL)>1t65nws2G&Vl_PSa9osCbg)T!|2 zr8+MY*i-q6S=WB{KE`2td*^#wcWh7j!~n4$hS{2QSZDTx5eqsuO!#5Fi@Buv zF8r*~*gbPHi%7(#$jo?UJ0IB)Ao}E+TF~p&6tL*1(*xr`Q2|M=e^Q?+z$73Ba|5K; z@2trO8*7TS9}tWsZKhmtIA6pXc)uj9FDDE)%H#%8bl##kq3z{XH;rieke6$7FWCE! zhM%U=ki@b81*Jr6&(BJl>}h4D4nHOUa&?xTIe7`V7u&v@$>ZRF43{^^+INoDD+J}t zxRHFV?u~L;0|ou7sO37vj)(NRpR37cj|#akI`4{R!EOnoOZGzq1Zs4MwT=`QUT~w0 z6O`)@-$0`35Dd5UC=06OVTLK(hv-<)1v&y2#1I2Lp@@OQq?oZl^?{3tV8*1V(ETR# zsUpfn8bUx#i~)zQigu#yb0aEc>Y{KTf`yu4ArXhjXYm0yZVvwBwM^4wZho&0c;JLy zhNSF9IlhsMQAR#mlP<@>@0Z^nYF2r#P9HThoi~lUD~lXeRlFy2acKBvVuD=dA8?mV zo9t?hY2&?lNWU33bIYbQ_T$lY&w5U&X7;&RTg*PU34L6ZL+$Knv$sj{G`Uyu#Lp+(TmFrqJ zm+-!pB2XYM8cPAG#kb^d$)TrUdDTSo8<>`14yb{^si6W8eif4_G0GS(a@G^D&MZUF zMI1H!=*fVD@doa$sk@#cGHjkzb8`EzJ!p#R6D!FU=L@+XOO0jmWfR+R1TvjT{U$nF z*%QLtr4oZ@YO9+L-J0hcO^Vnh`9D4_$5dC6+GohHM)!T=#XY2yPRgC~W!bPuOf$Tg z_Tk^US7H|)mNRguD12A`mQ^I5^F~&d9xl4YUqT1=f6*O$u|?nJVa%BSI~MxiU;H}; z*7))0$&bbx?}{do^Wy)Krpg;`1f-avFHbhNYJSPIk}HcTAFOL+Z`AGbT3^`s$fom* z*hvDY9WRW4YG!J6=K}ten>RRIec1L;HE@?P`ua>ZGM(p}onhyr9M-fmda)i8L4Lk; z2G`DbeJVu_XF#tZyrZCtTsYbkpUbcXT?ab55G1M|Au!yA;q{3KFjwMEywJi>1JdhZ z(A%(^fVSaA+vJhJ)eVgkUf(A;4Q$lER!9!TEiSe> z6e8<+MO4#v$rNI-F#MojS(*rZ^8QRg?>8D{)5{FD;{=C$59eL2E{isMeJ6L#_`i}n z5AC2`o{9g6?j<@be)PJhJ;tcCJBSg$c+xCC%1`B~bl#R%8d(K`y_YDJ^VjFxrkwBg z;w;$hvWqP)^)+`QEeECYzp3l%iiIY7)pTqPgsZRy%rpm>EUQzQh9l)!2|{YknWZY` zkgoe`z<}#7Bv`UO72&LL>3mkuH}Y$cPP}ZeeCdy}I;IpPw6Wfc(0=!{QDT-rNCVgbtmsxkiRuu)OlTI~H#Hz~#f73?0);<1trMq& zz-`bc9zBo(4I5?~gr21rp+9u8P@O5!0qD-?OZbn9 z+zOnq!|{n1`AE}IWf2?PcLt-?ksPVI`CybiMeMfAe8Kq_QeFMDQ721wD9`>p*ucV; zT1DDN?+MD=5h)Ks@4|Mw`@YC7CByx+x-4<2VUNW?|B~1%`6fXHgT9Pe6F2u#MmxL( z0%JNiV+B{jhB4=gm8>jH3ZPj6kzK}L#Xuw>S@?6JEYc)c9uKo%l^&p>`-#GQqreQ^ zBBddRL|ir-z9wTxH1aBZ$H2 zI9=)q*(p^RzMoAE%xeLQYg2ca%l&!8rzhV{S4WwCFK%p`zEC{mGfic-Puo;pD00)6 zObpjAke<)$Us|s4+2TGP@FV<4%qpv#>sOKTm~`2|-|*mLjKnB{uN!xD*y|5Jxv@Sy zrjF89deBl;6h1x{5CTLmccEAm07)?vy7@Q;1};Ym5;y?k`O!BXsIP*cd*8&(6FrwA z^MI!>o(~~aTbavE4FM-4-PnbrVhjDe(EZ|Z#@VXswB2eSpC(<6;pKM5n+JTpO~%cp zw&w8cQQ<$d3~Q}f0iSs?;x7Y#NtQL~7xui`#C{fMVay=&qOTH=P&IC6c5>jZaJv#6`vx#3IHfx&BA zR_oK;4i#M!fSNe|o{LJ}ieZ}eV4V9s6*eZVSY-S1HTlq|ntD~u zVu{nzvua>~7=^dU$mI2XcdPGF`Lc}sLu5eYAVyG#GXs4?m)fW;qwLs;`2W2c714~O zUbtqgaL2#MNc~6qqu+%_k-EFKqNyybbiloed&Bc4S&Kf=+I!u}cG2p5*+ z8rW*q8pAx2(VAd_{x`5?F}l9Aki+X;eXABLp-j zWb`DMSL5gl^`6Msu$zQC-ptCF{TpLUqvd&Q!;6$nF`g)b*=<8>+0+O02N;Gz*OlV0 z%(xDxlW&EEYi8w*7GW$v&uRRh0P(}Wkl6iYukudDGzZT+kYWfaAo*x*)*V*NoeJ>C z$QQ)ab=$Fp(OA0;0g8`&O^OO`7Kn>aiH>17KeW&lKgppsq_(RL$y<{^hd+V&ZZ@Dt zPoqacKy=`C9HhF2x>@n5XX8_K4Y3FIbi{m8Lt^`wm1=QvbFJcLty5aW%yxFMZKHu1 zmS}S7M1JzN}@VN$L@2 z%Tx{yn9FW&L+Mk5WKJK1^ehW0jNMVjjH?n{&mnWHdMP&Gx&2G%<;$J^m6Biqa$WCg z{lQtM0-8;$?Pqrj2SNr0vblQ8@3N%ivmCzoa+X=x*E={+AP^jNyJ|{5`*?&-p!p+f z!c=iY{WI=oJlu8Vq7ii|g`uhH*T3J<|5{B6{_PQ8RVIW?ZcvK-5b!T6f?)FCpK|VP za_AiZFyBO@S5!`!3CTr9NBXC#yPfPs*)6n~6b@=aElS?V3ld>Ma+lVlq5=f@J32ss zqPpY8gMO0xvTs)y;I!vzD56^Svc84wniYCwh7bD7<)rc^5Xc^wL^0k`3eGxsL5yUN zNAt4Y@A8Q|HEo7U%P?4JxEkUy#~{A7C+{CgV7l%e zu{c)-22{RkB!NgPq$>v?tl019%}+XzJuc8_Aaq}f8Gnf4Y3PvXbAD;4KMTJ!W&e2w z?)Zs8)!tkeo%3MNzhazi!s*XQ_*?p$4}J*dbsWH6327X7gf4F>ObkVnQ1SI-jLR0S z_X?h%{TvIpx~KXkR;vUs>{32Pa)=4OO$Ivy=N&m?t8TQew6g>zIkHua=ro~A^7&E! zGdI8ADj^ZHRe&z$<=-BQXP-%fR6Lk7tM-yH=rD{bx#d<_-;wFDeWJ;)y}4HN&!$E> zGyaj!uIDO8Vq|dU!^qMvrdxmPTOGx8SeR9+Y)P#h#;@dx?Bb}@g+BTd4kX?Y<#FTX zu$BU&Xb%JlVNnzp3^2!m__+i#dVQW`DNBk31A`p~%15*!I>tt#_r`x@4XrGo#|LT! zXupF0e>DRb;*e@XY(K#^0fI_*QoOP{tQbr)Mq zI+9|Rri}9Cdc*UY;psynr;$;dUMe#ALu*BXRQis(vN98G_ShQO*zuNPi?W#d$c{S>pwXl~ zIQgt+J41f&O(RiRz~yOPkfNbqkaAuJ6JM&i?;q9+-zI{9lJmIyro9_3_GC9tadZt2 z^_;6()yy55CN}WuhJ@vmwRJ_*`qo^yt7_4`4aiw&kscH~AcyQQ~_=ua>g z#k2ud7v+I>P|OPA#^HnCT9_Kk5Dy0mC(btpUdaYhxiBR9-^Hm7$l6JSZ{|3F*xrZS z^Gw}QD{6L^=S!-_tDQ#sh*ub@ha2xpDn8i>R;4FNh`UKWH@j!fD~A0|cn)nCz%hHK z>1)n)$x*PBE@luhggBLsbsn+lkrbDj7;TJ-UMbalfOLMaDeQMs=NJ9&N4x$}Ut6VC zere_l?)q(LK05f4li##xvl)E$q&=6U$th{ok<&2p)6NYi9wX9k?H^|KMxBp2Qb8(F z5_d?RZby;Y(Vu6Z^i9@tZ(;09@`!WFc2T&vp0z zTYLG}MK^3NrtH{dP?wKo{OH=m&QK6{3*--uf6b#$p&el>{?MLmUN9&5y%U+G%P4Ok z2*19g8)zx+JU4W@0LY7AE3*ePPG+go$nkso4JXsxzmOp>t`C2t=m#mMvEXH7I>b&sTKfCUO$ynj6sR2h0M6daJpCyg zK>l}R(7CF~gwn}W$@p-?XjQ+JGWmV)sO8Gwc4+a)o0)wgC-*1r4-GlP?sX5A%m%-* zK)&l#BTr7Tv$TqIu{ZSFZBLH5DI|3l=(psOvQ_Jo0fpHc`H{)_NRL1(W+e$m8T^U& z`l1Sa1n^3)#N8cev z0IS9Ru#L9a??siq;aq1PtR) ztt`w3Aus^f@;Cs$P2hU0wpMEln{NhR4&&cp#I#e-fNKIvhvUdmX zww0@~C|4ybG35qnqQjP!7Ua;+YQ^sj*&qX#7C+ijlGqQfxWo}-84qq1c?C2 z>i=OknQ;Kgh>;XaHM8i2*C3GU0v%N;x$0rr(_f5taq_$- zWe&w9qWHv?K<;w-yzFI{Y!f`!p86LmNzxb$dM$tGP}NzXM^|G}BS_j)I#&GLiZ3>Q zZt8Pls>WniqZc0Q$W@(hs3n$iQ00%E1-g{jK{rI=A!R>TZQ&Y=o0EA3r55y^gaH@w zW_56iZ4ov;W5%C)rgG3R!Ec|3J?nh!pTZwQU3W}t@+j+odhBg@t$+n~X11K?(++H4 zZ$0F#MxCT&!rA_A#@FJ>NAdZE*sbQl`0+Z)#n8T$WWI$uE38U%Zo~p0BYLPdsmC+nt4AF=;nZtCfE|42F(rFvGxSjE|^lIa}|iO zxhNKcfqw!!NeHsHc1agta;k?OYe*D&p)27qKeSvg_`vG`2#yB&{OtUm!$`6r4LUt( zEqJBp42q&z0a!1baG~goHy&3_M@a)IZON2-;qEeg164u0^mxW4JZ?C{Zi_VAv}=xG z&7v0HLw=W2t?f4PZ2tVm?{rI1%&#wObLsk*TeSD9%`mif1m73Ze;3GXu<4PyYy0zy z@l?{t)srII7cXWc`fMOra`#r!kuYGoEC7BfbV~hSr#UOMf#=smN zHSyzkpK@#Frv>+<5Mw`*QA#gd19P-mSDzXvB7mfm`)*2K++4il2ifAy1FR^=*1iJv zz3LYjqXvRw+7gxFX7iuCw&{qi>q#2BBu{g|YY`s~mCDw3H~n59E^@0DAez*Ym@;=} z=vvJ+Tqfj1TioPyGK^Is^M#W{=(1VuaCuh3zMnF~2y9aG)r@!$G-h$U2*%jFJ~N?L zW5GY>ypZib^CEMrFrKPP(Mn|gVGUDY>mfi4XXk|6#BhMQmTsNwx-5wj=4q1rh#4MB z&Sr-BNyPX6+ElKx}fR;Vw-?vt^nPB2Z$QqAhz8(6!v%LnQ+2$W~?Ae`cr0HT5Y2`$X%Gv z#*hZZ-@EK;I3;oVysmfBfc#CX_3=E9-fM&W8^~ZDWZA6KYCj`ujotG3-KdJZs9P;= zH#IFN9wL%}uciH?ugjYD9gq8@LS|p|Gh+F;k`LK?C>e@>AM`v z#rwBI+AlIaGsdJz^KEaCJ*q4H;vvd!EI{EE-0W@KuK{0U-^pyBDY667q>H_>JG(mz zB4cjJ{ldJ@MXNhQWlCCPm1U+po;9iDcK}yQo)0DG1v=wtKYBI-JxpHCk8~P+hcY%L zI^tbl6_L)NNSl%5(k~IDQZ_G_P*h;2+$_!)?92jkT?Q@;3qlahl7n9TzpkM`82YI< zG!B?AJ@A(r;IhV@paZ)NOL;Ni7Zgb_mXd%Lc%gU}_CGH=zKT1d%$Q`L*A9R*2h^?# z(E$gz=m9znuxil(YZCu~@hFg`O88dBD?M(?*+r)O8m}N+1s@NRueie@*o0g`8~CglE0DVeeLGaii(`<>Vq- zws%IME~_WXjOpn-1*;MnT?}0VZ)|Y)SnX<<*@r;erJCPiSx|j(C?h=;U+fm`El?-M z^?-X`UazRkTfn9IlsAY!q^RXMuNArr09OET0Kl~pot|#~+ttO+8-@eo5P%f`wFoHM zp)=#{tp7iR$V^H5S4Aq4U`m7V3WPz?D^e2RVn*-(ilax9q8^3Bz33&12i6e3E&7g5 zydYl%a0NRrn&wKxz-?p!AQy}%Wu>3$qGB^(A8a9dl~7sTbAjrPQP?Vx1W#@@&z7Dn z*GTnNhx^B;uS%ntmuI`P%HiM4$SUsc%6t<}GIZrGP*1gR4?Vp;RIVMrB2a(aE<*RF zbawBtv|CN|s|NVSxpId{X>5dRn&NJ?fM8Tp37-*vqiiT}1ltt{@p1YYBW}sUG<=CL z>PgfJpEEM->?|su;~vgTtWE4*txJD&i)f6~SWgkz!+26YeRa2OL8oteXZm&K4Z*AbY z@peS2pdGya*OG+NA0*qy8(i~_HPXh8Re$c%@Dqm>ai^^;dx~!Se!0Cu?4oaB@q|0S z_}Sx{v-zg>Nf8PAb=lL$bssCm@G2Z5M@aR>DHujNAv~=ttO0Zlasvdz*MaDv!8e`_ z6g7|m`wlR6E*PjnlE8vC*e^!&1z=DQ7$*-XKvA7U9WZtO-?Zh^YS;Jk{D0$@PpeAy$HU(XyTquySX}gj%+#B?ru(KJ?&q2O?t4m^%wf7DfeV0<7USx4W8oV1eU8~2m zN1I3x{epf~w}9W(VQI`y10&#%a$vdAU&vv~iCCfcJ^0w1!w6F3F{RNB|;z?;Dt5;w^nZ z;5uso$}l-o=|636%>%>@M{dGAh~$qd@i++#?1li+c;a*lKnd4Sw*X#P04{^8VGOzl z^3nIvD_{)QiuXXNl{F1+Qy2&OsX?u(k_Di~e^b5xBaYyL4Sgzj0USkyd*cWjj&)Ju zwtvRA{fK3qMQwP?lp(0rbcKFJ(0K9IMOk9xR?VqdDI{?co2{h4EB0ls%rET=FC)ve z&ppG({B(YT?il>=G5a$C@x?QYf|I-?Z9iv~il_*KvrU1?H!bRdk;>CtIn0v9gDyQrK0 zq3fWWe2Jb(!J;S8r9Bq<8983V(jMbV~d!L_o0!pW`wDEcJRNOC%S=t~CS zRX|PkM2i1nO~vEDI33f^ZI^6<*u4&p4)yAZ`^5e=OgLAqY-xJ#JSF}KE%Hj7Mhl+G zux~l2Cs(RxPP-T6%=9(qJNdUiEx9m+4~>IMfA>+FF_PBqlF#Q3X8|8FSVs)k^M&Bt z%g0wjm9jw`l!gU&3&Sm$^4+5s#V2mXcgPYJL@(OU2r{{$wfS_b|)#|QnsoORE{% tuple[int, ...]: + """Return dimensions as a tuple after validating that every entry is positive.""" + dimensions = tuple(dimensions) + if not dimensions or any(dimension <= 0 for dimension in dimensions): + raise ValueError(f"Expected positive {label} dimensions, got {dimensions}.") + return dimensions + + +def _task_action_dimensions(dimensions: Sequence[int], output_dim: int) -> tuple[int, ...]: + """Validate task action dimensions against the fixed global action width.""" + dimensions = _positive_dimensions(dimensions, "task action") + if sum(dimensions) != output_dim: + raise ValueError(f"Task action dimensions {dimensions} sum to {sum(dimensions)}, expected {output_dim}.") + return dimensions + + +def _task_encoding_slice(encoding_slice: Sequence[int], input_dim: int, task_count: int) -> tuple[int, int]: + """Validate and return the task one-hot slice within a model input.""" + if len(encoding_slice) != 2: + raise ValueError(f"Expected a two-element task encoding slice, got {encoding_slice}.") + resolved_slice = (int(encoding_slice[0]), int(encoding_slice[1])) + start, end = resolved_slice + if start < 0 or end > input_dim or end <= start: + raise ValueError(f"Task encoding slice {resolved_slice} is outside the {input_dim}D model input.") + if end - start != task_count: + raise ValueError(f"Task encoding slice {resolved_slice} has dimension {end - start}, expected {task_count}.") + return resolved_slice + + +def _task_encoding(obs: TensorDict, obs_groups: Sequence[str], encoding_slice: tuple[int, int]) -> torch.Tensor: + """Extract the raw task one-hot before observation normalization.""" + model_input = torch.cat([obs[obs_group] for obs_group in obs_groups], dim=-1) + return model_input[..., encoding_slice[0] : encoding_slice[1]] + + +def _validate_task_encoding(task_encoding: torch.Tensor) -> None: + """Validate that each sample contains exactly one one-hot task identity.""" + is_binary = torch.logical_or(task_encoding == 0.0, task_encoding == 1.0).all() + has_one_task = (task_encoding.sum(dim=-1) == 1.0).all() + if not bool(is_binary and has_one_task): + raise ValueError("Task encoding observations must be one-hot with exactly one active task per sample.") + + +class TaskHeadedGaussianDistribution(Distribution): + """Diagonal Gaussian distribution routed over contiguous task action heads.""" + + def __init__( + self, + output_dim: int, + task_action_dims: Sequence[int], + init_std: float = 1.0, + std_range: tuple[float, float] = (1e-6, 1e6), + std_type: str = "scalar", + learn_std: bool = True, + ) -> None: + """Initialize task-specific Gaussian standard deviations. + + Args: + output_dim: Fixed global action dimension. + task_action_dims: Action dimensions of the contiguous task heads. + init_std: Initial standard deviation for every active action dimension. + std_range: Inclusive standard-deviation clamp range. + std_type: Standard-deviation parameterization, either ``"scalar"`` or ``"log"``. + learn_std: Whether the task-specific standard deviations are learnable. + + Raises: + ValueError: If the task dimensions or standard-deviation configuration are invalid. + """ + super().__init__(output_dim) + self.task_action_dims = _task_action_dimensions(task_action_dims, output_dim) + if init_std <= 0.0: + raise ValueError(f"Expected a positive initial standard deviation, got {init_std}.") + if std_range[0] <= 0.0 or std_range[0] > std_range[1]: + raise ValueError(f"Expected a positive ordered standard-deviation range, got {std_range}.") + if std_type not in ("scalar", "log"): + raise ValueError(f"Unknown standard deviation type: {std_type}. Should be 'scalar' or 'log'.") + + self.std_type = std_type + self.std_range = std_range + self.log_std_range = (math.log(std_range[0]), math.log(std_range[1])) + initial_value = init_std if std_type == "scalar" else math.log(init_std) + self._std_params = nn.ParameterList( + [nn.Parameter(torch.full((dim,), initial_value), requires_grad=learn_std) for dim in self.task_action_dims] + ) + + action_masks = torch.zeros(len(self.task_action_dims), output_dim) + start = 0 + for task_id, action_dim in enumerate(self.task_action_dims): + action_masks[task_id, start : start + action_dim] = 1.0 + start += action_dim + self.register_buffer("_task_action_masks", action_masks) + + self._distribution: Normal | None = None + self._action_mask: torch.Tensor | None = None + Normal.set_default_validate_args(False) + + def update(self, mlp_output: torch.Tensor, task_encoding: torch.Tensor | None = None) -> None: + """Update the routed distribution from global means and one-hot task identities.""" + if task_encoding is None: + raise ValueError("TaskHeadedGaussianDistribution requires a task encoding when it is updated.") + if task_encoding.shape[-1] != len(self.task_action_dims): + raise ValueError( + f"Expected {len(self.task_action_dims)} task encoding dimensions, got {task_encoding.shape[-1]}." + ) + + action_mask = task_encoding.to(dtype=mlp_output.dtype) @ self._task_action_masks.to(dtype=mlp_output.dtype) + std = self._global_std().expand_as(mlp_output) + # Unit standard deviation keeps the inactive Normal parameters numerically valid; all probability terms mask it. + distribution_std = std * action_mask + (1.0 - action_mask) + self._distribution = Normal(mlp_output, distribution_std) + self._action_mask = action_mask + + def sample(self) -> torch.Tensor: + """Sample the active task head and return zero in every inactive global dimension.""" + distribution, action_mask = self._require_state() + return distribution.sample() * action_mask + + def deterministic_output(self, mlp_output: torch.Tensor) -> torch.Tensor: + """Return the routed means produced by the task-headed model.""" + return mlp_output + + def as_deterministic_output_module(self) -> nn.Module: + """Return an identity module because the model already routes its deterministic output.""" + return nn.Identity() + + @property + def input_dim(self) -> int: + """Return the fixed global mean dimension.""" + return self.output_dim + + @property + def mean(self) -> torch.Tensor: + """Return the current routed global action mean.""" + return self._require_state()[0].mean + + @property + def std(self) -> torch.Tensor: + """Return the concatenated standard deviations of every task head.""" + return self._global_std() + + @property + def entropy(self) -> torch.Tensor: + """Return entropy summed only over the selected task head.""" + distribution, action_mask = self._require_state() + return (distribution.entropy() * action_mask).sum(dim=-1) + + @property + def params(self) -> tuple[torch.Tensor, ...]: + """Return mean, numerically valid standard deviation, and the active action mask.""" + distribution, action_mask = self._require_state() + return distribution.mean, distribution.stddev, action_mask + + def log_prob(self, outputs: torch.Tensor) -> torch.Tensor: + """Return log-probability summed only over the selected task head.""" + distribution, action_mask = self._require_state() + return (distribution.log_prob(outputs) * action_mask).sum(dim=-1) + + def kl_divergence( + self, + old_params: tuple[torch.Tensor, ...], + new_params: tuple[torch.Tensor, ...], + ) -> torch.Tensor: + """Return KL divergence summed only over the task head stored with the old policy.""" + old_mean, old_std, old_action_mask = old_params + new_mean, new_std, _ = new_params + divergence = torch.distributions.kl_divergence(Normal(old_mean, old_std), Normal(new_mean, new_std)) + return (divergence * old_action_mask).sum(dim=-1) + + def _global_std(self) -> torch.Tensor: + """Concatenate the independently parameterized task-head standard deviations.""" + if self.std_type == "scalar": + stds = [param.clamp(self.std_range[0], self.std_range[1]) for param in self._std_params] + else: + stds = [torch.exp(param.clamp(self.log_std_range[0], self.log_std_range[1])) for param in self._std_params] + return torch.cat(stds) + + def _require_state(self) -> tuple[Normal, torch.Tensor]: + """Return the current distribution and mask or reject access before an update.""" + if self._distribution is None or self._action_mask is None: + raise RuntimeError("The task-headed Gaussian distribution has not been updated.") + return self._distribution, self._action_mask + + +class TaskHeadedMLPModel(MLPModel): + """Shared observation backbone with task-specific Gaussian action heads.""" + + def __init__( + self, + obs: TensorDict, + obs_groups: dict[str, list[str]], + obs_set: str, + output_dim: int, + hidden_dims: tuple[int, ...] | list[int] = (256, 256, 256), + activation: str = "elu", + obs_normalization: bool = False, + distribution_cfg: dict | None = None, + task_action_dims: tuple[int, ...] | list[int] = (8, 8, 6), + task_encoding_slice: tuple[int, int] | list[int] = (0, 3), + ) -> None: + """Initialize the shared backbone and contiguous task action heads. + + Args: + obs: Observation dictionary used to infer model input dimensions. + obs_groups: Observation groups assigned to each model set. + obs_set: Observation set consumed by this model. + output_dim: Fixed global action dimension. + hidden_dims: Shared-backbone layer dimensions. + activation: Shared-backbone activation function. + obs_normalization: Whether to normalize the shared observation input. + distribution_cfg: Task-headed Gaussian distribution configuration. + task_action_dims: Action dimensions ordered by task identity. + task_encoding_slice: Half-open slice of the raw model input containing the task one-hot. + + Raises: + TypeError: If the configured distribution is not task-headed. + ValueError: If dimensions or the initial task encoding are invalid. + """ + hidden_dims = _positive_dimensions(hidden_dims, "shared-backbone") + + super().__init__( + obs=obs, + obs_groups=obs_groups, + obs_set=obs_set, + output_dim=output_dim, + hidden_dims=hidden_dims, + activation=activation, + obs_normalization=obs_normalization, + distribution_cfg=None, + ) + + self.task_action_dims = _task_action_dimensions(task_action_dims, output_dim) + self.task_encoding_slice = _task_encoding_slice(task_encoding_slice, self.obs_dim, len(self.task_action_dims)) + _validate_task_encoding(self._task_encoding(obs)) + + # Replace the standard monolithic MLP with an activated shared backbone and task-local linear heads. + del self.mlp + self.backbone = _make_backbone(self.obs_dim, hidden_dims, activation) + self.action_heads = nn.ModuleList([nn.Linear(hidden_dims[-1], dim) for dim in self.task_action_dims]) + + if distribution_cfg is None: + raise ValueError("TaskHeadedMLPModel requires a task-headed distribution configuration.") + distribution_cfg = dict(distribution_cfg) + distribution_class: type[Distribution] = resolve_callable(distribution_cfg.pop("class_name")) # type: ignore + if not issubclass(distribution_class, TaskHeadedGaussianDistribution): + raise TypeError( + f"TaskHeadedMLPModel requires a TaskHeadedGaussianDistribution, got {distribution_class.__name__}." + ) + self.distribution = distribution_class( + output_dim, + task_action_dims=self.task_action_dims, + **distribution_cfg, + ) + + def forward( + self, + obs: TensorDict, + masks: torch.Tensor | None = None, + hidden_state: HiddenState = None, + stochastic_output: bool = False, + ) -> torch.Tensor: + """Route shared features through the task head selected by the raw task one-hot.""" + obs = unpad_trajectories(obs, masks) if masks is not None and not self.is_recurrent else obs + latent = self.backbone(self.get_latent(obs, masks, hidden_state)) + task_ids = self._task_encoding(obs).argmax(dim=-1) + task_encoding = F.one_hot(task_ids, num_classes=len(self.task_action_dims)).to(dtype=latent.dtype) + output = torch.cat( + [ + head(latent) * task_encoding[..., task_id : task_id + 1] + for task_id, head in enumerate(self.action_heads) + ], + dim=-1, + ) + if stochastic_output: + self.distribution.update(output, task_encoding) + return self.distribution.sample() + return output + + def as_jit(self) -> nn.Module: + """Return a TorchScript-compatible deterministic task-headed policy.""" + return _TorchTaskHeadedMLPModel(self) + + def as_onnx(self, verbose: bool = False) -> nn.Module: + """Return an ONNX-compatible deterministic task-headed policy.""" + return _OnnxTaskHeadedMLPModel(self, verbose) + + def _task_encoding(self, obs: TensorDict) -> torch.Tensor: + """Extract the raw task one-hot before observation normalization.""" + return _task_encoding(obs, self.obs_groups, self.task_encoding_slice) + + +class TaskHeadedValueModel(MLPModel): + """Shared observation backbone with task-specific scalar value heads.""" + + def __init__( + self, + obs: TensorDict, + obs_groups: dict[str, list[str]], + obs_set: str, + output_dim: int, + hidden_dims: tuple[int, ...] | list[int] = (256, 256, 256), + activation: str = "elu", + obs_normalization: bool = False, + distribution_cfg: dict | None = None, + task_head_count: int = 3, + task_encoding_slice: tuple[int, int] | list[int] = (0, 3), + ) -> None: + """Initialize a shared value backbone and one scalar head per task. + + Args: + obs: Observation dictionary used to infer model input dimensions. + obs_groups: Observation groups assigned to each model set. + obs_set: Observation set consumed by this model. + output_dim: Critic output dimension, which must be one. + hidden_dims: Shared-backbone layer dimensions. + activation: Shared-backbone activation function. + obs_normalization: Whether to normalize the shared observation input. + distribution_cfg: Output distribution configuration, which must be ``None`` for a critic. + task_head_count: Number of task-specific scalar value heads. + task_encoding_slice: Half-open slice of the raw model input containing the task one-hot. + + Raises: + ValueError: If the value, backbone, task-head, or task-encoding configuration is invalid. + """ + if output_dim != 1: + raise ValueError(f"TaskHeadedValueModel requires a scalar output, got {output_dim}.") + hidden_dims = _positive_dimensions(hidden_dims, "shared-backbone") + if task_head_count <= 0: + raise ValueError(f"Expected a positive task head count, got {task_head_count}.") + if distribution_cfg is not None: + raise ValueError("TaskHeadedValueModel does not support an output distribution.") + + super().__init__( + obs=obs, + obs_groups=obs_groups, + obs_set=obs_set, + output_dim=output_dim, + hidden_dims=hidden_dims, + activation=activation, + obs_normalization=obs_normalization, + distribution_cfg=None, + ) + + self.task_head_count = task_head_count + self.task_encoding_slice = _task_encoding_slice(task_encoding_slice, self.obs_dim, task_head_count) + _validate_task_encoding(self._task_encoding(obs)) + + del self.mlp + self.backbone = _make_backbone(self.obs_dim, hidden_dims, activation) + self.value_heads = nn.ModuleList([nn.Linear(hidden_dims[-1], 1) for _ in range(task_head_count)]) + + def forward( + self, + obs: TensorDict, + masks: torch.Tensor | None = None, + hidden_state: HiddenState = None, + stochastic_output: bool = False, + ) -> torch.Tensor: + """Return the scalar value from the head selected by the raw task one-hot.""" + del stochastic_output + obs = unpad_trajectories(obs, masks) if masks is not None and not self.is_recurrent else obs + latent = self.backbone(self.get_latent(obs, masks, hidden_state)) + task_ids = self._task_encoding(obs).argmax(dim=-1, keepdim=True) + task_values = torch.cat([head(latent) for head in self.value_heads], dim=-1) + return torch.gather(task_values, dim=-1, index=task_ids) + + def _task_encoding(self, obs: TensorDict) -> torch.Tensor: + """Extract the raw task one-hot before observation normalization.""" + return _task_encoding(obs, self.obs_groups, self.task_encoding_slice) + + +class _TorchTaskHeadedMLPModel(nn.Module): + """TorchScript-compatible deterministic task-headed policy.""" + + def __init__(self, model: TaskHeadedMLPModel) -> None: + """Copy inference modules and task routing metadata from a trained model.""" + super().__init__() + self.obs_normalizer = copy.deepcopy(model.obs_normalizer) + self.backbone = copy.deepcopy(model.backbone) + self.action_heads = copy.deepcopy(model.action_heads) + self.task_encoding_start = model.task_encoding_slice[0] + self.task_encoding_end = model.task_encoding_slice[1] + + def forward(self, obs: torch.Tensor) -> torch.Tensor: + """Return a fixed-width action containing only the selected task head.""" + task_ids = obs[..., self.task_encoding_start : self.task_encoding_end].argmax(dim=-1) + latent = self.backbone(self.obs_normalizer(obs)) + outputs: list[torch.Tensor] = [] + for task_id, head in enumerate(self.action_heads): + selected = (task_ids == task_id).unsqueeze(-1).to(dtype=latent.dtype) + outputs.append(head(latent) * selected) + return torch.cat(outputs, dim=-1) + + @torch.jit.export + def reset(self) -> None: + """Reset recurrent export state (no-op for the feed-forward model).""" + pass + + +class _OnnxTaskHeadedMLPModel(_TorchTaskHeadedMLPModel): + """ONNX-compatible deterministic task-headed policy.""" + + is_recurrent: bool = False + + def __init__(self, model: TaskHeadedMLPModel, verbose: bool) -> None: + """Copy a task-headed model and record ONNX export metadata.""" + super().__init__(model) + self.verbose = verbose + self.input_size = model.obs_dim + + def get_dummy_inputs(self) -> tuple[torch.Tensor]: + """Return a representative task-valid observation for ONNX tracing.""" + dummy_obs = torch.zeros(1, self.input_size) + dummy_obs[0, self.task_encoding_start] = 1.0 + return (dummy_obs,) + + @property + def input_names(self) -> list[str]: + """Return ONNX input tensor names.""" + return ["obs"] + + @property + def output_names(self) -> list[str]: + """Return ONNX output tensor names.""" + return ["actions"] + + +def _make_backbone(input_dim: int, hidden_dims: Sequence[int], activation: str) -> nn.Sequential: + """Create an activated MLP whose final hidden layer is shared by every task head.""" + layers: list[nn.Module] = [] + layer_input_dim = input_dim + for hidden_dim in hidden_dims: + layers.append(nn.Linear(layer_input_dim, hidden_dim)) + layers.append(resolve_nn_activation(activation)) + layer_input_dim = hidden_dim + return nn.Sequential(*layers) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/agents/ppo.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/agents/ppo.py new file mode 100644 index 000000000000..370efabeba3f --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/agents/ppo.py @@ -0,0 +1,108 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Task-balanced PPO for contributed heterogeneous manipulation.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +import torch +from rsl_rl.algorithms import PPO +from rsl_rl.models import MLPModel +from rsl_rl.storage import RolloutStorage +from tensordict import TensorDict + + +class TaskBalancedPPO(PPO): + """PPO that normalizes rollout advantages independently for each task.""" + + def __init__( + self, + actor: MLPModel, + critic: MLPModel, + storage: RolloutStorage, + task_names: Sequence[str], + task_encoding_obs_group: str, + task_encoding_slice: Sequence[int], + **kwargs: Any, + ) -> None: + """Initialize task routing metadata and the base PPO algorithm. + + Args: + actor: Task-headed policy model. + critic: Task-headed value model. + storage: On-policy rollout storage. + task_names: Task names ordered by one-hot identity. + task_encoding_obs_group: Observation group containing the task one-hot. + task_encoding_slice: Half-open slice containing the task one-hot. + **kwargs: Base PPO keyword arguments. + + Raises: + ValueError: If task metadata is invalid or mixed mini-batch normalization is enabled. + """ + self.task_names = tuple(task_names) + if not self.task_names: + raise ValueError("Expected at least one task name.") + if len(task_encoding_slice) != 2: + raise ValueError(f"Expected a two-element task encoding slice, got {task_encoding_slice}.") + self.task_encoding_slice = (int(task_encoding_slice[0]), int(task_encoding_slice[1])) + encoding_start, encoding_end = self.task_encoding_slice + if encoding_start < 0 or encoding_end <= encoding_start: + raise ValueError(f"Expected an ordered non-negative task encoding slice, got {self.task_encoding_slice}.") + if encoding_end - encoding_start != len(self.task_names): + raise ValueError( + f"Task encoding slice {self.task_encoding_slice} has dimension {encoding_end - encoding_start}," + f" expected {len(self.task_names)}." + ) + if kwargs.get("normalize_advantage_per_mini_batch", False): + raise ValueError("TaskBalancedPPO is incompatible with mixed-task mini-batch advantage normalization.") + + self.task_encoding_obs_group = task_encoding_obs_group + self.task_statistics: dict[str, float] = {} + super().__init__(actor, critic, storage, **kwargs) + + def compute_returns(self, obs: TensorDict) -> None: + """Compute GAE targets and normalize advantages within each task rollout.""" + super().compute_returns(obs) + storage = self.storage + raw_advantages = storage.returns - storage.values + task_encoding = storage.observations[self.task_encoding_obs_group][ + ..., self.task_encoding_slice[0] : self.task_encoding_slice[1] + ] + self._validate_task_encoding(task_encoding) + task_ids = task_encoding.argmax(dim=-1) + + task_statistics = {} + for task_id, task_name in enumerate(self.task_names): + selected = task_ids == task_id + if not torch.any(selected): + raise ValueError(f"Rollout contains no samples for task '{task_name}'.") + task_advantages = raw_advantages[selected] + task_returns = storage.returns[selected] + advantage_mean = task_advantages.mean() + advantage_std = task_advantages.std(unbiased=False) + storage.advantages[selected] = (task_advantages - advantage_mean) / (advantage_std + 1.0e-8) + task_statistics[f"{task_name}_advantage_std"] = advantage_std.item() + task_statistics[f"{task_name}_return_std"] = task_returns.std(unbiased=False).item() + self.task_statistics = task_statistics + + def update(self) -> dict[str, float]: + """Run PPO updates and append per-task rollout scale diagnostics.""" + losses = super().update() + losses.update(self.task_statistics) + return losses + + def _validate_task_encoding(self, task_encoding: torch.Tensor) -> None: + """Validate one-hot task identities stored in the rollout.""" + if task_encoding.shape[-1] != len(self.task_names): + raise ValueError( + f"Expected {len(self.task_names)} task encoding dimensions, got {task_encoding.shape[-1]}." + ) + is_binary = torch.logical_or(task_encoding == 0.0, task_encoding == 1.0).all() + has_one_task = (task_encoding.sum(dim=-1) == 1.0).all() + if not bool(is_binary and has_one_task): + raise ValueError("Task encoding observations must be one-hot with exactly one active task per sample.") diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/agents/rsl_rl_ppo_cfg.py index 918b9b387223..b0b931e324d8 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/__init__.py new file mode 100644 index 000000000000..b3618332aa8a --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/__init__.py @@ -0,0 +1,10 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""MDP terms for contributed heterogeneous manipulation training.""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/__init__.pyi b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/__init__.pyi new file mode 100644 index 000000000000..cd12356000bf --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/__init__.pyi @@ -0,0 +1,79 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +__all__ = [ + "CabinetOpenDrawerBonus", + "LiftGoalTracking", + "LiftSuccess", + "SelectedBinaryJointPositionActionCfg", + "SelectedJointPositionActionCfg", + "SelectedUniformPoseCommandCfg", + "articulation_state_invalid", + "cabinet_align_ee_handle", + "cabinet_align_grasp", + "cabinet_approach_ee_handle", + "cabinet_approach_gripper", + "cabinet_drawer_state", + "cabinet_ee_to_handle", + "cabinet_grasp_handle", + "cabinet_multi_stage_open", + "lift_ee_object_distance", + "lift_goal_tracking", + "lift_object_dropped", + "lift_object_height", + "lift_object_out_of_bounds", + "lift_object_position_b", + "lift_object_pose_b", + "reach_orientation_error", + "reach_position_error", + "reach_success", + "reset_multitask_scene", + "selected_action_l2", + "selected_action_rate_l2", + "selected_joint_pos_rel", + "selected_joint_vel_l2", + "selected_joint_vel_rel", + "task_encoding", + "task_time_out", +] + +from .actions_cfg import SelectedBinaryJointPositionActionCfg, SelectedJointPositionActionCfg +from .commands_cfg import SelectedUniformPoseCommandCfg +from .events import reset_multitask_scene +from .observations import ( + cabinet_drawer_state, + cabinet_ee_to_handle, + lift_object_position_b, + lift_object_pose_b, + selected_joint_pos_rel, + selected_joint_vel_rel, + task_encoding, +) +from .rewards import ( + CabinetOpenDrawerBonus, + LiftGoalTracking, + LiftSuccess, + cabinet_align_ee_handle, + cabinet_align_grasp, + cabinet_approach_ee_handle, + cabinet_approach_gripper, + cabinet_grasp_handle, + cabinet_multi_stage_open, + lift_ee_object_distance, + lift_goal_tracking, + lift_object_height, + reach_orientation_error, + reach_position_error, + selected_action_l2, + selected_action_rate_l2, + selected_joint_vel_l2, +) +from .terminations import ( + articulation_state_invalid, + lift_object_dropped, + lift_object_out_of_bounds, + reach_success, + task_time_out, +) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/actions.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/actions.py new file mode 100644 index 000000000000..d2cb2af78a22 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/actions.py @@ -0,0 +1,130 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Selection-aware joint action terms.""" + +from __future__ import annotations + +import math +from collections.abc import Sequence +from typing import TYPE_CHECKING + +import torch + +from isaaclab.managers import ActionTerm + +from ..selection_utils import SceneEntitySelectionCfg + +if TYPE_CHECKING: + from isaaclab.assets import Articulation + from isaaclab.envs import ManagerBasedEnv + + from .actions_cfg import SelectedBinaryJointPositionActionCfg, SelectedJointPositionActionCfg + + +class _SelectedJointAction(ActionTerm): + """Resolve joints and maintain global inputs for a partial articulation view.""" + + cfg: SelectedBinaryJointPositionActionCfg | SelectedJointPositionActionCfg + _asset: Articulation + + def __init__( + self, cfg: SelectedBinaryJointPositionActionCfg | SelectedJointPositionActionCfg, env: ManagerBasedEnv + ) -> None: + super().__init__(cfg, env) + joint_ids, self._joint_names = self._asset.find_joints(cfg.joint_names, preserve_order=True, as_proxy=True) + self._joint_ids = joint_ids.torch + self._num_joints = len(joint_ids) + self._asset_cfg = SceneEntitySelectionCfg(cfg.asset_name) + self._asset_cfg.resolve(env.scene) + self._raw_actions = torch.zeros(env.num_envs, self.action_dim, device=self.device) + + @property + def raw_actions(self) -> torch.Tensor: + """Unprocessed global action batch.""" + return self._raw_actions + + @property + def processed_actions(self) -> torch.Tensor: + """Processed actions in asset physics-view order.""" + return self._processed_actions + + def reset(self, env_ids: Sequence[int] | None = None) -> None: + """Clear raw actions for the reset global environments.""" + self._raw_actions[env_ids] = 0.0 + + +class SelectedJointPositionAction(_SelectedJointAction): + """Apply a global action batch to one articulation's selected physics-view rows.""" + + cfg: SelectedJointPositionActionCfg + _asset: Articulation + + def __init__(self, cfg: SelectedJointPositionActionCfg, env: ManagerBasedEnv) -> None: + super().__init__(cfg, env) + self._joint_limit_margin = cfg.joint_limit_margin + if self._joint_limit_margin is not None: + if not math.isfinite(self._joint_limit_margin) or self._joint_limit_margin < 0.0: + raise ValueError("joint_limit_margin must be finite and non-negative.") + limits = self._asset.data.soft_joint_pos_limits.torch[:, self._joint_ids] + if torch.any(limits[..., 0] + self._joint_limit_margin > limits[..., 1] - self._joint_limit_margin): + raise ValueError("joint_limit_margin leaves an empty target range for at least one joint.") + self._processed_actions = torch.zeros(len(self._asset_cfg.env_ids), self.action_dim, device=self.device) + + @property + def action_dim(self) -> int: + """Number of controlled joints.""" + return self._num_joints + + def process_actions(self, actions: torch.Tensor) -> None: + """Scale global actions and align them with the articulation physics view.""" + self._raw_actions[:] = actions + self._processed_actions = actions[self._asset_cfg.env_ids] * self.cfg.scale + if not self.cfg.relative: + self._processed_actions += self._asset.data.default_joint_pos.torch[:, self._joint_ids] + + def apply_actions(self) -> None: + """Write joint-position targets to the selected articulation instances.""" + target = self._processed_actions + if self.cfg.relative: + target = target + self._asset.data.joint_pos.torch[:, self._joint_ids] + if self._joint_limit_margin is not None: + limits = self._asset.data.soft_joint_pos_limits.torch[:, self._joint_ids] + target = torch.maximum( + torch.minimum(target, limits[..., 1] - self._joint_limit_margin), + limits[..., 0] + self._joint_limit_margin, + ) + self._asset.actuators.target_command.set_position_index(value=target, joint_ids=self._joint_ids) + + +class SelectedBinaryJointPositionAction(_SelectedJointAction): + """Map one binary global action to joint targets in asset physics-view order.""" + + cfg: SelectedBinaryJointPositionActionCfg + _asset: Articulation + + def __init__(self, cfg: SelectedBinaryJointPositionActionCfg, env: ManagerBasedEnv) -> None: + super().__init__(cfg, env) + self._processed_actions = torch.zeros(len(self._asset_cfg.env_ids), self._num_joints, device=self.device) + self._open_command = torch.full((self._num_joints,), cfg.open_command, device=self.device) + self._close_command = torch.full((self._num_joints,), cfg.close_command, device=self.device) + + @property + def action_dim(self) -> int: + """Binary action dimension.""" + return 1 + + def process_actions(self, actions: torch.Tensor) -> None: + """Choose open or closed targets from the sign of selected actions.""" + self._raw_actions[:] = actions + self._processed_actions = torch.where( + actions[self._asset_cfg.env_ids] < 0.0, self._close_command, self._open_command + ) + + def apply_actions(self) -> None: + """Write selected binary joint targets.""" + self._asset.actuators.target_command.set_position_index( + value=self._processed_actions, joint_ids=self._joint_ids + ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/actions_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/actions_cfg.py index 2fe08e503db8..66e231c0a8ee 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/actions_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/actions_cfg.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING from isaaclab.managers import ActionTermCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: from .actions import SelectedBinaryJointPositionAction, SelectedJointPositionAction diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/commands.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/commands.py new file mode 100644 index 000000000000..2f85cceb4472 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/commands.py @@ -0,0 +1,103 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Selection-aware pose commands for contributed heterogeneous task batches.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +import torch + +from isaaclab.managers import CommandTerm +from isaaclab.markers import VisualizationMarkers +from isaaclab.utils.math import combine_frame_transforms, quat_from_euler_xyz, quat_unique + +if TYPE_CHECKING: + from isaaclab.envs import ManagerBasedRLEnv + + from .commands_cfg import SelectedUniformPoseCommandCfg + + +class SelectedUniformPoseCommand(CommandTerm): + """Sample root-frame pose commands only where the configured scene entity exists.""" + + cfg: SelectedUniformPoseCommandCfg + + def __init__(self, cfg: SelectedUniformPoseCommandCfg, env: ManagerBasedRLEnv): + super().__init__(cfg, env) + self.pose_command = torch.zeros(self.num_envs, 7, device=self.device) + self._all_env_ids = torch.arange(self.num_envs, device=self.device) + self._reference_cfg = cfg.reference_cfg + self._tracked_cfg = cfg.tracked_cfg + self._reference_cfg.resolve(env.scene) + self._tracked_cfg.resolve(env.scene) + self._reference = env.scene[self._reference_cfg.name] + self._tracked = env.scene[self._tracked_cfg.name] + + @property + def command(self) -> torch.Tensor: + """Pose command, shape ``(num_envs, 7)``.""" + return self.pose_command + + def _resample_command(self, env_ids: Sequence[int]) -> None: + global_env_ids = self._all_env_ids[env_ids] + _, selected_env_ids = self._reference_cfg.select(global_env_ids) + ranges = self.cfg.ranges + sample = torch.empty(len(selected_env_ids), device=self.device) + self.pose_command[selected_env_ids, 0] = sample.uniform_(*ranges.pos_x) + self.pose_command[selected_env_ids, 1] = sample.uniform_(*ranges.pos_y) + self.pose_command[selected_env_ids, 2] = sample.uniform_(*ranges.pos_z) + euler = torch.empty(len(selected_env_ids), 3, device=self.device) + euler[:, 0].uniform_(*ranges.roll) + euler[:, 1].uniform_(*ranges.pitch) + euler[:, 2].uniform_(*ranges.yaw) + self.pose_command[selected_env_ids, 3:] = quat_unique(quat_from_euler_xyz(*euler.unbind(-1))) + + def _update_metrics(self) -> None: + pass + + def _update_command(self) -> None: + pass + + def _set_debug_vis_impl(self, debug_vis: bool) -> None: + if debug_vis and not hasattr(self, "goal_pose_visualizer"): + self.goal_pose_visualizer = VisualizationMarkers(self.cfg.goal_pose_visualizer_cfg) + self.current_pose_visualizer = VisualizationMarkers(self.cfg.current_pose_visualizer_cfg) + if not hasattr(self, "goal_pose_visualizer"): + return + self.goal_pose_visualizer.set_visibility(debug_vis) + self.current_pose_visualizer.set_visibility(debug_vis) + + def _debug_vis_callback(self, event) -> None: + if not self._reference.is_initialized or not self._tracked.is_initialized: + return + + reference_pose_w = self._reference.data.root_link_pose_w.torch + command = self.pose_command[self._reference_cfg.env_ids] + goal_pos_w, goal_quat_w = combine_frame_transforms( + reference_pose_w[:, :3], reference_pose_w[:, 3:7], command[:, :3], command[:, 3:] + ) + self.goal_pose_visualizer.visualize( + goal_pos_w, + goal_quat_w, + environment_ids=self._reference_cfg.env_ids, + ) + + if self._tracked_cfg.body_names is None: + tracked_pose_w = self._tracked.data.root_link_pose_w.torch + else: + tracked_pose_w = self._tracked.data.body_link_pose_w.torch[:, self._tracked_cfg.body_ids] + if tracked_pose_w.shape[1] != 1: + raise ValueError( + f"Expected '{self._tracked_cfg.name}' to select one tracked body, got {tracked_pose_w.shape[1]}." + ) + tracked_pose_w = tracked_pose_w[:, 0] + self.current_pose_visualizer.visualize( + tracked_pose_w[:, :3], + tracked_pose_w[:, 3:7], + environment_ids=self._tracked_cfg.env_ids, + ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/commands_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/commands_cfg.py index 8a9598f7a0bd..a18ba66eb4fa 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/commands_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/commands_cfg.py @@ -12,7 +12,7 @@ from isaaclab.managers import CommandTermCfg from isaaclab.markers import FRAME_MARKER_CFG, VisualizationMarkersCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from ..selection_utils import SceneEntitySelectionCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/events.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/events.py new file mode 100644 index 000000000000..ffc4ed42fd36 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/events.py @@ -0,0 +1,119 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Selection-aware reset events for heterogeneous manipulation scenes.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import torch + +from ..selection_utils import SceneEntitySelectionCfg + +if TYPE_CHECKING: + from isaaclab.assets import Articulation, RigidObject + from isaaclab.envs import ManagerBasedEnv + + +def _reset_root(env: ManagerBasedEnv, asset_cfg: SceneEntitySelectionCfg, env_ids: torch.Tensor) -> None: + """Write an asset's default root state for selected global environments.""" + asset: Articulation | RigidObject = env.scene[asset_cfg.name] + rows, selected_env_ids = asset_cfg.select(env_ids) + if rows.numel() == 0: + return + root_pose = asset.data.default_root_pose.torch[rows].clone() + root_pose[:, :3] += env.scene.env_origins[selected_env_ids] + asset.write_root_pose_to_sim_index(root_pose=root_pose, env_ids=rows) + asset.write_root_velocity_to_sim_index(root_velocity=asset.data.default_root_vel.torch[rows], env_ids=rows) + + +def _reset_joints_default(env: ManagerBasedEnv, asset_cfg: SceneEntitySelectionCfg, env_ids: torch.Tensor) -> None: + """Write default articulation joint states for selected global environments.""" + asset: Articulation = env.scene[asset_cfg.name] + rows, _ = asset_cfg.select(env_ids) + if rows.numel() == 0: + return + joint_pos = asset.data.default_joint_pos.torch[rows].clone() + joint_vel = asset.data.default_joint_vel.torch[rows].clone() + asset.write_joint_position_to_sim_index(position=joint_pos, env_ids=rows) + asset.write_joint_velocity_to_sim_index(velocity=joint_vel, env_ids=rows) + asset.actuators.target_command.set_position_index(value=joint_pos, env_ids=rows) + asset.actuators.target_command.set_velocity_index(value=joint_vel, env_ids=rows) + + +def _randomize_joint_offset( + env: ManagerBasedEnv, + asset_cfg: SceneEntitySelectionCfg, + env_ids: torch.Tensor, + position_range: tuple[float, float], +) -> None: + """Reset articulation joints with uniform offsets from their defaults.""" + asset: Articulation = env.scene[asset_cfg.name] + rows, _ = asset_cfg.select(env_ids) + if rows.numel() == 0: + return + joint_pos = asset.data.default_joint_pos.torch[rows].clone() + joint_pos += torch.empty_like(joint_pos).uniform_(*position_range) + limits = asset.data.soft_joint_pos_limits.torch[rows] + joint_pos.clamp_(limits[..., 0], limits[..., 1]) + joint_vel = torch.zeros_like(joint_pos) + asset.write_joint_position_to_sim_index(position=joint_pos, env_ids=rows) + asset.write_joint_velocity_to_sim_index(velocity=joint_vel, env_ids=rows) + asset.actuators.target_command.set_position_index(value=joint_pos, env_ids=rows) + + +def _randomize_joint_scale( + env: ManagerBasedEnv, + asset_cfg: SceneEntitySelectionCfg, + env_ids: torch.Tensor, + position_range: tuple[float, float], +) -> None: + """Reset articulation joints by uniformly scaling their default positions.""" + asset: Articulation = env.scene[asset_cfg.name] + rows, _ = asset_cfg.select(env_ids) + if rows.numel() == 0: + return + joint_pos = asset.data.default_joint_pos.torch[rows].clone() + joint_pos *= torch.empty_like(joint_pos).uniform_(*position_range) + limits = asset.data.soft_joint_pos_limits.torch[rows] + joint_pos.clamp_(limits[..., 0], limits[..., 1]) + joint_vel = torch.zeros_like(joint_pos) + asset.write_joint_position_to_sim_index(position=joint_pos, env_ids=rows) + asset.write_joint_velocity_to_sim_index(velocity=joint_vel, env_ids=rows) + asset.actuators.target_command.set_position_index(value=joint_pos, env_ids=rows) + + +def reset_multitask_scene( + env: ManagerBasedEnv, + env_ids: torch.Tensor, + root_asset_cfgs: tuple[SceneEntitySelectionCfg, ...], + lift_robot_cfg: SceneEntitySelectionCfg, + lift_object_cfg: SceneEntitySelectionCfg, + cabinet_robot_cfg: SceneEntitySelectionCfg, + cabinet_cfg: SceneEntitySelectionCfg, + reach_robot_cfg: SceneEntitySelectionCfg, +) -> None: + """Reset active assets and apply task-specific initial-state randomization.""" + for asset_cfg in root_asset_cfgs: + _reset_root(env, asset_cfg, env_ids) + + _reset_joints_default(env, lift_robot_cfg, env_ids) + _reset_joints_default(env, cabinet_cfg, env_ids) + _randomize_joint_offset(env, cabinet_robot_cfg, env_ids, (-0.1, 0.1)) + _randomize_joint_scale(env, reach_robot_cfg, env_ids, (0.75, 1.25)) + + lift_object: RigidObject = env.scene[lift_object_cfg.name] + object_rows, object_env_ids = lift_object_cfg.select(env_ids) + if object_rows.numel() == 0: + return + object_pose = lift_object.data.default_root_pose.torch[object_rows].clone() + object_pose[:, :3] += env.scene.env_origins[object_env_ids] + object_pose[:, 0] += torch.empty(len(object_rows), device=env.device).uniform_(-0.1, 0.1) + object_pose[:, 1] += torch.empty(len(object_rows), device=env.device).uniform_(-0.25, 0.25) + lift_object.write_root_pose_to_sim_index(root_pose=object_pose, env_ids=object_rows) + lift_object.write_root_velocity_to_sim_index( + root_velocity=torch.zeros(len(object_rows), 6, device=env.device), env_ids=object_rows + ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/observations.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/observations.py new file mode 100644 index 000000000000..0f7316e2533f --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/observations.py @@ -0,0 +1,112 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Selection-aware observations for heterogeneous manipulation tasks.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import torch + +from isaaclab.utils import math as math_utils + +from ..selection_utils import SceneEntitySelectionCfg +from .utils import _offset_body_pose + +if TYPE_CHECKING: + from isaaclab.assets import Articulation, RigidObject + from isaaclab.envs import ManagerBasedRLEnv + + +def task_encoding(env: ManagerBasedRLEnv, task_asset_cfgs: tuple[SceneEntitySelectionCfg, ...]) -> torch.Tensor: + """Return a one-hot identity for the selected manipulation task.""" + encoding = torch.zeros(env.num_envs, len(task_asset_cfgs), device=env.device) + for task_id, asset_cfg in enumerate(task_asset_cfgs): + encoding[asset_cfg.instance_ids >= 0, task_id] = 1.0 + return encoding + + +def selected_joint_pos_rel(env: ManagerBasedRLEnv, asset_cfg: SceneEntitySelectionCfg) -> torch.Tensor: + """Return relative joint positions in global environment order.""" + asset: Articulation = env.scene[asset_cfg.name] + values = ( + asset.data.joint_pos.torch[:, asset_cfg.joint_ids] - asset.data.default_joint_pos.torch[:, asset_cfg.joint_ids] + ) + return asset_cfg.scatter_to_envs(values) + + +def selected_joint_vel_rel(env: ManagerBasedRLEnv, asset_cfg: SceneEntitySelectionCfg) -> torch.Tensor: + """Return relative joint velocities in global environment order.""" + asset: Articulation = env.scene[asset_cfg.name] + values = ( + asset.data.joint_vel.torch[:, asset_cfg.joint_ids] - asset.data.default_joint_vel.torch[:, asset_cfg.joint_ids] + ) + return asset_cfg.scatter_to_envs(values) + + +def lift_object_pose_b( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + object_cfg: SceneEntitySelectionCfg, +) -> torch.Tensor: + """Return the lift object's pose in the Franka root frame.""" + robot: Articulation = env.scene[robot_cfg.name] + object_asset: RigidObject = env.scene[object_cfg.name] + robot_rows = robot_cfg.instance_ids[object_cfg.env_ids] + pos_b, quat_b = math_utils.subtract_frame_transforms( + robot.data.root_pos_w.torch[robot_rows], + robot.data.root_quat_w.torch[robot_rows], + object_asset.data.root_pos_w.torch, + object_asset.data.root_quat_w.torch, + ) + return object_cfg.scatter_to_envs(torch.cat((pos_b, quat_b), dim=-1)) + + +def lift_object_position_b( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + object_cfg: SceneEntitySelectionCfg, +) -> torch.Tensor: + """Return the lift object's position [m] in the OpenArm root frame.""" + robot: Articulation = env.scene[robot_cfg.name] + object_asset: RigidObject = env.scene[object_cfg.name] + robot_rows = robot_cfg.instance_ids[object_cfg.env_ids] + pos_b, _ = math_utils.subtract_frame_transforms( + robot.data.root_pos_w.torch[robot_rows], + robot.data.root_quat_w.torch[robot_rows], + object_asset.data.root_pos_w.torch, + ) + return object_cfg.scatter_to_envs(pos_b) + + +def cabinet_drawer_state(env: ManagerBasedRLEnv, cabinet_cfg: SceneEntitySelectionCfg) -> torch.Tensor: + """Return top-drawer position and velocity in global environment order.""" + cabinet: Articulation = env.scene[cabinet_cfg.name] + values = torch.cat( + ( + cabinet.data.joint_pos.torch[:, cabinet_cfg.joint_ids], + cabinet.data.joint_vel.torch[:, cabinet_cfg.joint_ids], + ), + dim=-1, + ) + return cabinet_cfg.scatter_to_envs(values) + + +def cabinet_ee_to_handle( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + cabinet_cfg: SceneEntitySelectionCfg, +) -> torch.Tensor: + """Return the vector [m] from the cabinet Franka TCP to the drawer handle.""" + env_ids, ee_pos_w, _ = _offset_body_pose(env, robot_cfg, (0.0, 0.0, 0.1034)) + _, handle_pos_w, _ = _offset_body_pose( + env, + cabinet_cfg, + (0.305, 0.0, 0.01), + (0.5, -0.5, -0.5, 0.5), + ) + values = handle_pos_w[cabinet_cfg.instance_ids[env_ids]] - ee_pos_w + return robot_cfg.scatter_to_envs(values) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/rewards.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/rewards.py new file mode 100644 index 000000000000..7b57ea79eb85 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/rewards.py @@ -0,0 +1,364 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Selection-aware rewards for heterogeneous manipulation tasks.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import torch + +from isaaclab.managers import ManagerTermBase, RewardTermCfg +from isaaclab.utils import math as math_utils + +from ..selection_utils import SceneEntitySelectionCfg +from .utils import _offset_body_pose + +if TYPE_CHECKING: + from isaaclab.assets import Articulation, RigidObject + from isaaclab.envs import ManagerBasedRLEnv + + +def _action_slice(env: ManagerBasedRLEnv, term_names: tuple[str, ...]) -> slice: + """Return the cached contiguous global action slice occupied by action terms.""" + cache = env.__dict__.setdefault("_multitask_action_slices", {}) + if term_names not in cache: + starts = {} + start = 0 + for name, dim in zip(env.action_manager.active_terms, env.action_manager.action_term_dim): + starts[name] = (start, start + dim) + start += dim + cache[term_names] = slice(starts[term_names[0]][0], starts[term_names[-1]][1]) + return cache[term_names] + + +def _lift_goal_error( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + object_cfg: SceneEntitySelectionCfg, + command_name: str, +) -> tuple[torch.Tensor, torch.Tensor]: + """Return lift global IDs and object-to-goal position error [m].""" + robot: Articulation = env.scene[robot_cfg.name] + object_asset: RigidObject = env.scene[object_cfg.name] + robot_rows = robot_cfg.instance_ids[object_cfg.env_ids] + command = env.command_manager.get_command(command_name)[object_cfg.env_ids] + goal_pos_w, _ = math_utils.combine_frame_transforms( + robot.data.root_pos_w.torch[robot_rows], robot.data.root_quat_w.torch[robot_rows], command[:, :3] + ) + error = torch.linalg.norm(object_asset.data.root_pos_w.torch - goal_pos_w, dim=-1) + return object_cfg.env_ids, error + + +def lift_ee_object_distance( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + object_cfg: SceneEntitySelectionCfg, + std: float, +) -> torch.Tensor: + """Reward the lift OpenArm for bringing its TCP close to the object.""" + env_ids, tcp_pos_w, _ = _offset_body_pose(env, robot_cfg, (0.0, 0.0, 0.0)) + object_asset: RigidObject = env.scene[object_cfg.name] + distance = torch.linalg.norm( + object_asset.data.root_pos_w.torch[object_cfg.instance_ids[env_ids]] - tcp_pos_w, dim=-1 + ) + return robot_cfg.scatter_to_envs(1.0 - torch.tanh(distance / std)) + + +def lift_object_height( + env: ManagerBasedRLEnv, object_cfg: SceneEntitySelectionCfg, minimum_height: float +) -> torch.Tensor: + """Reward lift environments whose object rises above an environment-frame height [m].""" + object_asset: RigidObject = env.scene[object_cfg.name] + height = object_asset.data.root_pos_w.torch[:, 2] - env.scene.env_origins[object_cfg.env_ids, 2] + return object_cfg.scatter_to_envs((height > minimum_height).float()) + + +def lift_goal_tracking( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + object_cfg: SceneEntitySelectionCfg, + command_name: str, + std: float, + minimum_height: float, +) -> torch.Tensor: + """Reward lift-object target tracking after the object clears the table.""" + env_ids, error = _lift_goal_error(env, robot_cfg, object_cfg, command_name) + object_asset: RigidObject = env.scene[object_cfg.name] + height = object_asset.data.root_pos_w.torch[:, 2] - env.scene.env_origins[env_ids, 2] + reward = (height > minimum_height) * (1.0 - torch.tanh(error / std)) + return object_cfg.scatter_to_envs(reward) + + +class _LiftSuccessTerm(ManagerTermBase): + """Maintain and log sticky success state for lift reward terms.""" + + def __init__(self, cfg: RewardTermCfg, env: ManagerBasedRLEnv) -> None: + super().__init__(cfg, env) + self.succeeded = torch.zeros(env.num_envs, dtype=torch.bool, device=env.device) + + def reset(self, env_ids: torch.Tensor) -> None: + """Log and clear success for reset lift environments.""" + object_cfg: SceneEntitySelectionCfg = self.cfg.params["object_cfg"] + selected = env_ids[object_cfg.instance_ids[env_ids] >= 0] + if selected.numel() == 0: + return + log = self._env.extras.setdefault("log", {}) + log["Metrics/lift_success_rate"] = self.succeeded[selected].float().mean().item() + self.succeeded[selected] = False + + +class LiftGoalTracking(_LiftSuccessTerm): + """Reward OpenArm goal tracking and log sticky per-episode success.""" + + def __call__( + self, + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + object_cfg: SceneEntitySelectionCfg, + command_name: str, + std: float, + minimum_height: float, + success_threshold: float, + ) -> torch.Tensor: + """Return coarse goal tracking and update sticky success state.""" + env_ids, error = _lift_goal_error(env, robot_cfg, object_cfg, command_name) + object_asset: RigidObject = env.scene[object_cfg.name] + height = object_asset.data.root_pos_w.torch[:, 2] - env.scene.env_origins[env_ids, 2] + is_lifted = height > minimum_height + self.succeeded[env_ids] |= is_lifted & (error < success_threshold) + reward = is_lifted * (1.0 - torch.tanh(error / std)) + return object_cfg.scatter_to_envs(reward) + + +class LiftSuccess(_LiftSuccessTerm): + """Reward lift-goal completion and log sticky episode success.""" + + def __call__( + self, + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + object_cfg: SceneEntitySelectionCfg, + command_name: str, + threshold: float, + ) -> torch.Tensor: + """Reward lift environments whose object reaches the sampled goal.""" + env_ids, error = _lift_goal_error(env, robot_cfg, object_cfg, command_name) + succeeded = error < threshold + self.succeeded[env_ids] |= succeeded + return object_cfg.scatter_to_envs(succeeded.float()) + + +def _cabinet_frames( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + cabinet_cfg: SceneEntitySelectionCfg, +) -> tuple[torch.Tensor, ...]: + """Return aligned cabinet-task TCP, handle, and fingertip poses.""" + env_ids, ee_pos, ee_quat = _offset_body_pose(env, robot_cfg, (0.0, 0.0, 0.1034)) + _, handle_pos, handle_quat = _offset_body_pose(env, cabinet_cfg, (0.305, 0.0, 0.01), (0.5, -0.5, -0.5, 0.5)) + _, left_pos, _ = _offset_body_pose(env, robot_cfg, (0.0, 0.0, 0.046), body_index=1) + _, right_pos, _ = _offset_body_pose(env, robot_cfg, (0.0, 0.0, 0.046), body_index=2) + cabinet_rows = cabinet_cfg.instance_ids[env_ids] + return env_ids, ee_pos, ee_quat, handle_pos[cabinet_rows], handle_quat[cabinet_rows], left_pos, right_pos + + +def cabinet_approach_ee_handle( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + cabinet_cfg: SceneEntitySelectionCfg, + threshold: float, +) -> torch.Tensor: + """Reward cabinet environments for approaching the drawer handle.""" + env_ids, ee_pos, _, handle_pos, _, _, _ = _cabinet_frames(env, robot_cfg, cabinet_cfg) + distance = torch.linalg.norm(handle_pos - ee_pos, dim=-1) + reward = torch.square(1.0 / (1.0 + distance**2)) + reward = torch.where(distance <= threshold, 2.0 * reward, reward) + return robot_cfg.scatter_to_envs(torch.nan_to_num(reward, nan=0.0, posinf=0.0, neginf=0.0)) + + +def cabinet_align_ee_handle( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + cabinet_cfg: SceneEntitySelectionCfg, +) -> torch.Tensor: + """Reward cabinet environments for aligning the TCP with the drawer handle.""" + env_ids, _, ee_quat, _, handle_quat, _, _ = _cabinet_frames(env, robot_cfg, cabinet_cfg) + ee_rot = math_utils.matrix_from_quat(ee_quat) + handle_rot = math_utils.matrix_from_quat(handle_quat) + align_z = torch.sum(ee_rot[..., 2] * -handle_rot[..., 0], dim=-1) + align_x = torch.sum(ee_rot[..., 0] * -handle_rot[..., 1], dim=-1) + reward = 0.5 * (torch.sign(align_z) * align_z**2 + torch.sign(align_x) * align_x**2) + return robot_cfg.scatter_to_envs(torch.nan_to_num(reward, nan=0.0, posinf=0.0, neginf=0.0)) + + +def _cabinet_grasp_alignment( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + cabinet_cfg: SceneEntitySelectionCfg, +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + """Return cabinet IDs, grasp mask, handle position, and fingertip positions.""" + env_ids, _, _, handle_pos, _, left_pos, right_pos = _cabinet_frames(env, robot_cfg, cabinet_cfg) + graspable = (right_pos[:, 2] < handle_pos[:, 2]) & (left_pos[:, 2] > handle_pos[:, 2]) + return env_ids, graspable, handle_pos, torch.stack((left_pos, right_pos), dim=1) + + +def cabinet_align_grasp( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + cabinet_cfg: SceneEntitySelectionCfg, +) -> torch.Tensor: + """Reward cabinet environments when the fingers straddle the drawer handle.""" + env_ids, graspable, _, _ = _cabinet_grasp_alignment(env, robot_cfg, cabinet_cfg) + return robot_cfg.scatter_to_envs(graspable.float()) + + +def cabinet_approach_gripper( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + cabinet_cfg: SceneEntitySelectionCfg, + offset: float, +) -> torch.Tensor: + """Reward cabinet environments for placing both fingers around the handle.""" + env_ids, graspable, handle_pos, fingertips = _cabinet_grasp_alignment(env, robot_cfg, cabinet_cfg) + distances = torch.abs(fingertips[..., 2] - handle_pos[:, None, 2]) + reward = graspable * torch.sum(offset - distances, dim=-1) + return robot_cfg.scatter_to_envs(torch.nan_to_num(reward, nan=0.0, posinf=0.0, neginf=0.0)) + + +def cabinet_grasp_handle( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + cabinet_cfg: SceneEntitySelectionCfg, + threshold: float, + open_joint_pos: float, +) -> torch.Tensor: + """Reward closing the cabinet Franka fingers near the drawer handle.""" + env_ids, ee_pos, _, handle_pos, _, _, _ = _cabinet_frames(env, robot_cfg, cabinet_cfg) + robot: Articulation = env.scene[robot_cfg.name] + distance = torch.linalg.norm(handle_pos - ee_pos, dim=-1) + reward = (distance <= threshold) * torch.sum( + open_joint_pos - robot.data.joint_pos.torch[:, robot_cfg.joint_ids], dim=-1 + ) + return robot_cfg.scatter_to_envs(torch.nan_to_num(reward, nan=0.0, posinf=0.0, neginf=0.0)) + + +class CabinetOpenDrawerBonus(ManagerTermBase): + """Reward drawer opening and log cabinet-task success statistics.""" + + def __init__(self, cfg: RewardTermCfg, env: ManagerBasedRLEnv) -> None: + super().__init__(cfg, env) + self.succeeded = torch.zeros(env.num_envs, dtype=torch.bool, device=env.device) + self.best_drawer_pos = torch.zeros(env.num_envs, device=env.device) + + def reset(self, env_ids: torch.Tensor) -> None: + """Log and clear episode statistics for reset cabinet environments.""" + cabinet_cfg: SceneEntitySelectionCfg = self.cfg.params["cabinet_cfg"] + selected = env_ids[cabinet_cfg.instance_ids[env_ids] >= 0] + if selected.numel() == 0: + return + log = self._env.extras.setdefault("log", {}) + log["Metrics/cabinet_success_rate"] = self.succeeded[selected].float().mean().item() + log["Metrics/cabinet_drawer_pos"] = self.best_drawer_pos[selected].mean().item() + self.succeeded[selected] = False + self.best_drawer_pos[selected] = 0.0 + + def __call__( + self, + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + cabinet_cfg: SceneEntitySelectionCfg, + success_threshold: float, + ) -> torch.Tensor: + """Compute drawer displacement with a grasp-alignment multiplier.""" + cabinet: Articulation = env.scene[cabinet_cfg.name] + drawer_pos = cabinet.data.joint_pos.torch[:, cabinet_cfg.joint_ids[0]] + drawer_limits = cabinet.data.soft_joint_pos_limits.torch[:, cabinet_cfg.joint_ids[0]] + drawer_pos = torch.nan_to_num(drawer_pos, nan=0.0, posinf=0.0, neginf=0.0) + drawer_pos = torch.maximum(torch.minimum(drawer_pos, drawer_limits[:, 1]), drawer_limits[:, 0]) + env_ids, graspable, _, _ = _cabinet_grasp_alignment(env, robot_cfg, cabinet_cfg) + self.succeeded[env_ids] |= drawer_pos > success_threshold + self.best_drawer_pos[env_ids] = torch.maximum(self.best_drawer_pos[env_ids], drawer_pos) + return robot_cfg.scatter_to_envs((graspable.float() + 1.0) * drawer_pos) + + +def cabinet_multi_stage_open( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + cabinet_cfg: SceneEntitySelectionCfg, +) -> torch.Tensor: + """Reward easy, medium, and hard drawer-opening milestones.""" + cabinet: Articulation = env.scene[cabinet_cfg.name] + drawer_pos = cabinet.data.joint_pos.torch[:, cabinet_cfg.joint_ids[0]] + _, graspable, _, _ = _cabinet_grasp_alignment(env, robot_cfg, cabinet_cfg) + reward = (drawer_pos > 0.01) * 0.5 + reward += (drawer_pos > 0.2) * graspable + reward += (drawer_pos > 0.3) * graspable + return cabinet_cfg.scatter_to_envs(reward) + + +def reach_position_error(env: ManagerBasedRLEnv, robot_cfg: SceneEntitySelectionCfg, command_name: str) -> torch.Tensor: + """Return UR10 end-effector position error [m] in reach environments.""" + robot: Articulation = env.scene[robot_cfg.name] + command = env.command_manager.get_command(command_name)[robot_cfg.env_ids] + goal_pos_w, _ = math_utils.combine_frame_transforms( + robot.data.root_pos_w.torch, robot.data.root_quat_w.torch, command[:, :3] + ) + error = torch.linalg.norm(robot.data.body_pos_w.torch[:, robot_cfg.body_ids[0]] - goal_pos_w, dim=-1) + return robot_cfg.scatter_to_envs(error) + + +def reach_orientation_error( + env: ManagerBasedRLEnv, robot_cfg: SceneEntitySelectionCfg, command_name: str +) -> torch.Tensor: + """Return UR10 end-effector orientation error [rad] in reach environments.""" + robot: Articulation = env.scene[robot_cfg.name] + command = env.command_manager.get_command(command_name)[robot_cfg.env_ids] + goal_quat_w = math_utils.quat_mul(robot.data.root_quat_w.torch, command[:, 3:7]) + error = math_utils.quat_error_magnitude(robot.data.body_quat_w.torch[:, robot_cfg.body_ids[0]], goal_quat_w) + return robot_cfg.scatter_to_envs(error) + + +def selected_action_l2( + env: ManagerBasedRLEnv, + task_asset_cfg: SceneEntitySelectionCfg, + action_term_names: tuple[str, ...], +) -> torch.Tensor: + """Penalize one task action head in environments containing that task asset.""" + selected_slice = _action_slice(env, action_term_names) + penalty = torch.sum(torch.square(env.action_manager.action[:, selected_slice]), dim=-1) + return penalty * (task_asset_cfg.instance_ids >= 0) + + +def selected_action_rate_l2( + env: ManagerBasedRLEnv, + task_asset_cfg: SceneEntitySelectionCfg, + action_term_names: tuple[str, ...], +) -> torch.Tensor: + """Penalize changes in one task action head where that task asset exists.""" + selected_slice = _action_slice(env, action_term_names) + delta = env.action_manager.action[:, selected_slice] - env.action_manager.prev_action[:, selected_slice] + return torch.sum(torch.square(delta), dim=-1) * (task_asset_cfg.instance_ids >= 0) + + +def selected_joint_vel_l2( + env: ManagerBasedRLEnv, + asset_cfg: SceneEntitySelectionCfg, + max_velocity: float | None = None, +) -> torch.Tensor: + """Penalize articulation joint velocities in environments containing it. + + Args: + env: Manager-based RL environment. + asset_cfg: Selection-aware articulation and joints. + max_velocity: Optional absolute velocity bound [m/s or rad/s, depending on joint type]. + """ + asset: Articulation = env.scene[asset_cfg.name] + joint_vel = torch.nan_to_num(asset.data.joint_vel.torch[:, asset_cfg.joint_ids]) + if max_velocity is not None: + joint_vel = joint_vel.clamp(-max_velocity, max_velocity) + values = torch.sum(torch.square(joint_vel), dim=-1) + return asset_cfg.scatter_to_envs(values) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/terminations.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/terminations.py new file mode 100644 index 000000000000..3aebab1c40f4 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/terminations.py @@ -0,0 +1,101 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Selection-aware terminations for heterogeneous manipulation tasks.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import torch + +from ..selection_utils import SceneEntitySelectionCfg +from .rewards import reach_orientation_error, reach_position_error + +if TYPE_CHECKING: + from isaaclab.assets import Articulation, RigidObject + from isaaclab.envs import ManagerBasedRLEnv + + +def reach_success( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntitySelectionCfg, + command_name: str, + position_threshold: float, + orientation_threshold: float, +) -> torch.Tensor: + """Terminate successful UR10 reach environments.""" + position_error = reach_position_error(env, robot_cfg, command_name) + orientation_error = reach_orientation_error(env, robot_cfg, command_name) + return ( + (robot_cfg.instance_ids >= 0) + & (position_error < position_threshold) + & (orientation_error < orientation_threshold) + ) + + +def lift_object_out_of_bounds( + env: ManagerBasedRLEnv, + object_cfg: SceneEntitySelectionCfg, + bounds: tuple[tuple[float, float], tuple[float, float], tuple[float, float]], +) -> torch.Tensor: + """Terminate lift environments whose object leaves configured local bounds [m].""" + object_asset: RigidObject = env.scene[object_cfg.name] + position = object_asset.data.root_pos_w.torch - env.scene.env_origins[object_cfg.env_ids] + lower = position.new_tensor([axis[0] for axis in bounds]) + upper = position.new_tensor([axis[1] for axis in bounds]) + outside = torch.any((position < lower) | (position > upper), dim=-1) + return object_cfg.scatter_to_envs(outside) + + +def lift_object_dropped( + env: ManagerBasedRLEnv, + object_cfg: SceneEntitySelectionCfg, + minimum_height: float, +) -> torch.Tensor: + """Terminate lift environments whose object falls below a local height [m].""" + object_asset: RigidObject = env.scene[object_cfg.name] + height = object_asset.data.root_pos_w.torch[:, 2] - env.scene.env_origins[object_cfg.env_ids, 2] + return object_cfg.scatter_to_envs(height < minimum_height) + + +def articulation_state_invalid( + env: ManagerBasedRLEnv, + asset_cfg: SceneEntitySelectionCfg, + max_joint_velocity: float, + joint_position_margin: float, +) -> torch.Tensor: + """Terminate selected articulations with non-finite or implausible joint state. + + Args: + env: Manager-based RL environment. + asset_cfg: Selection-aware articulation and joints. + max_joint_velocity: Maximum absolute joint velocity [m/s or rad/s, depending on joint type]. + joint_position_margin: Allowed distance beyond soft position limits [m or rad, depending on joint type]. + """ + asset: Articulation = env.scene[asset_cfg.name] + joint_pos = asset.data.joint_pos.torch[:, asset_cfg.joint_ids] + joint_vel = asset.data.joint_vel.torch[:, asset_cfg.joint_ids] + limits = asset.data.soft_joint_pos_limits.torch[:, asset_cfg.joint_ids] + invalid = ~torch.isfinite(joint_pos).all(dim=-1) | ~torch.isfinite(joint_vel).all(dim=-1) + invalid |= torch.any(torch.abs(joint_vel) > max_joint_velocity, dim=-1) + invalid |= torch.any( + (joint_pos < limits[..., 0] - joint_position_margin) | (joint_pos > limits[..., 1] + joint_position_margin), + dim=-1, + ) + return asset_cfg.scatter_to_envs(invalid) + + +def task_time_out( + env: ManagerBasedRLEnv, + task_asset_cfgs: tuple[SceneEntitySelectionCfg, ...], + episode_lengths_s: tuple[float, ...], +) -> torch.Tensor: + """Time out each task at its homogeneous episode duration [s].""" + timed_out = torch.zeros(env.num_envs, dtype=torch.bool, device=env.device) + for asset_cfg, episode_length_s in zip(task_asset_cfgs, episode_lengths_s): + max_steps = round(episode_length_s / env.step_dt) + timed_out |= (asset_cfg.instance_ids >= 0) & (env.episode_length_buf >= max_steps) + return timed_out diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/utils.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/utils.py new file mode 100644 index 000000000000..d7725d4423bc --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/utils.py @@ -0,0 +1,47 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Private tensor helpers for heterogeneous manipulation MDP terms.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import torch + +from isaaclab.utils import math as math_utils + +from ..selection_utils import SceneEntitySelectionCfg + +if TYPE_CHECKING: + from isaaclab.assets import Articulation + from isaaclab.envs import ManagerBasedRLEnv + + +def _offset_body_pose( + env: ManagerBasedRLEnv, + asset_cfg: SceneEntitySelectionCfg, + offset_pos: tuple[float, float, float], + offset_quat: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0), + body_index: int = 0, +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """Return selected global IDs and an offset body pose in the world frame. + + Args: + env: Manager-based RL environment. + asset_cfg: Selection-aware entity containing exactly one body. + offset_pos: Child-frame translation [m]. + offset_quat: Child-frame quaternion in ``(x, y, z, w)`` order. + body_index: Index into the resolved body selection. + + Returns: + Global environment IDs, positions [m], and quaternions for the selected view rows. + """ + asset: Articulation = env.scene[asset_cfg.name] + pose = asset.data.body_link_pose_w.torch[:, asset_cfg.body_ids[body_index]] + pos = pose.new_tensor(offset_pos).expand(len(pose), -1) + quat = math_utils.convert_quat(pose.new_tensor(offset_quat).expand(len(pose), -1), to="wxyz") + pos_w, quat_w = math_utils.combine_frame_transforms(pose[:, :3], pose[:, 3:7], pos, quat) + return asset_cfg.env_ids, pos_w, quat_w diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/multitask_env.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/multitask_env.py new file mode 100644 index 000000000000..ace525ab8eac --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/multitask_env.py @@ -0,0 +1,61 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Manager-based environment with selection-aware heterogeneous resets.""" + +from collections.abc import Sequence + +import torch + +from isaaclab.envs import ManagerBasedRLEnv + +from .selection_utils import SceneEntitySelectionCfg + + +class MultitaskManipulationEnv(ManagerBasedRLEnv): + """Manager-based manipulation environment whose assets occupy partial physics views.""" + + def _reset_idx(self, env_ids: Sequence[int]) -> None: + """Reset global environments through each asset's view-row mapping. + + Args: + env_ids: Global environment IDs to reset. + """ + global_env_ids = torch.as_tensor(env_ids, dtype=torch.long, device=self.device) + + self.curriculum_manager.compute(env_ids=global_env_ids) + for asset_name, asset in (*self.scene.articulations.items(), *self.scene.rigid_objects.items()): + rows, _ = self._get_entity_selection(asset_name).select(global_env_ids) + if rows.numel() > 0: + asset.reset(rows) + + if "reset" in self.event_manager.available_modes: + env_step_count = self._sim_step_counter // self.cfg.decimation + self.event_manager.apply(mode="reset", env_ids=global_env_ids, global_env_step_count=env_step_count) + + self.extras["log"] = {} + managers = ( + self.observation_manager, + self.action_manager, + self.reward_manager, + self.curriculum_manager, + self.command_manager, + self.event_manager, + self.termination_manager, + self.recorder_manager, + ) + for manager in managers: + self.extras["log"].update(manager.reset(global_env_ids)) + + self.episode_length_buf[global_env_ids] = 0 + self.sim.render_context.reset_scene_state_cadence() + + def _get_entity_selection(self, asset_name: str) -> SceneEntitySelectionCfg: + """Return the cached selection configuration for a complete scene asset.""" + cache: dict[str, SceneEntitySelectionCfg] = self.__dict__.setdefault("_scene_entity_selections", {}) + if asset_name not in cache: + cache[asset_name] = SceneEntitySelectionCfg(asset_name) + cache[asset_name].resolve(self.scene) + return cache[asset_name] diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/multitask_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/multitask_env_cfg.py index 87139e8fe849..c1a1677c1c19 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/multitask_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/multitask_env_cfg.py @@ -21,7 +21,7 @@ from isaaclab.markers import FRAME_MARKER_CFG, SPHERE_MARKER_CFG, VisualizationMarkersCfg from isaaclab.scene import InteractiveSceneCfg from isaaclab.scene import add as add_scene -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass from isaaclab.visualizers import VisualizerCfg from isaaclab_tasks.contrib.lift.config.openarm.joint_pos_env_cfg import OpenArmCubeLiftEnvCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/selection_utils.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/selection_utils.py index d9b7af533fd9..432de01e0d4c 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/selection_utils.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/selection_utils.py @@ -14,7 +14,7 @@ from isaaclab.cloner.path import match from isaaclab.managers.scene_entity_cfg import SceneEntityCfg -from isaaclab.utils import configclass +from isaaclab.utils.configclass import configclass if TYPE_CHECKING: from isaaclab.assets import BaseArticulation, BaseRigidObject, BaseRigidObjectCollection diff --git a/source/isaaclab_tasks/test/contrib/test_contrib_environments.py b/source/isaaclab_tasks/test/contrib/test_contrib_environments.py index a1dc901834ee..25437c653e3c 100644 --- a/source/isaaclab_tasks/test/contrib/test_contrib_environments.py +++ b/source/isaaclab_tasks/test/contrib/test_contrib_environments.py @@ -67,4 +67,5 @@ def _contrib_environment_params() -> list: @pytest.mark.parametrize("task_name", _contrib_environment_params()) def test_contrib_environments(task_name): - _run_environments(task_name, device="cuda", num_envs=2) + num_envs = 3 if task_name == "IsaacContrib-Multitask-Manipulation" else 2 + _run_environments(task_name, device="cuda", num_envs=num_envs) From 3a917ca19001d11b24ff224506401de6ab6e24f4 Mon Sep 17 00:00:00 2001 From: Kai Pei <2047767028@qq.com> Date: Fri, 4 Sep 2026 10:51:11 -0700 Subject: [PATCH 03/13] Makes DataGenConfig.max_num_failures actually bound a Mimic generation run (#7433) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description `DataGenConfig.max_num_failures` is documented as *"Maximum number of failures allowed before stopping generation"* and eighteen shipped Mimic environment configs set it to `25`. Nothing reads it. ```console $ git grep -c max_num_failures source/isaaclab/isaaclab/envs/mimic_env_cfg.py:1 # the definition source/isaaclab_mimic/isaaclab_mimic/envs/*.py:18 # eighteen assignments ``` No third entry: there is no read anywhere in the repository, and the field has been inert since Isaac Lab Mimic was introduced in #179. `env_loop` counts `num_failures` and then never consults it; its only termination is ```python check_val = num_success if generation_guarantee else num_attempts if check_val >= generation_num_trials: ``` With `generation_guarantee = True` (which those same eighteen configs also set), a task whose success rate is low retries without any bound, and the one knob that claims to stop that does nothing. On our own data generation this cost 337 attempts for 10 demos before we discovered the cap was not real. ### Reproduction, on a stock task ```bash ./isaaclab.sh -p scripts/imitation_learning/isaaclab_mimic/generate_dataset.py \ --task Isaac-Stack-Cube-Franka-IK-Rel-Mimic-v0 \ --input_file ./datasets/annotated_dataset.hdf5 \ --output_file /tmp/out.hdf5 \ --generation_num_trials 30 --num_envs 10 --headless ``` `FrankaCubeStackIKRelMimicEnvCfg` sets `max_num_failures = 25`. Observed: | | successes | failures | attempts | |---|---|---|---| | before | 30 | **50** | 80 | | after (`max_num_failures = 25`) | 12 | **25** | 37 | Before the change the run passed the configured cap and kept going, ending at twice it. After, it stops on the attempt that reaches 25 failures and says so: ``` Reached 25 failures (max_num_failures=25) after 12/30 successes. Exiting. ``` ### On the default Wiring the field up exactly as written would abort the primary documented workflow. At the ~36% success rate measured above, `--generation_num_trials 1000` needs on the order of 1800 failures, so a cap of 25 would end the run after roughly forty attempts. Since the eighteen assignments were inert when they were written and no current behaviour depends on them, they are removed here and the default becomes `None`, meaning no limit. **Runs behave exactly as they do today unless a limit is explicitly requested.** If maintainers would rather the shipped configs keep an active cap, that is a one-line change per config and I am happy to make it — it just needs a value that suits large runs. ### Tests `source/isaaclab_mimic/test/test_generation_failure_cap.py` drives `env_loop` with a fake environment whose `step()` consumes scripted attempt outcomes and refills the action queue, so the loop's own termination logic runs unmodified with no simulator or dataset behind it; a step-count fuse turns an unbounded loop into a failed assertion. Six cases: the cap stops a run that never succeeds; `None` keeps the guarantee unbounded as before; enough successes still end the run first; a cap reached short of the target ends it; attempt-based termination with the guarantee off is untouched (×2). Against the unpatched loop the two cap-dependent cases fail (`'fuse' == 'exited'`, and `(3, 4) == (2, 4)` — the loop overran the cap and collected an extra success) and the four asserting unchanged behaviour pass. ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Checklist - [x] I have read and understood the contribution guidelines - [ ] I have run the `pre-commit` checks with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] I have added a changelog fragment under `source//changelog.d/` for every touched package - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JFz91VMgmrjS4XR6f9Q9Z2 --------- Co-authored-by: Claude Opus 5 (cherry picked from commit 59df9b803b7eefdc83a9b030595c935d50f8dc5e) --- CONTRIBUTORS.md | 1 + .../changelog.d/mimic-max-num-failures.rst | 8 + .../isaaclab/isaaclab/envs/mimic_env_cfg.py | 17 +- .../changelog.d/mimic-max-num-failures.rst | 14 ++ .../isaaclab_mimic/datagen/generation.py | 12 ++ .../agibot_place_toy2box_mimic_env_cfg.py | 1 - .../agibot_place_upright_mug_mimic_env_cfg.py | 1 - .../envs/exhaustpipe_gr1t2_mimic_env_cfg.py | 1 - .../franka_bin_stack_ik_rel_mimic_env_cfg.py | 1 - .../envs/franka_stack_ik_abs_mimic_env_cfg.py | 1 - ...ka_stack_ik_rel_blueprint_mimic_env_cfg.py | 1 - .../envs/franka_stack_ik_rel_mimic_env_cfg.py | 1 - .../franka_stack_ik_rel_skillgen_env_cfg.py | 1 - ..._ik_rel_visuomotor_cosmos_mimic_env_cfg.py | 1 - ...a_stack_ik_rel_visuomotor_mimic_env_cfg.py | 1 - .../galbot_stack_rmp_abs_mimic_env_cfg.py | 2 - .../galbot_stack_rmp_rel_mimic_env_cfg.py | 2 - .../envs/locomanipulation_g1_mimic_env_cfg.py | 1 - .../envs/nutpour_gr1t2_mimic_env_cfg.py | 1 - .../envs/pickplace_gr1t2_mimic_env_cfg.py | 1 - ...place_gr1t2_waist_enabled_mimic_env_cfg.py | 1 - .../test/test_generation_failure_cap.py | 150 ++++++++++++++++++ 22 files changed, 200 insertions(+), 20 deletions(-) create mode 100644 source/isaaclab/changelog.d/mimic-max-num-failures.rst create mode 100644 source/isaaclab_mimic/changelog.d/mimic-max-num-failures.rst create mode 100644 source/isaaclab_mimic/test/test_generation_failure_cap.py diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 1811ca6fd5eb..17e5dcd01062 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -123,6 +123,7 @@ Guidelines for modifications: * Jiwen Cai * Johnson Sun * Juana Du +* Kai Pei * Kaixi Bao * Kourosh Darvish * Kousheek Chakraborty diff --git a/source/isaaclab/changelog.d/mimic-max-num-failures.rst b/source/isaaclab/changelog.d/mimic-max-num-failures.rst new file mode 100644 index 000000000000..9f811b3a0612 --- /dev/null +++ b/source/isaaclab/changelog.d/mimic-max-num-failures.rst @@ -0,0 +1,8 @@ +Fixed +^^^^^ + +* Fixed :attr:`~isaaclab.envs.mimic_env_cfg.DataGenConfig.max_num_failures` being ignored. The field + documented a cap on failed generation attempts but was never read, so a run with + :attr:`~isaaclab.envs.mimic_env_cfg.DataGenConfig.generation_guarantee` enabled retried without + bound on a task with a low success rate. Its default is now ``None`` (no limit) and setting it to + an integer stops generation once that many attempts have failed. diff --git a/source/isaaclab/isaaclab/envs/mimic_env_cfg.py b/source/isaaclab/isaaclab/envs/mimic_env_cfg.py index a98c34bf5f90..990c792b895c 100644 --- a/source/isaaclab/isaaclab/envs/mimic_env_cfg.py +++ b/source/isaaclab/isaaclab/envs/mimic_env_cfg.py @@ -38,8 +38,21 @@ class DataGenConfig: Keeping failed demonstrations is useful for visualizing and debugging low success rates. """ - max_num_failures: int = 50 - """Maximum number of failures allowed before stopping generation.""" + max_num_failures: int | None = None + """Maximum number of failed generation attempts before stopping, or None for no limit. + + Only applies together with :attr:`generation_guarantee`. With the guarantee enabled, generation + keeps retrying until :attr:`generation_num_trials` demos succeed, so a task whose success rate is + low can run for an unbounded number of attempts; this caps that. Defaults to None so the + guarantee keeps its usual meaning unless a limit is asked for. + + With the guarantee disabled, generation already stops after :attr:`generation_num_trials` + attempts and this field is ignored, so setting it cannot cut a fixed-attempt run short. + + The bound is read once per simulation step. Attempts that end on the step that crosses it are + already complete, so a run over ``num_envs`` parallel environments can record up to + ``num_envs - 1`` failures beyond the bound; it is exact whenever attempts end on separate steps. + """ seed: int = 1 """Seed for randomization to ensure reproducibility.""" diff --git a/source/isaaclab_mimic/changelog.d/mimic-max-num-failures.rst b/source/isaaclab_mimic/changelog.d/mimic-max-num-failures.rst new file mode 100644 index 000000000000..bffb2ae2ed24 --- /dev/null +++ b/source/isaaclab_mimic/changelog.d/mimic-max-num-failures.rst @@ -0,0 +1,14 @@ +Fixed +^^^^^ + +* Fixed the Mimic generation loop never bounding a run by failure count. ``env_loop`` now stops when + ``datagen_config.max_num_failures`` failed attempts have accumulated, alongside the existing stop + on enough successes or attempts. + +Removed +^^^^^^^ + +* Removed the ``datagen_config.max_num_failures = 25`` assignment from the shipped Mimic environment + configs. The field was never read when those lines were written, so honouring it now would newly + cap every shipped task at 25 failed attempts and cut short any run asking for a large number of + demos. Set the field explicitly to opt into a cap. diff --git a/source/isaaclab_mimic/isaaclab_mimic/datagen/generation.py b/source/isaaclab_mimic/isaaclab_mimic/datagen/generation.py index 5001726e50c3..687a74f0cc4f 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/datagen/generation.py +++ b/source/isaaclab_mimic/isaaclab_mimic/datagen/generation.py @@ -140,6 +140,18 @@ def env_loop( print(f"Reached {generation_num_trials} successes/attempts. Exiting.") break + # with the success guarantee on, nothing else bounds the run: a task that rarely + # succeeds retries forever. max_num_failures is the opt-in bound on that. Without the + # guarantee the check above already stops on generation_num_trials attempts, so the + # cap has nothing to add and must not cut a fixed-attempt run short. + max_num_failures = env.cfg.datagen_config.max_num_failures + if generation_guarantee and max_num_failures is not None and num_failures >= max_num_failures: + print( + f"Reached {num_failures} failures (max_num_failures={max_num_failures}) after" + f" {num_success}/{generation_num_trials} successes. Exiting." + ) + break + # check that simulation is stopped or not if env.sim.is_stopped(): break diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/agibot_place_toy2box_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/agibot_place_toy2box_mimic_env_cfg.py index 43085361f950..0df362689ec5 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/agibot_place_toy2box_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/agibot_place_toy2box_mimic_env_cfg.py @@ -32,7 +32,6 @@ def __post_init__(self): self.datagen_config.generation_select_src_per_subtask = True self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True - self.datagen_config.max_num_failures = 25 self.datagen_config.seed = 1 # The following are the subtask configurations for the stack task. diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/agibot_place_upright_mug_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/agibot_place_upright_mug_mimic_env_cfg.py index 31bf8fe81f3a..b645c4fcf2ab 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/agibot_place_upright_mug_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/agibot_place_upright_mug_mimic_env_cfg.py @@ -29,7 +29,6 @@ def __post_init__(self): self.datagen_config.generation_select_src_per_subtask = True self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True - self.datagen_config.max_num_failures = 25 self.datagen_config.seed = 1 # The following are the subtask configurations for the stack task. diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/exhaustpipe_gr1t2_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/exhaustpipe_gr1t2_mimic_env_cfg.py index 78df5903d761..4c7192a6307f 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/exhaustpipe_gr1t2_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/exhaustpipe_gr1t2_mimic_env_cfg.py @@ -30,7 +30,6 @@ def __post_init__(self): self.datagen_config.generation_joint_pos = False self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True - self.datagen_config.max_num_failures = 25 self.datagen_config.num_demo_to_render = 10 self.datagen_config.num_fail_demo_to_render = 25 self.datagen_config.seed = 10 diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_bin_stack_ik_rel_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_bin_stack_ik_rel_mimic_env_cfg.py index ef79f102f330..3e35749f9c33 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_bin_stack_ik_rel_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_bin_stack_ik_rel_mimic_env_cfg.py @@ -28,7 +28,6 @@ def __post_init__(self): self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True self.datagen_config.generation_relative = True - self.datagen_config.max_num_failures = 25 self.datagen_config.seed = 1 # The following are the subtask configurations for the stack task. diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_abs_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_abs_mimic_env_cfg.py index 974e0895b984..537541ffae01 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_abs_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_abs_mimic_env_cfg.py @@ -27,7 +27,6 @@ def __post_init__(self): self.datagen_config.generation_select_src_per_subtask = True self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True - self.datagen_config.max_num_failures = 25 self.datagen_config.seed = 1 # The following are the subtask configurations for the stack task. diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_blueprint_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_blueprint_mimic_env_cfg.py index cda843307acf..2773e8139013 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_blueprint_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_blueprint_mimic_env_cfg.py @@ -29,7 +29,6 @@ def __post_init__(self): self.datagen_config.generation_select_src_per_subtask = True self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True - self.datagen_config.max_num_failures = 25 self.datagen_config.seed = 1 # The following are the subtask configurations for the stack task. diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_mimic_env_cfg.py index a3ce6fc310a2..6f84e90bf97a 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_mimic_env_cfg.py @@ -31,7 +31,6 @@ def __post_init__(self): self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True self.datagen_config.generation_relative = True - self.datagen_config.max_num_failures = 25 self.datagen_config.seed = 1 # The following are the subtask configurations for the stack task. diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_skillgen_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_skillgen_env_cfg.py index cd7e3e472c6e..9b1cb235b6c6 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_skillgen_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_skillgen_env_cfg.py @@ -33,7 +33,6 @@ def __post_init__(self): self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True self.datagen_config.generation_relative = True - self.datagen_config.max_num_failures = 25 self.datagen_config.seed = 1 # The following are the subtask configurations for the stack task. diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_visuomotor_cosmos_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_visuomotor_cosmos_mimic_env_cfg.py index 83a727ee6022..f188a9ecffe9 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_visuomotor_cosmos_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_visuomotor_cosmos_mimic_env_cfg.py @@ -30,7 +30,6 @@ def __post_init__(self): self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True self.datagen_config.generation_relative = True - self.datagen_config.max_num_failures = 25 self.datagen_config.seed = 1 # The following are the subtask configurations for the stack task. diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_visuomotor_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_visuomotor_mimic_env_cfg.py index e6ca8a12cd02..050128cf9a93 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_visuomotor_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/franka_stack_ik_rel_visuomotor_mimic_env_cfg.py @@ -30,7 +30,6 @@ def __post_init__(self): self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True self.datagen_config.generation_relative = True - self.datagen_config.max_num_failures = 25 self.datagen_config.seed = 1 # The following are the subtask configurations for the stack task. diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_abs_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_abs_mimic_env_cfg.py index 1b3d544444cb..ef94781bc961 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_abs_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_abs_mimic_env_cfg.py @@ -31,7 +31,6 @@ def __post_init__(self): self.datagen_config.generation_select_src_per_subtask = True self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True - self.datagen_config.max_num_failures = 25 self.datagen_config.seed = 1 # The following are the subtask configurations for the stack task. @@ -157,7 +156,6 @@ def __post_init__(self): self.datagen_config.generation_select_src_per_subtask = True self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True - self.datagen_config.max_num_failures = 25 self.datagen_config.seed = 1 # The following are the subtask configurations for the stack task. diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_rel_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_rel_mimic_env_cfg.py index be87d9080743..8def41266475 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_rel_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/galbot_stack_rmp_rel_mimic_env_cfg.py @@ -31,7 +31,6 @@ def __post_init__(self): self.datagen_config.generation_select_src_per_subtask = True self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True - self.datagen_config.max_num_failures = 25 self.datagen_config.seed = 1 # The following are the subtask configurations for the stack task. @@ -155,7 +154,6 @@ def __post_init__(self): self.datagen_config.generation_select_src_per_subtask = True self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True - self.datagen_config.max_num_failures = 25 self.datagen_config.seed = 1 # The following are the subtask configurations for the stack task. diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/locomanipulation_g1_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/locomanipulation_g1_mimic_env_cfg.py index 5ddcf3d26c3f..71ad54c594ab 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/locomanipulation_g1_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/locomanipulation_g1_mimic_env_cfg.py @@ -30,7 +30,6 @@ def __post_init__(self): self.datagen_config.generation_joint_pos = False self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True - self.datagen_config.max_num_failures = 25 self.datagen_config.num_demo_to_render = 10 self.datagen_config.num_fail_demo_to_render = 25 self.datagen_config.seed = 1 diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/nutpour_gr1t2_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/nutpour_gr1t2_mimic_env_cfg.py index e145257e2fbe..dc12d03f49e5 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/nutpour_gr1t2_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/nutpour_gr1t2_mimic_env_cfg.py @@ -28,7 +28,6 @@ def __post_init__(self): self.datagen_config.generation_joint_pos = False self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True - self.datagen_config.max_num_failures = 25 self.datagen_config.num_demo_to_render = 10 self.datagen_config.num_fail_demo_to_render = 25 self.datagen_config.seed = 10 diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/pickplace_gr1t2_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/pickplace_gr1t2_mimic_env_cfg.py index 9a987a3b0f21..b220625bdb46 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/pickplace_gr1t2_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/pickplace_gr1t2_mimic_env_cfg.py @@ -28,7 +28,6 @@ def __post_init__(self): self.datagen_config.generation_joint_pos = False self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True - self.datagen_config.max_num_failures = 25 self.datagen_config.num_demo_to_render = 10 self.datagen_config.num_fail_demo_to_render = 25 self.datagen_config.seed = 1 diff --git a/source/isaaclab_mimic/isaaclab_mimic/envs/pickplace_gr1t2_waist_enabled_mimic_env_cfg.py b/source/isaaclab_mimic/isaaclab_mimic/envs/pickplace_gr1t2_waist_enabled_mimic_env_cfg.py index 6ed48a7590ca..bfae20234e67 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/envs/pickplace_gr1t2_waist_enabled_mimic_env_cfg.py +++ b/source/isaaclab_mimic/isaaclab_mimic/envs/pickplace_gr1t2_waist_enabled_mimic_env_cfg.py @@ -30,7 +30,6 @@ def __post_init__(self): self.datagen_config.generation_joint_pos = False self.datagen_config.generation_transform_first_robot_pose = False self.datagen_config.generation_interpolate_from_last_target_pose = True - self.datagen_config.max_num_failures = 25 self.datagen_config.num_demo_to_render = 10 self.datagen_config.num_fail_demo_to_render = 25 self.datagen_config.seed = 1 diff --git a/source/isaaclab_mimic/test/test_generation_failure_cap.py b/source/isaaclab_mimic/test/test_generation_failure_cap.py new file mode 100644 index 000000000000..c029c1addf8b --- /dev/null +++ b/source/isaaclab_mimic/test/test_generation_failure_cap.py @@ -0,0 +1,150 @@ +# Copyright (c) 2024-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the ``max_num_failures`` bound in the Mimic generation loop. + +``env_loop`` is driven with a fake environment whose ``step`` consumes one scripted attempt outcome +per call and feeds the action queue back, so the loop's own termination logic runs unmodified +without a simulator, data generator, or dataset. A step-count fuse turns an unbounded loop into a +test failure instead of a hang. +""" + +from isaaclab.app import AppLauncher + +# launch omniverse app +simulation_app = AppLauncher(headless=True).app + +import asyncio +from types import SimpleNamespace + +import pytest +import torch + +from isaaclab_mimic.datagen import generation + +FUSE_STEPS = 40 + + +class _Fuse(Exception): + """Raised by the fake environment once the loop has run for longer than any test expects.""" + + +def _run( + outcomes, + *, + max_num_failures, + generation_num_trials=3, + generation_guarantee=True, + num_envs=1, + attempts_per_step=1, +): + """Run ``env_loop`` over scripted attempt outcomes; return how it ended and the final counters. + + ``attempts_per_step`` is how many of the ``num_envs`` generators finish an attempt on the same + step, which is what decides whether the bound is read before or after the extra attempts land. + + Returns: + A tuple ``(how, num_success, num_failures, num_attempts)`` where ``how`` is ``"exited"`` if + the loop returned on its own and ``"fuse"`` if it was still running after ``FUSE_STEPS``. + """ + generation.num_success = generation.num_failures = generation.num_attempts = 0 + loop = asyncio.get_event_loop() + action_queue: asyncio.Queue = asyncio.Queue() + reset_queue: asyncio.Queue = asyncio.Queue() + for env_id in range(num_envs): + action_queue.put_nowait((env_id, torch.zeros(7))) + scripted = iter(outcomes) + steps = {"n": 0} + + def step(actions): + steps["n"] += 1 + if steps["n"] > FUSE_STEPS: + raise _Fuse(f"env_loop still running after {FUSE_STEPS} steps") + for _ in range(attempts_per_step): + if next(scripted): + generation.num_success += 1 + else: + generation.num_failures += 1 + generation.num_attempts += 1 + for env_id in range(num_envs): + action_queue.put_nowait((env_id, torch.zeros(7))) + + env = SimpleNamespace( + num_envs=num_envs, + device="cpu", + action_space=SimpleNamespace(shape=(num_envs, 7)), + step=step, + reset=lambda env_ids=None: None, + close=lambda: None, + sim=SimpleNamespace(is_stopped=lambda: False), + cfg=SimpleNamespace( + datagen_config=SimpleNamespace( + generation_guarantee=generation_guarantee, + generation_num_trials=generation_num_trials, + max_num_failures=max_num_failures, + ) + ), + ) + try: + generation.env_loop(env, reset_queue, action_queue, None, loop) + how = "exited" + except _Fuse: + how = "fuse" + return how, generation.num_success, generation.num_failures, generation.num_attempts + + +def test_failure_cap_stops_a_run_that_never_succeeds(): + how, succ, fail, attempts = _run([False] * 100, max_num_failures=5) + assert how == "exited" + assert (succ, fail, attempts) == (0, 5, 5) + + +def test_no_cap_by_default_keeps_the_success_guarantee(): + """``None`` means what the guarantee always meant: keep going until enough demos succeed.""" + how, _, fail, _ = _run([False] * 100, max_num_failures=None) + assert how == "fuse" + assert fail == FUSE_STEPS + + +def test_enough_successes_still_end_the_run_first(): + outcomes = [False, True, False, True, True] + [False] * 50 + how, succ, fail, _ = _run(outcomes, max_num_failures=100, generation_num_trials=3) + assert how == "exited" + assert (succ, fail) == (3, 2) + + +def test_cap_reached_before_enough_successes_ends_the_run(): + outcomes = [False, True, False, True, False, False] + [True] * 50 + how, succ, fail, _ = _run(outcomes, max_num_failures=4, generation_num_trials=3) + assert how == "exited" + assert (succ, fail) == (2, 4) + + +@pytest.mark.parametrize("max_num_failures", [None, 3, 100]) +def test_attempt_based_termination_is_unchanged(max_num_failures): + """With the guarantee off the run stops on ``generation_num_trials`` attempts, cap or no cap. + + A cap of 3 against 10 requested attempts is the case that matters: the fixed-attempt contract + says the run delivers the attempts it was asked for, so the cap must not end it at 3. + """ + how, _, _, attempts = _run( + [False] * 100, max_num_failures=max_num_failures, generation_num_trials=10, generation_guarantee=False + ) + assert how == "exited" + assert attempts == 10 + + +def test_bound_is_exact_when_attempts_end_on_separate_steps(): + """Four environments, one attempt landing per step: the bound is read between every attempt.""" + how, _, fail, _ = _run([False] * 100, max_num_failures=5, num_envs=4, attempts_per_step=1) + assert how == "exited" + assert fail == 5 + + +def test_attempts_ending_together_overshoot_the_bound_by_at_most_num_envs_minus_one(): + """Attempts that end on the step that crosses the bound are already complete and still count.""" + how, _, fail, _ = _run([False] * 100, max_num_failures=5, num_envs=4, attempts_per_step=4) + assert how == "exited" + assert 5 < fail <= 5 + 4 - 1 From 4171326df5c2b82d2c54e9d17c855fa9a8a895a8 Mon Sep 17 00:00:00 2001 From: ooctipus Date: Mon, 7 Sep 2026 10:53:00 -0700 Subject: [PATCH 04/13] Remove solver-specific velocity task tuning (#7607) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Remove backend-specific task and training changes from rough velocity configurations so physics selection no longer silently changes the comparison inputs. - Remove MJWarp-only actuator armature overrides from Anymal-C, Cassie, Go1, and Go2. - Use the same 5,000-iteration G1 PPO budget for every physics backend. - Replace the one-option base-COM PresetCfg with its owned EventTerm and update the robot-specific callers. - Remove tests whose only contract was the deleted G1 budget or Go2 armature special case. Keep the legacy Newton alias check tied to the selected solver instead of an actuator side effect. Downstream configurations that intentionally require different values can still set them explicitly. ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## Release backport - [ ] Backport this pull request to the active release branch after it merges into develop ## Testing - `uv run --extra test python -m pytest source/isaaclab_tasks/test/core/test_hydra.py source/isaaclab_tasks/test/core/test_velocity_newton_cfg.py` — 88 passed. - `uv run isaaclab -f` — passed. - No new tests were added. ## Checklist - [x] I have read and understood the contribution guidelines. - [x] I have run the pre-commit checks. - [x] Documentation changes are not required. - [x] My changes generate no new warnings. - [x] I removed tests that encoded the deleted backend-specific tuning. - [x] I added an isaaclab_tasks changelog fragment. - [x] My name already exists in CONTRIBUTORS.md. (cherry picked from commit b0c3941fe79afedad138d097d4f38ad999113f57) --- .../unify-velocity-solver-inputs.rst | 7 +++++++ .../velocity/config/a1/rough_env_cfg.py | 2 +- .../velocity/config/anymal_c/rough_env_cfg.py | 4 ---- .../velocity/config/digit/rough_env_cfg.py | 3 --- .../velocity/config/go1/rough_env_cfg.py | 4 +--- .../velocity/config/cassie/rough_env_cfg.py | 2 -- .../config/g1/agents/rsl_rl_ppo_cfg.py | 10 +--------- .../core/velocity/config/go2/rough_env_cfg.py | 2 -- .../core/velocity/velocity_env_cfg.py | 18 ++++++++---------- .../test/core/test_g1_agent_cfg.py | 14 -------------- source/isaaclab_tasks/test/core/test_hydra.py | 15 +++------------ 11 files changed, 21 insertions(+), 60 deletions(-) create mode 100644 source/isaaclab_tasks/changelog.d/unify-velocity-solver-inputs.rst delete mode 100644 source/isaaclab_tasks/test/core/test_g1_agent_cfg.py diff --git a/source/isaaclab_tasks/changelog.d/unify-velocity-solver-inputs.rst b/source/isaaclab_tasks/changelog.d/unify-velocity-solver-inputs.rst new file mode 100644 index 000000000000..c29d3af3d725 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/unify-velocity-solver-inputs.rst @@ -0,0 +1,7 @@ +Changed +^^^^^^^ + +* Unified rough-velocity task inputs across physics backends by removing MJWarp-only actuator armatures, + using 5,000 G1 training iterations for every backend, and representing shared base-COM randomization as a + plain event. Downstream configurations that require the former backend-specific behavior should set it + explicitly. diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/a1/rough_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/a1/rough_env_cfg.py index bad828ad1f40..6f54f541c9be 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/a1/rough_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/a1/rough_env_cfg.py @@ -41,4 +41,4 @@ def __post_init__(self): # events self.events.add_base_mass.params["asset_cfg"].body_names = "trunk" self.events.base_external_force_torque.params["asset_cfg"].body_names = "trunk" - self.events.base_com.default.params["asset_cfg"].body_names = "trunk" + self.events.base_com.params["asset_cfg"].body_names = "trunk" diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_c/rough_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_c/rough_env_cfg.py index 5badbdf012d0..4d3af315ffe7 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_c/rough_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/anymal_c/rough_env_cfg.py @@ -7,7 +7,6 @@ from isaaclab.utils import configclass from isaaclab_tasks.core.velocity.velocity_env_cfg import LocomotionVelocityRoughEnvCfg -from isaaclab_tasks.utils import preset ## # Pre-defined configs @@ -22,6 +21,3 @@ def __post_init__(self): # scene self.scene.robot = ANYMAL_C_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") - self.scene.robot.actuators["legs"].armature = preset( - default=0.0, newton_mjwarp=0.01, physx=0.0, isaacsim_physx=0.0 - ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/digit/rough_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/digit/rough_env_cfg.py index 80d7254169b1..5da30aab2947 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/digit/rough_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/digit/rough_env_cfg.py @@ -261,8 +261,5 @@ def __post_init__(self): # events self.events.add_base_mass.params["asset_cfg"].body_names = "torso_base" self.events.base_external_force_torque.params["asset_cfg"].body_names = "torso_base" - # Digit is PhysX-only, so the inherited ``newton_mjwarp`` branch names no - # reachable backend; collapse the preset so it cannot be selected on its own. - self.events.base_com = self.events.base_com.default self.events.base_com.params["asset_cfg"].body_names = "torso_base" self.events.reset_robot_joints.params["position_range"] = (1.0, 1.0) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/go1/rough_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/go1/rough_env_cfg.py index 90d64e85db7a..678c33ceaa62 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/go1/rough_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/go1/rough_env_cfg.py @@ -7,7 +7,6 @@ from isaaclab.utils import configclass from isaaclab_tasks.core.velocity.velocity_env_cfg import LocomotionVelocityRoughEnvCfg -from isaaclab_tasks.utils import preset ## # Pre-defined configs @@ -22,7 +21,6 @@ def __post_init__(self): # scene self.scene.robot = UNITREE_GO1_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") - self.scene.robot.actuators["base_legs"].armature = preset(default=0.0, newton_mjwarp=0.02) self.scene.height_scanner.prim_path = "{ENV_REGEX_NS}/Robot/trunk" # scale down the terrains because the robot is small self.scene.terrain.terrain_generator.sub_terrains["boxes"].grid_height_range = (0.025, 0.1) @@ -43,4 +41,4 @@ def __post_init__(self): # events self.events.add_base_mass.params["asset_cfg"].body_names = "trunk" self.events.base_external_force_torque.params["asset_cfg"].body_names = "trunk" - self.events.base_com.default.params["asset_cfg"].body_names = "trunk" + self.events.base_com.params["asset_cfg"].body_names = "trunk" diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/cassie/rough_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/cassie/rough_env_cfg.py index 16170f952f12..f65b6af462a1 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/cassie/rough_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/cassie/rough_env_cfg.py @@ -13,7 +13,6 @@ LocomotionVelocityRoughEnvCfg, RewardsCfg, ) -from isaaclab_tasks.utils import preset ## # Pre-defined configs @@ -60,7 +59,6 @@ def __post_init__(self): # scene self.scene.robot = CASSIE_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") - self.scene.robot.actuators["legs"].armature = preset(default=0.0, newton_mjwarp=0.02) self.scene.height_scanner.prim_path = "{ENV_REGEX_NS}/Robot/pelvis" # actions self.actions.joint_pos.scale = 0.5 diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/g1/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/g1/agents/rsl_rl_ppo_cfg.py index 197c80a59180..7b890ebdf66c 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/g1/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/g1/agents/rsl_rl_ppo_cfg.py @@ -7,19 +7,11 @@ from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg -from isaaclab_tasks.utils import preset - @configclass class G1RoughPPORunnerCfg(RslRlOnPolicyRunnerCfg): num_steps_per_env = 24 - # Newton needs ~1.7x the PPO iterations to match PhysX on G1. PhysX saturates near iter 3000 - # (reward ≈ +18, ep_len ≈ 980) and does not meaningfully improve on either metric past that — - # reward oscillates +16 to +19 through iter 7500, ep_len stays flat. Newton reaches the same - # (reward, ep_len) quality at iter 5000 (+16 / 984). Comparing reward alone is misleading: - # ep_len confirms the robot is stable in both cases. The gap is sample-efficiency, not a - # ceiling — no physics or reward tuning closes it. - max_iterations = preset(default=3000, newton_mjwarp=5000) + max_iterations = 5000 save_interval = 50 experiment_name = "g1_rough" obs_groups = {"actor": ["policy"], "critic": ["policy"]} diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/go2/rough_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/go2/rough_env_cfg.py index 925318d6187f..ed7e260714f8 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/go2/rough_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/go2/rough_env_cfg.py @@ -7,7 +7,6 @@ from isaaclab.utils import configclass from isaaclab_tasks.core.velocity.velocity_env_cfg import LocomotionVelocityRoughEnvCfg -from isaaclab_tasks.utils import preset ## # Pre-defined configs @@ -25,7 +24,6 @@ def __post_init__(self): self.sim.use_newton_actuators = True # scene self.scene.robot = UNITREE_GO2_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") - self.scene.robot.actuators["base_legs"].armature = preset(default=0.0, newton_mjwarp=0.02) self.scene.height_scanner.prim_path = "{ENV_REGEX_NS}/Robot/base" # scale down the terrains because the robot is small terrains = self.scene.terrain.terrain_generator.sub_terrains diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/velocity_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/velocity_env_cfg.py index 1d127d6c33e9..7aeae7f961fb 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/velocity_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/velocity_env_cfg.py @@ -36,7 +36,7 @@ from isaaclab.utils.noise import UniformNoiseCfg as Unoise import isaaclab_tasks.core.velocity.mdp as mdp -from isaaclab_tasks.utils import PresetCfg, preset +from isaaclab_tasks.utils import PresetCfg ## # Pre-defined configs @@ -223,15 +223,13 @@ class EventsCfg: }, ) - base_com = preset( - default=EventTerm( - func=mdp.randomize_rigid_body_com, - mode="startup", - params={ - "asset_cfg": SceneEntityCfg("robot", body_names="base"), - "com_range": {"x": (-0.05, 0.05), "y": (-0.05, 0.05), "z": (-0.01, 0.01)}, - }, - ), + base_com = EventTerm( + func=mdp.randomize_rigid_body_com, + mode="startup", + params={ + "asset_cfg": SceneEntityCfg("robot", body_names="base"), + "com_range": {"x": (-0.05, 0.05), "y": (-0.05, 0.05), "z": (-0.01, 0.01)}, + }, ) # reset diff --git a/source/isaaclab_tasks/test/core/test_g1_agent_cfg.py b/source/isaaclab_tasks/test/core/test_g1_agent_cfg.py deleted file mode 100644 index 461811f05bba..000000000000 --- a/source/isaaclab_tasks/test/core/test_g1_agent_cfg.py +++ /dev/null @@ -1,14 +0,0 @@ -# Copyright (c) 2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -from isaaclab_tasks.core.velocity.config.g1.agents.rsl_rl_ppo_cfg import G1RoughPPORunnerCfg -from isaaclab_tasks.utils import resolve_presets - - -def test_g1_rough_newton_preset_uses_extended_training_schedule(): - """Verify MJWarp selects the tuned G1 Newton training duration.""" - agent_cfg = resolve_presets(G1RoughPPORunnerCfg(), selected=("newton_mjwarp",)) - - assert agent_cfg.max_iterations == 5000 diff --git a/source/isaaclab_tasks/test/core/test_hydra.py b/source/isaaclab_tasks/test/core/test_hydra.py index 2a77316df346..10fd5e1807c5 100644 --- a/source/isaaclab_tasks/test/core/test_hydra.py +++ b/source/isaaclab_tasks/test/core/test_hydra.py @@ -860,24 +860,15 @@ class EnvCfgFactory: # ============================================================================= -def test_go2_rough_newton_mjwarp_armature_preset(): - """Go2 rough terrain uses higher MJWarp armature without changing PhysX.""" - from isaaclab_tasks.core.velocity.config.go2.rough_env_cfg import UnitreeGo2RoughEnvCfg - - env_cfg, _ = _apply(UnitreeGo2RoughEnvCfg(), global_presets=["newton_mjwarp"]) - assert env_cfg.scene.robot.actuators["base_legs"].armature == 0.02 - - env_cfg, _ = _apply(UnitreeGo2RoughEnvCfg()) - assert env_cfg.scene.robot.actuators["base_legs"].armature == 0.0 - - def test_go2_rough_legacy_newton_alias_resolves_to_newton_mjwarp(): """Real-config alias path: ``presets=newton`` against an actual env cfg resolves to newton_mjwarp.""" + from isaaclab_newton.physics import MJWarpSolverCfg + from isaaclab_tasks.core.velocity.config.go2.rough_env_cfg import UnitreeGo2RoughEnvCfg with pytest.warns(FutureWarning, match="Preset 'newton' is deprecated"): env_cfg, _ = _apply(UnitreeGo2RoughEnvCfg(), global_presets=["newton"]) - assert env_cfg.scene.robot.actuators["base_legs"].armature == 0.02 + assert isinstance(env_cfg.sim.physics.solver_cfg, MJWarpSolverCfg) def test_velocity_events_newton_mjwarp_keeps_base_com_randomization(): From b337b96c946479e6f4d6be176925a05c6110b4c3 Mon Sep 17 00:00:00 2001 From: Mustafa H <34825877+StafaH@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:53:03 -0400 Subject: [PATCH 05/13] [Tests] Skip DrLegs-Walk in the contrib environment test (#7623) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description `test-contrib-environments` failed on #7610 (run [34042243157](https://github.com/isaac-sim/IsaacLab/actions/runs/34042243157/job/101511058208)) in `test_contrib_environments[IsaacContrib-DrLegs-Walk]` with `AssertionError: Invalid data` on the policy observation. The PR itself only touched `source/isaaclab/test/install_ci`, so this is unrelated to that change. **Root cause.** The first row of the failing observation is NaN across projected gravity, base angular velocity and joint positions, i.e. the robot state itself is non-finite, not a single observation term. Reproducing the test scenario locally (2 envs, 20 random-action steps, fresh env per seed) shows the Kamino P-ADMM solver diverging mid-episode: in 1 of 12 seeds the entire state of env 0 (root position, root quaternion, joint positions and velocities) turns NaN at step 6. The heading-driven velocity command then inherits the NaN through `heading_w`, and the `root_height` / `bad_orientation` termination terms cannot fire because every comparison against NaN is false, so the episode is never reset. The sibling `IsaacContrib-DrLegs-HoldPose` task (same robot, same solver preset, same random actions) stayed finite in 9 of 9 local trials and passed in the same CI job, as did the deprecated alias id of the Walk task, which is why the failure is intermittent rather than deterministic. **Change.** Add `DrLegs-Walk` to `_SKIPPED_TASK_SUBSTRINGS` in `test_contrib_environments.py` with the reason documented inline. The substring covers both `IsaacContrib-DrLegs-Walk` and its deprecated alias `Isaac-DrLegs-Walk-v0`, which the test also enumerates. HoldPose keeps running. This is a test-side skip; the solver instability itself needs to be addressed in Kamino/Newton (or by retuning the DR Legs solver preset), and the inline comment says to re-enable once that lands. ## Validation - Reproduction script (launches once, rebuilds the env per seed, mirrors `env_test_utils._run_environments`): Walk 1/12 seeds NaN at step 6 with root pose, joint state and the velocity command all non-finite; HoldPose 0/9. - `pytest source/isaaclab_tasks/test/contrib/test_contrib_environments.py -k DrLegs-Walk -rs`: both ids report `SKIPPED ... Kamino solver intermittently produces NaN robot state under random actions.`; HoldPose ids still collect as runnable. - Ruff and ruff-format pass; `isaaclab_tasks` changelog `.skip` fragment included. ## Type of change - Test fix (non-breaking) ## Checklist - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] I have added a changelog fragment under `source//changelog.d/` for every touched package (do **not** edit `CHANGELOG.rst` or bump `extension.toml` — CI handles that) - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there (cherry picked from commit 63d579e93388bf6c6f59c6866c36c0e2c12c0b0d) --- .../changelog.d/fix-skip-drlegs-walk-contrib-test.skip | 3 +++ .../isaaclab_tasks/test/contrib/test_contrib_environments.py | 5 +++++ 2 files changed, 8 insertions(+) create mode 100644 source/isaaclab_tasks/changelog.d/fix-skip-drlegs-walk-contrib-test.skip diff --git a/source/isaaclab_tasks/changelog.d/fix-skip-drlegs-walk-contrib-test.skip b/source/isaaclab_tasks/changelog.d/fix-skip-drlegs-walk-contrib-test.skip new file mode 100644 index 000000000000..e29cd024514a --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/fix-skip-drlegs-walk-contrib-test.skip @@ -0,0 +1,3 @@ +Test-only change: skip ``IsaacContrib-DrLegs-Walk`` (and its deprecated alias) in the contributed +environment smoke test because the Kamino solver intermittently diverges to NaN under random actions. +No user-visible behavior change. diff --git a/source/isaaclab_tasks/test/contrib/test_contrib_environments.py b/source/isaaclab_tasks/test/contrib/test_contrib_environments.py index 25437c653e3c..3d2b961e244d 100644 --- a/source/isaaclab_tasks/test/contrib/test_contrib_environments.py +++ b/source/isaaclab_tasks/test/contrib/test_contrib_environments.py @@ -32,6 +32,11 @@ "IsaacContrib-AutoMate-Disassembly-Direct": "Requires CUDA support outside the standard environment test runner.", } _SKIPPED_TASK_SUBSTRINGS = { + # Under random actions the Kamino P-ADMM solver intermittently diverges and the whole robot state + # (root pose, joint state) turns NaN mid-episode, so the run fails nondeterministically (about 1 in 12 + # seeds locally; the sibling HoldPose task stays finite). The termination terms cannot catch a NaN state. + # Re-enable once the solver instability is resolved upstream. + "DrLegs-Walk": "Kamino solver intermittently produces NaN robot state under random actions.", "RmpFlow": "Uses SingleArticulation, which requires an update.", "Skillgen": "Requires cuRobo-specific coverage.", "Suction": "Requires CPU simulation.", From 36ba5493ebb91330e86a51cf1d31504f1058e9b6 Mon Sep 17 00:00:00 2001 From: Maximilian Krause <99733341+maxkra15@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:54:43 +0200 Subject: [PATCH 06/13] Use default ground planes in MPM demos (#7639) # Description Use the default `GroundPlaneCfg` appearance in the granular, two-way coupling, and snowball MPM demos. This removes per-demo size and color overrides and keeps the examples aligned with the standard Isaac Lab ground plane. No new dependencies. ## Type of change - Documentation/example cleanup (non-breaking) ## Release backport - [ ] Backport this pull request to the active release branch after it merges into `develop` ## Screenshots Not applicable; this intentionally adopts the standard ground-plane appearance. ## Checklist - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) on all changed files - [x] Documentation changes are not required - [x] My changes generate no new warnings - [x] Tests are not required for configuration-only demo cleanup - [x] Changelog fragments are not required because no source package is changed - [x] My name already exists in `CONTRIBUTORS.md` (cherry picked from commit bb3f366193a68de879e85a3a3a2e5e02fb233cf4) --- scripts/demos/mpm/newton_mpm_granular.py | 2 +- scripts/demos/mpm/newton_mpm_twoway_coupling.py | 2 +- scripts/demos/mpm/snowball_smash.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/demos/mpm/newton_mpm_granular.py b/scripts/demos/mpm/newton_mpm_granular.py index 965cbe704e44..a7c009b5e516 100644 --- a/scripts/demos/mpm/newton_mpm_granular.py +++ b/scripts/demos/mpm/newton_mpm_granular.py @@ -146,7 +146,7 @@ class GranularSceneCfg(InteractiveSceneCfg): ground = AssetBaseCfg( prim_path="/World/Ground", - spawn=sim_utils.GroundPlaneCfg(size=(12.0, 12.0), color=(0.30, 0.30, 0.30)), + spawn=sim_utils.GroundPlaneCfg(), ) dome_light = AssetBaseCfg( diff --git a/scripts/demos/mpm/newton_mpm_twoway_coupling.py b/scripts/demos/mpm/newton_mpm_twoway_coupling.py index 04e44b753c99..2c60956c0399 100644 --- a/scripts/demos/mpm/newton_mpm_twoway_coupling.py +++ b/scripts/demos/mpm/newton_mpm_twoway_coupling.py @@ -230,7 +230,7 @@ class CoupledSceneCfg(InteractiveSceneCfg): ground = AssetBaseCfg( prim_path="/World/Ground", - spawn=sim_utils.GroundPlaneCfg(size=(12.0, 12.0), color=(0.30, 0.30, 0.30)), + spawn=sim_utils.GroundPlaneCfg(), init_state=AssetBaseCfg.InitialStateCfg(pos=(0.0, 0.0, -wall_t)), ) dome_light = AssetBaseCfg( diff --git a/scripts/demos/mpm/snowball_smash.py b/scripts/demos/mpm/snowball_smash.py index 137398441cd0..0be3833d4aed 100644 --- a/scripts/demos/mpm/snowball_smash.py +++ b/scripts/demos/mpm/snowball_smash.py @@ -279,7 +279,7 @@ class SnowballSmashSceneCfg(InteractiveSceneCfg): # the MPM model view. ground = AssetBaseCfg( prim_path="/World/Ground", - spawn=sim_utils.GroundPlaneCfg(size=(12.0, 12.0), color=(0.32, 0.34, 0.38)), + spawn=sim_utils.GroundPlaneCfg(), ) # The co-located hidden kinematic slab belongs only to the MPM entry. From f95bdc576df0ce17efa7b91d8c36100a91237bad Mon Sep 17 00:00:00 2001 From: Yuchen Deng Date: Fri, 11 Sep 2026 12:53:27 -0700 Subject: [PATCH 07/13] Fixed locomanipulation NuRec SDG generation (#7756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Fixed locomanipulation NuRec SDG generation Hugging Face NuRec assets were updated to v0.2. This PR updates locomanip_sdg to use the v0.2 assets and fixes several issues in the NuRec SDG workflow: - Fixed navigation pose projection and recorder initial-state capture after placement. - Synced randomized static fixture poses so downstream success checks use the placed table pose - Applied Isaac RTX Gaussian camera settings for NuRec camera capture. - Automatically enabled placement randomization when a NuRec scene is used. ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Release backport - [ ] Backport this pull request to the active release branch after it merges into `develop` ## Screenshots image ## Checklist Docker and GPU tests run on demand. Push the commits you want tested, then comment `run-ci` on the pull request. - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] I have added a changelog fragment under `source//changelog.d/` for every touched package (do **not** edit `CHANGELOG.rst` or bump `extension.toml` — CI handles that) - [ ] I have added my name to the `CONTRIBUTORS.md` or my name already exists there (cherry picked from commit b9618b614040a2c94eaf52cc8e5151de8c50e78e) --- .../humanoids_imitation.rst | 68 +++++++------------ .../locomanipulation_sdg/generate_data.py | 57 +++++++++++----- .../changelog.d/locomanip-nurec-sdg.rst | 7 ++ .../envs/g1_locomanipulation_sdg_env.py | 27 ++++++-- .../locomanipulation_sdg/scene_utils.py | 27 ++++++-- 5 files changed, 112 insertions(+), 74 deletions(-) create mode 100644 source/isaaclab_mimic/changelog.d/locomanip-nurec-sdg.rst diff --git a/docs/source/features/imitation-learning/humanoids_imitation.rst b/docs/source/features/imitation-learning/humanoids_imitation.rst index 11c6d77efb32..e0f03adec0a8 100644 --- a/docs/source/features/imitation-learning/humanoids_imitation.rst +++ b/docs/source/features/imitation-learning/humanoids_imitation.rst @@ -774,12 +774,11 @@ Integrating 3D Gaussian Splatting into SDG This section extends :ref:`locomanipulation SDG ` -by replacing the synthetic background with a 3D Gaussian Splatting (NuRec) scene. As in the -base pipeline, the workflow takes a manipulation dataset as input and produces a combined -navigation and manipulation dataset as an HDF5 file — but here the robot navigates and -manipulates objects inside a neurally-rendered environment, and an ego-centric camera -captures the result, producing more realistic training data than a purely synthetic scene. -NVIDIA Isaac Sim renders 3DGS models stored as USD assets; see +by replacing the synthetic background with a 3D Gaussian Splatting (NuRec) scene. The workflow +takes a manipulation dataset as input and produces a combined navigation and manipulation +dataset as an HDF5 file. The navigation replay happens in a neurally-reconstructed environment, +and an ego-centric camera captures the result, producing more realistic training data. +For more info on NuRec rendering in Isaac Sim, see `Neural Volume Rendering `__ for details. @@ -805,21 +804,17 @@ Log in to Hugging Face: hf auth login --token -Download the required USDZ stage files and occupancy maps: +Download the required USDZ stage file and occupancy maps: .. code:: bash hf download nvidia/PhysicalAI-Robotics-NuRec \ - hand_hold-voyager-babyboom/stage_volume.usdz \ - hand_hold-voyager-babyboom/stage_particle.usdz \ + hand_hold-voyager-babyboom/particle_sh_optimized.usdz \ hand_hold-voyager-babyboom/occupancy_map.png \ hand_hold-voyager-babyboom/occupancy_map.yaml \ --repo-type dataset \ --local-dir -The sample includes both a volume-based USD (``stage_volume.usdz``) and a particle-field USD -(``stage_particle.usdz``). Either can be used as the background asset. - Asset requirements """""""""""""""""" @@ -831,11 +826,6 @@ compatible with the SDG pipeline: object placement. - An occupancy map is required for path planning. - - If your scene was reconstructed using the `Stereo Workflow `__, - the occupancy map is generated via ``nvblox``. - - If your background includes a mesh, use the `Occupancy Map Generator `__ - to create a map via physical simulation. - Generating the dataset """""""""""""""""""""" @@ -843,13 +833,7 @@ Before proceeding, ensure you have generated a manipulation dataset or downloade dataset provided in the :ref:`Generate the manipulation dataset ` section. -Once you have gathered: - -- A manipulation dataset -- A background USD asset -- A matched occupancy map - -you can run the generation command. At runtime, the script adds a ground plane at ``z=0`` to +At runtime, the script adds a ground plane at ``z=0`` to the scene. It then proceeds through four stages: 1. **Pick**: The robot picks up an object at the start location by replaying the manipulation @@ -873,18 +857,20 @@ Run the generation command: --num_runs 1 \ --lift_step 60 \ --navigate_step 130 \ - --output_file /generated_dataset_g1_locomanipulation_sdg_gaussian_background.hdf5 \ - --visualizer kit \ - --background_usd_path /stage_particle.usdz \ - --background_occupancy_yaml_file /occupancy_map.yaml \ - --randomize_placement \ + --output_file /generated_dataset_g1_locomanipulation_sdg_nurec.hdf5 \ + --background_usd_path /hand_hold-voyager-babyboom/particle_sh_optimized.usdz \ + --background_occupancy_yaml_file /hand_hold-voyager-babyboom/occupancy_map.yaml \ --high_res_video +To inspect the run in the GUI, add ``--viz kit``; adjust the perspective camera, or switch +the viewport to the robot camera view for clearer visualization. + The key parameters are: -- ``--background_usd_path``: Path to the 3D Gaussian background USD asset. -- ``--background_occupancy_yaml_file``: Path to the occupancy map file. -- ``--high_res_video``: Capture the ego-centric camera at 960×540 instead of the default +- ``--background_usd_path``: Path to the 3D Gaussian NuRec USD asset. +- ``--background_occupancy_yaml_file``: Path to the NuRec occupancy map file. +- Placement randomization is enabled automatically when a NuRec scene is used. +- ``--high_res_video``: Capture the ego-centric camera at 512×320 instead of the default 256×160. When the run completes successfully, an HDF5 dataset is generated containing camera @@ -892,23 +878,15 @@ observations. You can convert the ego-centric camera view to MP4: .. code:: bash - uv run python scripts/tools/hdf5_to_mp4.py \ - --input_file /generated_dataset_g1_locomanipulation_sdg_gaussian_background.hdf5 \ + uv run --extra mimic python scripts/tools/hdf5_to_mp4.py \ + --input_file /generated_dataset_g1_locomanipulation_sdg_nurec.hdf5 \ --output_dir / \ --input_keys robot_pov_cam \ - --video_width 960 \ - --video_height 540 + --video_width 512 \ + --video_height 320 Set ``--video_width`` and ``--video_height`` to match the resolution captured during -generation: 960×540 with ``--high_res_video``, or 256×160 without it. - -To play the generated MP4 video on Ubuntu, install the following multimedia packages: - -.. code:: bash - - sudo apt update - sudo apt install libavcodec-extra gstreamer1.0-libav gstreamer1.0-plugins-ugly - +generation: 512×320 with ``--high_res_video``, or 256×160 without it. .. figure:: https://download.isaacsim.omniverse.nvidia.com/isaaclab/images/locomanipulation_sdg_gaussian_background_2x.webp :width: 100% diff --git a/scripts/imitation_learning/locomanipulation_sdg/generate_data.py b/scripts/imitation_learning/locomanipulation_sdg/generate_data.py index f71916309190..399f89be47c3 100644 --- a/scripts/imitation_learning/locomanipulation_sdg/generate_data.py +++ b/scripts/imitation_learning/locomanipulation_sdg/generate_data.py @@ -95,13 +95,13 @@ "--background_usd_path", type=str, default=None, - help="Path to the USD file for the background asset", + help="Path to the NuRec USD file.", ) parser.add_argument( "--background_occupancy_yaml_file", type=str, default=None, - help="Path to the occupancy map YAML file for the background asset", + help="Path to the NuRec occupancy map YAML file.", ) parser.add_argument( "--high_res_video", @@ -126,7 +126,7 @@ # forward unrecognized args as Hydra-style task config overrides args_cli, hydra_overrides = parser.parse_known_args() -app_launcher = AppLauncher(args_cli) +app_launcher = AppLauncher(args_cli, enable_cameras=True) simulation_app = app_launcher.app import enum @@ -358,7 +358,9 @@ def project_robot_state_into_env(env: LocomanipulationSDGEnv, input_episode_data ) default_vel = env.scene["robot"].data.default_root_vel.torch.clone() default_vel[0] = torch.zeros(6, device=env.device) - env.scene["robot"].data.default_root_vel.warp.assign(wp.from_torch(default_vel.to(env.device).contiguous())) + env.scene["robot"].data.default_root_vel.warp.assign( + wp.from_torch(default_vel.to(env.device).contiguous()).view(wp.spatial_vectorf) + ) robot_state = recording_initial_state["articulation"]["robot"] joint_position = robot_state["joint_position"][0].to(env.device) @@ -409,7 +411,9 @@ def project_object_state_into_env(env: LocomanipulationSDGEnv, input_episode_dat ) default_vel = env.scene["object"].data.default_root_vel.torch.clone() default_vel[0] = torch.zeros(6, device=env.device) - env.scene["object"].data.default_root_vel.warp.assign(wp.from_torch(default_vel.to(env.device).contiguous())) + env.scene["object"].data.default_root_vel.warp.assign( + wp.from_torch(default_vel.to(env.device).contiguous()).view(wp.spatial_vectorf) + ) return new_object_pose @@ -438,8 +442,7 @@ def setup_navigation_scene( if background_fixture is not None: occupancy_map = background_fixture.get_occupancy_map() fixtures = [env.get_start_fixture(), env.get_end_fixture()] + env.get_obstacle_fixtures() - if not randomize_placement: - raise ValueError("randomize_placement needs to be True when background_usd_path is provided") + randomize_placement = True else: occupancy_map = merge_occupancy_maps( [ @@ -484,17 +487,20 @@ def setup_navigation_scene( env.obs_buf = env.observation_manager.compute(update_history=True) nav_map = occupancy_map.buffered_meters(0.15) - nav_fs = nav_map.freespace_mask() - start_pos = env.get_base().get_pose()[0, :2].detach().cpu().numpy() - start_px = nav_map.world_to_pixel_numpy(start_pos[None])[0].astype(int) - sx, sy = int(start_px[0]), int(start_px[1]) - - # Clear the buffer zone around the robot's start pixel if it falls inside it. - # The robot stands adjacent to the start table so its position sits within the 0.15m buffer. - if 0 <= sy < nav_fs.shape[0] and 0 <= sx < nav_fs.shape[1] and not nav_fs[sy, sx]: + + def clear_nav_map_buffer(world_xy: torch.Tensor | np.ndarray): + nav_fs = nav_map.freespace_mask() + pixel_xy = nav_map.world_to_pixel_numpy(np.asarray(world_xy)[None])[0].astype(int) + x_px, y_px = int(pixel_xy[0]), int(pixel_xy[1]) + if not (0 <= y_px < nav_fs.shape[0] and 0 <= x_px < nav_fs.shape[1]) or nav_fs[y_px, x_px]: + return clear_r = int(0.15 / nav_map.resolution) yy, xx = np.ogrid[: nav_map.data.shape[0], : nav_map.data.shape[1]] - nav_map.data[(xx - sx) ** 2 + (yy - sy) ** 2 <= clear_r**2] = OccupancyMapDataValue.FREESPACE + nav_map.data[(xx - x_px) ** 2 + (yy - y_px) ** 2 <= clear_r**2] = OccupancyMapDataValue.FREESPACE + + # The robot start and approach goal can sit adjacent to their tables, inside the 0.15 m buffer. + clear_nav_map_buffer(env.get_base().get_pose()[0, :2].detach().cpu().numpy()) + clear_nav_map_buffer(base_goal_approach.get_pose()[0, :2].detach().cpu().numpy()) base_path = plan_path(start=env.get_base(), end=base_goal_approach, occupancy_map=nav_map) @@ -899,6 +905,11 @@ def replay( print("Failed to setup navigation scene", flush=True) return False + # Background placement can move fixtures and re-project robot/object root poses + # after reset_to records initial_state. Re-record from the projected scene. + env.recorder_manager.reset(env_ids=[0]) + env.recorder_manager.record_post_reset(env_ids=[0]) + if sensor_camera_view: _set_sensor_camera_view() @@ -1001,7 +1012,17 @@ def replay( env_cfg.recorders.dataset_export_mode = DatasetExportMode.EXPORT_SUCCEEDED_ONLY env_cfg.recorders.export_in_record_pre_reset = True - if args_cli.background_usd_path is not None and args_cli.background_occupancy_yaml_file is not None: + nurec_scene_used = ( + args_cli.background_usd_path is not None or args_cli.background_occupancy_yaml_file is not None + ) + if nurec_scene_used and ( + args_cli.background_usd_path is None or args_cli.background_occupancy_yaml_file is None + ): + raise ValueError( + "Both --background_usd_path and --background_occupancy_yaml_file must be provided for NuRec scenes." + ) + + if nurec_scene_used: env_cfg.background_usd_path = args_cli.background_usd_path env_cfg.background_occupancy_yaml_file = args_cli.background_occupancy_yaml_file @@ -1048,7 +1069,7 @@ def replay( following_offset=args_cli.following_offset, angle_threshold=args_cli.angle_threshold, approach_distance=args_cli.approach_distance, - randomize_placement=args_cli.randomize_placement, + randomize_placement=args_cli.randomize_placement or nurec_scene_used, sensor_camera_view=args_cli.sensor_camera_view, ) diff --git a/source/isaaclab_mimic/changelog.d/locomanip-nurec-sdg.rst b/source/isaaclab_mimic/changelog.d/locomanip-nurec-sdg.rst new file mode 100644 index 000000000000..5c13727e221a --- /dev/null +++ b/source/isaaclab_mimic/changelog.d/locomanip-nurec-sdg.rst @@ -0,0 +1,7 @@ +Fixed +^^^^^ + +* Fixed locomanipulation SDG generation with NuRec backgrounds by enabling camera capture, applying Isaac RTX + Gaussian renderer settings, syncing randomized fixture poses, and recording the projected scene state after + placement. ``--high_res_video`` now records RGB observations at 512x320 instead of 960x540; update MP4 + conversion dimensions and model input shapes accordingly. diff --git a/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/envs/g1_locomanipulation_sdg_env.py b/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/envs/g1_locomanipulation_sdg_env.py index 72be2a63c075..a981caefb9a7 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/envs/g1_locomanipulation_sdg_env.py +++ b/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/envs/g1_locomanipulation_sdg_env.py @@ -5,6 +5,7 @@ import numpy as np import torch +from isaaclab_physx.renderers import IsaacRtxRendererCfg, IsaacRtxRendererGlobalSettingsCfg import isaaclab.sim as sim_utils from isaaclab.assets import AssetBaseCfg @@ -35,6 +36,11 @@ NUM_FORKLIFTS = 0 NUM_BOXES = 0 +ISAAC_RTX_GAUSSIAN_CAMERA_RENDERER_CARB_SETTINGS = { + "/rtx/rtpt/gaussian/accumulatedDepth/allHits/enabled": True, + "/rtx/rtpt/gaussian/accumulatedAlbedo/enabled": True, + "/rtx/rtpt/gaussian/maxGaussiansToAccumulate": 360, +} @configclass @@ -52,7 +58,17 @@ class G1LocomanipulationSDGSceneCfg(LocomanipulationG1SceneCfg): ), ) - def add_robot_pov_cam(self, height, width): + def add_robot_pov_cam(self, height, width, use_nurec_renderer_settings: bool = False): + camera_kwargs = {} + if use_nurec_renderer_settings: + camera_kwargs["renderer_cfg"] = IsaacRtxRendererCfg( + global_settings=IsaacRtxRendererGlobalSettingsCfg( + enable_dl_denoiser=True, + antialiasing_mode="DLSS", + carb_settings=ISAAC_RTX_GAUSSIAN_CAMERA_RENDERER_CARB_SETTINGS, + ) + ) + robot_pov_cam = CameraCfg( prim_path="{ENV_REGEX_NS}/Robot/torso_link/d435_link/camera", update_period=0.0, @@ -61,6 +77,7 @@ def add_robot_pov_cam(self, height, width): data_types=["rgb"], spawn=sim_utils.PinholeCameraCfg(focal_length=8.0, clipping_range=(0.1, 20.0)), offset=CameraCfg.OffsetCfg(pos=(0.0, 0.0, 0.0), rot=(0.0, -0.1736482, 0.0, 0.9848078), convention="world"), + **camera_kwargs, ) setattr(self, "robot_pov_cam", robot_pov_cam) @@ -176,10 +193,10 @@ def __init__(self, cfg: G1LocomanipulationSDGEnvCfg, **kwargs): self._num_boxes = NUM_BOXES set_ground_invisible = False - if cfg.high_res_video: - cfg.scene.add_robot_pov_cam(540, 960) - else: - cfg.scene.add_robot_pov_cam(160, 256) + camera_height, camera_width = (320, 512) if cfg.high_res_video else (160, 256) + cfg.scene.add_robot_pov_cam( + camera_height, camera_width, use_nurec_renderer_settings=cfg.background_usd_path is not None + ) cfg.scene.add_forklifts(self._num_forklifts) cfg.scene.add_boxes(self._num_boxes) diff --git a/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/scene_utils.py b/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/scene_utils.py index 0b2506f48b3b..d6a4780f2513 100644 --- a/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/scene_utils.py +++ b/source/isaaclab_mimic/isaaclab_mimic/locomanipulation_sdg/scene_utils.py @@ -11,7 +11,7 @@ import isaaclab.utils.math as math_utils from isaaclab.assets import AssetBaseCfg -from isaaclab.sim.views import FrameView +from isaaclab.sim.views import UsdFrameView from .occupancy_map_utils import OccupancyMap, intersect_occupancy_maps from .transform_utils import transform_mul @@ -101,10 +101,11 @@ class SceneAsset(HasPose): def __init__(self, scene, entity_name: str): self.scene = scene self.entity_name = entity_name - self._xform_view: FrameView | None = None + self._xform_view: UsdFrameView | None = None + self._cached_pose: torch.Tensor | None = None - def _get_xform_view(self) -> FrameView: - """Return the FrameView for this asset, building it on demand. + def _get_xform_view(self) -> UsdFrameView: + """Return the USD transform view for this asset, building it on demand. Static scene assets carry no runtime view (``scene[name]`` returns the spawned configuration), so the pose view is created lazily here and cached on this @@ -114,23 +115,27 @@ def _get_xform_view(self) -> FrameView: if self._xform_view is None or self._xform_view.count == 0: if self._xform_view is not None: self._xform_view.close() + self._cached_pose = None entity = self.scene[self.entity_name] prim_path = ( entity.prim_path if isinstance(entity, AssetBaseCfg) else getattr(entity, "_usd_view", entity)._prim_path ) - self._xform_view = FrameView(prim_path, device=self.scene.device) + self._xform_view = UsdFrameView(prim_path, device=self.scene.device) return self._xform_view def get_pose(self): """Get the 3D pose of the entity.""" + if self._cached_pose is not None: + return self._cached_pose.clone() xform_prim = self._get_xform_view() pos_w, quat_w = xform_prim.get_world_poses() position = pos_w.torch orientation = quat_w.torch pose = torch.cat([position, orientation], dim=-1) - return pose + self._cached_pose = pose.detach().clone() + return self._cached_pose.clone() def set_pose(self, pose: torch.Tensor): """Set the 3D pose of the entity.""" @@ -139,6 +144,16 @@ def set_pose(self, pose: torch.Tensor): orientation = pose[..., 3:] with xform_prim.xform_world_space_writer() as writer: writer.set_poses(wp.from_torch(position.contiguous()), wp.from_torch(orientation.contiguous()), None) + self._cached_pose = pose.detach().clone() + entity = self.scene[self.entity_name] + if isinstance(entity, AssetBaseCfg): + # Static assets have no runtime data object, so keep their config pose aligned. + pose_cfg = pose[0] if pose.ndim > 1 else pose + env_origin = self.scene.env_origins[0].to(pose_cfg.device) + pos = (pose_cfg[:3] - env_origin).detach().cpu().tolist() + rot = pose_cfg[3:].detach().cpu().tolist() + entity.init_state.pos = tuple(float(value) for value in pos) + entity.init_state.rot = tuple(float(value) for value in rot) class RelativePose(HasPose): From 4f70bc9c117969379345b8fb7d33296f376359f3 Mon Sep 17 00:00:00 2001 From: Daniela Hase <116915287+daniela-hase@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:30:28 -0700 Subject: [PATCH 08/13] Added Rendering Benchmark (#7702) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Adds a rendering benchmark for comparing OVRTX and Newton/Warp renderer performance on an animated Franka + Sektion-cabinet scene, plus the plumbing needed to measure it. - `scripts/benchmarks/benchmark_renderer.py`: sweeps a declarative list of renderer profiles (OVRTX old/new pipeline × shading presets, Newton BVH constructor combinations) under `nsys`, parses per-frame render times from the exported NVTX ranges, and reports pixels/second plus median/mean/min/max/stdev either as a table or as JSON. - `source/isaaclab/isaaclab/renderers/render_context.py`: adds a backend-agnostic NVTX range (`RENDER_PROFILE_SCOPE`) around `RenderContext.render_into_camera`'s renderer call, gated by the `ISAACLAB_RENDER_PROFILE` env var so it has no effect (and no device-sync cost) in normal runs. - `source/isaaclab_tasks/isaaclab_tasks/core/render_benchmark/`: a new direct-workflow task, `Isaac-RenderBenchmark-Franka-Cabinet`, with no policy or reward — all articulated joints (Franka + cabinet drawers/doors) are driven by a deterministic sinusoid so every frame has moving geometry to render. Supports `newton_mjwarp`/`physx` physics presets and `rgb`/`albedo`/`depth`/`simple_shading_*` camera presets. ## Type of change - New feature (non-breaking change which adds functionality) ## Release backport - [ ] Backport this pull request to the active release branch after it merges into `develop` ## Checklist - [x] I have read and understood the contribution guidelines - [x] I have run the `pre-commit` checks with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] I have added a changelog fragment under `source//changelog.d/` for every touched package - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Co-authored-by: Matthew Taylor (cherry picked from commit 487b5cac4a2a94ddd60070b3de21a16904ef0679) --- scripts/benchmarks/benchmark_renderer.py | 360 ++++++++++++++++++ .../test/test_benchmark_renderer.py | 136 +++++++ .../changelog.d/render-nvtx-profile-scope.rst | 8 + .../isaaclab/renderers/render_context.py | 32 +- .../test_simulation_render_context.py | 34 ++ .../benchmark-task-package.minor.rst | 10 + .../changelog.d/render-benchmark-task.rst | 5 + .../isaaclab_tasks/isaaclab_tasks/__init__.py | 3 + .../isaaclab_tasks/benchmark/__init__.py | 10 + .../benchmark/render_benchmark/__init__.py | 15 + .../render_benchmark/render_benchmark_env.py | 190 +++++++++ .../render_benchmark_env_cfg.py | 213 +++++++++++ .../benchmark/shadow_hand_camera/__init__.py | 21 + .../shadow_hand_camera_benchmark_env_cfg.py | 0 .../reorient/config/shadow_hand/__init__.py | 13 +- .../benchmark/test_render_benchmark_cfg.py | 130 +++++++ 16 files changed, 1166 insertions(+), 14 deletions(-) create mode 100644 scripts/benchmarks/benchmark_renderer.py create mode 100644 scripts/benchmarks/test/test_benchmark_renderer.py create mode 100644 source/isaaclab/changelog.d/render-nvtx-profile-scope.rst create mode 100644 source/isaaclab_tasks/changelog.d/benchmark-task-package.minor.rst create mode 100644 source/isaaclab_tasks/changelog.d/render-benchmark-task.rst create mode 100644 source/isaaclab_tasks/isaaclab_tasks/benchmark/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/render_benchmark_env.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/render_benchmark_env_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/benchmark/shadow_hand_camera/__init__.py rename source/isaaclab_tasks/isaaclab_tasks/{contrib/reorient/config/shadow_hand => benchmark/shadow_hand_camera}/shadow_hand_camera_benchmark_env_cfg.py (100%) create mode 100644 source/isaaclab_tasks/test/benchmark/test_render_benchmark_cfg.py diff --git a/scripts/benchmarks/benchmark_renderer.py b/scripts/benchmarks/benchmark_renderer.py new file mode 100644 index 000000000000..4cb93e3d032c --- /dev/null +++ b/scripts/benchmarks/benchmark_renderer.py @@ -0,0 +1,360 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +# +# Command to run: +# uv run --no-sync python scripts/benchmarks/benchmark_renderer.py [PROFILE] +# + +import argparse +import fnmatch +import json +import os +import re +import shutil +import site +import statistics +import subprocess +import sys +from pathlib import Path + +PROFILES = [ + { + "name": "ovrtx_constant_diffuse_oldpipe", + "preset": "ovrtx_renderer,simple_shading_constant_diffuse", + "settings": {"min-pipe": False}, + }, + { + "name": "ovrtx_constant_diffuse_newpipe", + "preset": "ovrtx_renderer,simple_shading_constant_diffuse", + "settings": {"min-pipe": True}, + }, + { + "name": "ovrtx_diffuse_mdl_oldpipe", + "preset": "ovrtx_renderer,simple_shading_diffuse_mdl", + "settings": {"min-pipe": False}, + }, + { + "name": "ovrtx_diffuse_mdl_newpipe", + "preset": "ovrtx_renderer,simple_shading_diffuse_mdl", + "settings": {"min-pipe": True}, + }, + { + "name": "ovrtx_full_mdl_oldpipe", + "preset": "ovrtx_renderer,simple_shading_full_mdl", + "settings": {"min-pipe": False}, + }, + { + "name": "ovrtx_full_mdl_newpipe", + "preset": "ovrtx_renderer,simple_shading_full_mdl", + "settings": {"min-pipe": True}, + }, + {"name": "newton_lbvh_lbvh", "preset": "newton_renderer,rgb", "settings": {"tlas": "lbvh", "blas": "lbvh"}}, + {"name": "newton_lbvh_sah", "preset": "newton_renderer,rgb", "settings": {"tlas": "lbvh", "blas": "sah"}}, + {"name": "newton_lbvh_cubql", "preset": "newton_renderer,rgb", "settings": {"tlas": "lbvh", "blas": "cubql"}}, + {"name": "newton_sah_lbvh", "preset": "newton_renderer,rgb", "settings": {"tlas": "sah", "blas": "lbvh"}}, + {"name": "newton_sah_sah", "preset": "newton_renderer,rgb", "settings": {"tlas": "sah", "blas": "sah"}}, + {"name": "newton_sah_cubql", "preset": "newton_renderer,rgb", "settings": {"tlas": "sah", "blas": "cubql"}}, +] + +TASK_NAME = "Isaac-RenderBenchmark-Franka-Cabinet" +FRAME_PADDING = 5 + +# Resolved from this file rather than the working directory, so the script runs from anywhere. +SCRIPT_DIR = Path(__file__).resolve().parent +RUNTIME_SCRIPT = SCRIPT_DIR / "runtime.py" +OUTPUT_PATH = str(SCRIPT_DIR.parent.parent / "benchmarks") + +OVRTX_RENDERER = "ovrtx_renderer" +NEWTON_RENDERER = "newton_renderer" + +RENDER_SCOPE = "IsaacLab::Renderer::render" +"""Backend-agnostic timer name around ``BaseRenderer.render``, enabled by ``ISAACLAB_RENDER_PROFILE``. + +See :data:`isaaclab.renderers.render_context.RENDER_PROFILE_SCOPE`. It brackets the render alone, +excluding the scene-state sync before it and the output readback after it. ``wp.ScopedTimer`` prints +one ``" took X.XX ms"`` line per call, which :func:`parse_log` regexes out of the run's log. +""" + +RENDER_SCOPE_PATTERN = re.compile(rf"{re.escape(RENDER_SCOPE)} took ([\d.]+) ms") + +log_stream = sys.stdout +"""Destination for progress and diagnostics. ``--json`` points it at stderr so stdout holds only JSON.""" + + +def log(message: str = "") -> None: + """Write one human-readable line to :data:`log_stream`.""" + print(message, file=log_stream) + + +def pixels_per_second(median_ms: float, num_envs: int, resolution: int) -> float: + """Rendered pixels per second [px/s] implied by a median frame time. + + Args: + median_ms: Median time to render one frame [ms]. + num_envs: Number of environments rendered per frame. + resolution: Width and height of each environment's tile [px]. + + Returns: + Throughput in pixels per second [px/s]. + """ + return num_envs * resolution * resolution * 1000.0 / median_ms + + +def build_record(profile: dict, results: dict | None, num_envs: int, resolution: int) -> dict: + """Summarize one profile's outcome for reporting. + + Args: + profile: Profile entry from :data:`PROFILES`. + results: Timing statistics from :func:`parse_log`, or ``None`` if the run failed. + num_envs: Number of environments the profile rendered. + resolution: Width and height of each environment's tile [px]. + + Returns: + A record carrying the profile's identity plus either its timings or the log to inspect. + """ + record = {"name": profile["name"], "preset": profile["preset"], "settings": profile["settings"]} + if not results: + return record | {"status": "failed", "log": os.path.join(OUTPUT_PATH, profile["name"] + ".log")} + return ( + record + | { + "status": "ok", + "size": results["size"], + "pixels_per_second": pixels_per_second(results["median"], num_envs, resolution), + } + | {f"{key}_ms": results[key] for key in ("median", "mean", "min", "max", "stdev")} + ) + + +def parse_log(filename: str, num_frames: int): + """Summarize per-frame render times [ms] from a run's captured log. + + Every backend is measured the same way: the wall time of :data:`RENDER_SCOPE`, printed once per + render by ``wp.ScopedTimer`` when ``ISAACLAB_RENDER_PROFILE`` is set. The timer synchronizes the + device on both ends, so it covers completed rather than merely submitted work — including for + the RTX backends, whose Vulkan render is consumed by warp extraction kernels inside the scope. + + Args: + filename: Path to the captured run log. + num_frames: Number of frames to measure, after skipping :data:`FRAME_PADDING` warm-up frames. + + Returns: + Timing statistics, or ``None`` if the log holds no usable frames. + """ + with open(filename) as file: + frames = [float(match.group(1)) for line in file if (match := RENDER_SCOPE_PATTERN.search(line))] + + if not frames: + log(f"No '{RENDER_SCOPE}' timings in {filename}; was ISAACLAB_RENDER_PROFILE set for this run?") + return None + out = frames[FRAME_PADDING : FRAME_PADDING + num_frames] + + if out: + return { + "size": len(out), + "median": statistics.median(out), + "mean": statistics.mean(out), + "min": min(out), + "max": max(out), + "stdev": statistics.stdev(out) if len(out) > 1 else 0, + } + return None + + +def run_profile(profile: dict, args: argparse.Namespace): + """Run one entry of :data:`PROFILES` and summarize its render times from the captured log. + + Args: + profile: Profile entry naming the preset and its backend settings. + args: Parsed command-line arguments. + + Returns: + Timing statistics from :func:`parse_log`, or ``False`` if the run failed. + """ + warp_cache_path = os.path.join(OUTPUT_PATH, "warp-cache") + + env = { + "NEWTON_USE_CUDA_GRAPH": "0", + "ISAACLAB_RENDER_PROFILE": "1", + "BENCHMARK_SAVE_IMAGE": "1" if args.save_image else "0", + "BENCHMARK_RENDER_RESOLUTION": f"{args.resolution}", + "WARP_CACHE_PATH": warp_cache_path, + } + + # Kernel compilation lands in the warm-up frames that FRAME_PADDING discards, so a warm cache + # does not reach the measured window -- it only saves a recompile per profile. + if not args.keep_warp_cache and os.path.exists(warp_cache_path): + shutil.rmtree(warp_cache_path) + + profile_name: str = profile["name"] + preset: str = profile["preset"] + renderer: str = preset.split(",")[0] + + if renderer == OVRTX_RENDERER: + env["LD_PRELOAD"] = os.path.join( + site.getsitepackages()[0], "ovrtx/bin/plugins/omni.client.lib/libomniclient.so" + ) + env["CUDA_VISIBLE_DEVICES"] = "0" + env["OMNI_KIT_ACCEPT_EULA"] = "YES" + env["OVRTX_rtx_post_tonemap_op"] = "0" + env["OVRTX_rtx_minimal_useMinimalPipeline"] = "1" if profile["settings"]["min-pipe"] else "0" + + if renderer == NEWTON_RENDERER: + env["NEWTON_BVH_SCENE"] = profile["settings"]["tlas"] + env["NEWTON_BVH_GEOMETRY"] = profile["settings"]["blas"] + + os.makedirs(OUTPUT_PATH, exist_ok=True) + log_filename = os.path.join(OUTPUT_PATH, profile_name + ".log") + + cmd = [ + sys.executable, + str(RUNTIME_SCRIPT), + "--task", + args.task, + "--num_envs", + f"{args.num_envs}", + "--warmup_steps", + "0", + "--num_steps", + f"{args.num_frames + FRAME_PADDING * 2}", + "--output_path", + OUTPUT_PATH, + f"presets={preset}", + ] + + with open(log_filename, "w") as file: + process = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=dict(os.environ) | env, text=True + ) + + for line in process.stdout: + file.write(line) + if args.verbose: + log_stream.write(f"\x1b[90m{line}\x1b[0m") + log_stream.flush() + + return_code = process.wait() + if return_code != 0: + log(f"Failed with exit code {process.returncode}, see {log_filename} for details.") + return False + + return parse_log(log_filename, args.num_frames) + + +def _build_arg_parser() -> argparse.ArgumentParser: + """Build the CLI parser, kept separate from module import so tests can import pure helpers.""" + parser = argparse.ArgumentParser("IsaacLab Benchmark: Sweep Franka Cabinet") + parser.add_argument("--num_frames", type=int, default="20", help="Number of frames to render") + parser.add_argument("--num_envs", type=int, default="1024", help="Number of environments to render") + parser.add_argument("--resolution", type=int, default="256", help="Render resolution") + parser.add_argument("--task", default=TASK_NAME, help="Gym task id to profile") + parser.add_argument( + "--keep_warp_cache", + action="store_true", + help="Reuse the warp kernel cache instead of recompiling per profile", + ) + parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output") + parser.add_argument("-s", "--save_image", action="store_true", help="Save image for debugging purposes") + parser.add_argument("--json", action="store_true", help="Write results to stdout as JSON instead of a table") + parser.add_argument("profile", nargs="*", help="Profiles to run") + return parser + + +def main() -> None: + """Parse CLI arguments, run the selected profiles, and report their results.""" + global log_stream + + args = _build_arg_parser().parse_args() + + # Keep stdout free of anything but the JSON document. + if args.json: + log_stream = sys.stderr + + if not args.profile: + if args.json: + print(json.dumps({"available_profiles": [profile["name"] for profile in PROFILES]}, indent=2)) + else: + log("Available profiles:") + for profile in PROFILES: + log(" " + profile["name"]) + exit(0) + + matched_names = set() + for profile_name in args.profile: + matches = [profile["name"] for profile in PROFILES if fnmatch.fnmatch(profile["name"], profile_name)] + if not matches: + print(f"No profile found matching: {profile_name}", file=sys.stderr) + exit(1) + matched_names.update(matches) + + # Run in declaration order so a given selection always reports in the same order. + selected_profiles = [profile for profile in PROFILES if profile["name"] in matched_names] + + all_results = {} + for profile in selected_profiles: + log(f"profile: {profile['name']}") + log(f" preset: {profile['preset']}") + for key, value in profile["settings"].items(): + log(f" {key}: {value}") + + try: + all_results[profile["name"]] = run_profile(profile, args) + except KeyboardInterrupt: + break + + if results := all_results[profile["name"]]: + log(f" size: {results['size']}") + for key, value in results.items(): + if key != "size": + log(f" {key}: {value:.2f}ms") + log("") + + # A KeyboardInterrupt leaves the remaining profiles unrun; report only what completed. + records = [ + build_record(profile, all_results[profile["name"]], args.num_envs, args.resolution) + for profile in selected_profiles + if profile["name"] in all_results + ] + benchmark_failed = any(record["status"] == "failed" for record in records) + + if args.json: + print( + json.dumps( + { + "task": args.task, + "num_envs": args.num_envs, + "num_frames": args.num_frames, + "resolution": args.resolution, + "profiles": records, + }, + indent=2, + ) + ) + else: + separator = "|------------------------------------------|------|--------------|--------------|--------------|--------------|--------------|--------------|" # noqa: E501 + log("") + log( + "| PROFILE | SIZE | PIXEL / SEC | MEDIAN | MEAN | MIN | MAX | STDEV |" # noqa: E501 + ) + log(separator) + for record in records: + if record["status"] == "ok": + gpxs = record["pixels_per_second"] / 1e9 + log( + f"| {record['name']:<40} | {record['size']:>4} | {gpxs:>6.2f} Gpx/s | {record['median_ms']:>10.2f}ms | {record['mean_ms']:>10.2f}ms | {record['min_ms']:>10.2f}ms | {record['max_ms']:>10.2f}ms | {record['stdev_ms']:>10.2f}ms |" # noqa: E501 + ) + else: + log(f"| {record['name']:<40} | FAILED {record['log']:<87} |") + log(separator) + log("") + + if benchmark_failed: + exit(1) + exit(0) + + +if __name__ == "__main__": + main() diff --git a/scripts/benchmarks/test/test_benchmark_renderer.py b/scripts/benchmarks/test/test_benchmark_renderer.py new file mode 100644 index 000000000000..05c57d63ff5b --- /dev/null +++ b/scripts/benchmarks/test/test_benchmark_renderer.py @@ -0,0 +1,136 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Unit tests for the pure helpers in ``scripts/benchmarks/benchmark_renderer.py``. + +The script drives a rendering backend end-to-end, so that path is not covered here. These tests +exercise the record-building, log-parsing, and CLI-parsing logic that does not require a GPU. +""" + +import importlib.util +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT_PATH = ROOT / "scripts" / "benchmarks" / "benchmark_renderer.py" + + +def _load_module(): + """Import ``benchmark_renderer.py`` as a module, bypassing its CLI entry point.""" + spec = importlib.util.spec_from_file_location("benchmark_renderer", SCRIPT_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def benchmark_renderer(): + return _load_module() + + +def test_render_scope_matches_render_context(benchmark_renderer): + """The script's timer name must match the one the renderer prints, or nothing is parsed.""" + from isaaclab.renderers.render_context import RENDER_PROFILE_SCOPE + + assert benchmark_renderer.RENDER_SCOPE == RENDER_PROFILE_SCOPE + + +def test_pixels_per_second(benchmark_renderer): + """Throughput is env count times tile area, scaled from milliseconds to seconds.""" + assert benchmark_renderer.pixels_per_second(median_ms=1000.0, num_envs=4, resolution=256) == pytest.approx( + 4 * 256 * 256 + ) + + +def test_build_record_ok(benchmark_renderer): + profile = {"name": "p", "preset": "newton_renderer,rgb", "settings": {"tlas": "sah"}} + results = {"size": 20, "median": 2.0, "mean": 2.1, "min": 1.5, "max": 3.0, "stdev": 0.2} + + record = benchmark_renderer.build_record(profile, results, num_envs=4, resolution=256) + + assert record["status"] == "ok" + assert record["size"] == 20 + assert record["median_ms"] == 2.0 + assert record["pixels_per_second"] == pytest.approx(benchmark_renderer.pixels_per_second(2.0, 4, 256)) + + +def test_build_record_failed(benchmark_renderer): + profile = {"name": "p", "preset": "newton_renderer,rgb", "settings": {"tlas": "sah"}} + + record = benchmark_renderer.build_record(profile, None, num_envs=4, resolution=256) + + assert record == { + "name": "p", + "preset": "newton_renderer,rgb", + "settings": {"tlas": "sah"}, + "status": "failed", + "log": str(Path(benchmark_renderer.OUTPUT_PATH) / "p.log"), + } + + +_RENDER_SCOPE = "IsaacLab::Renderer::render" + + +def _write_log(path: Path, timings_ms: list[float]) -> None: + """Write a synthetic run log with one ``wp.ScopedTimer`` print line per timing. + + Interleaves unrelated lines to mimic real subprocess output (warp init banner, other timers), + so the parser is exercised against noise rather than a file with only matching lines. + """ + lines = ["Warp 1.17.0 initialized:", "SomeOtherScope took 0.10 ms"] + for value in timings_ms: + lines.append(f"{_RENDER_SCOPE} took {value:.2f} ms") + path.write_text("\n".join(lines) + "\n") + + +def test_parse_log_skips_padding_and_keeps_num_frames(benchmark_renderer, tmp_path): + """Only the ``num_frames`` timings after :data:`FRAME_PADDING` warm-up frames are summarized.""" + log_path = tmp_path / "profile.log" + padding = benchmark_renderer.FRAME_PADDING + # padding warm-up frames of 1ms each, then 4 measured frames of 2ms each (only 3 are kept). + timings = [1.0] * padding + [2.0] * 4 + _write_log(log_path, timings) + + results = benchmark_renderer.parse_log(str(log_path), num_frames=3) + + assert results["size"] == 3 + assert results["median"] == pytest.approx(2.0) + assert results["min"] == pytest.approx(2.0) + assert results["max"] == pytest.approx(2.0) + + +def test_parse_log_returns_none_without_matching_lines(benchmark_renderer, tmp_path): + """A log with no ``RENDER_SCOPE`` timings means profiling was never enabled for that run.""" + log_path = tmp_path / "profile.log" + _write_log(log_path, []) + + assert benchmark_renderer.parse_log(str(log_path), num_frames=3) is None + + +def _run_cli(args: list[str]) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, str(SCRIPT_PATH), *args], capture_output=True, text=True, cwd=ROOT, timeout=30 + ) + + +def test_cli_lists_available_profiles_as_json(benchmark_renderer): + """With no profile selected, the CLI reports every declared profile name and exits cleanly.""" + result = _run_cli(["--json"]) + + assert result.returncode == 0 + payload = json.loads(result.stdout) + assert payload["available_profiles"] == [profile["name"] for profile in benchmark_renderer.PROFILES] + + +def test_cli_rejects_unmatched_profile_glob(): + """An unmatched profile pattern fails fast, before any profiling subprocess is launched.""" + result = _run_cli(["does-not-exist-*"]) + + assert result.returncode == 1 + assert "No profile found matching: does-not-exist-*" in result.stderr diff --git a/source/isaaclab/changelog.d/render-nvtx-profile-scope.rst b/source/isaaclab/changelog.d/render-nvtx-profile-scope.rst new file mode 100644 index 000000000000..df22fa38fe68 --- /dev/null +++ b/source/isaaclab/changelog.d/render-nvtx-profile-scope.rst @@ -0,0 +1,8 @@ +Added +^^^^^ + +* Added a synchronized timer around the renderer call inside + :meth:`~isaaclab.renderers.render_context.RenderContext.render_into_camera`, gated by the ``ISAACLAB_RENDER_PROFILE`` + environment variable, so any rendering backend can be profiled through the same scope name + (:data:`~isaaclab.renderers.render_context.RENDER_PROFILE_SCOPE`). When enabled, each render prints its elapsed + time to the log. diff --git a/source/isaaclab/isaaclab/renderers/render_context.py b/source/isaaclab/isaaclab/renderers/render_context.py index aa132a98b2d8..c08fe2e7f143 100644 --- a/source/isaaclab/isaaclab/renderers/render_context.py +++ b/source/isaaclab/isaaclab/renderers/render_context.py @@ -8,6 +8,7 @@ from __future__ import annotations import logging +import os from typing import Any import torch @@ -21,6 +22,22 @@ logger = logging.getLogger(__name__) +RENDER_PROFILE_SCOPE = "IsaacLab::Renderer::render" +"""Name of the timed scope bracketing :meth:`BaseRenderer.render`, emitted when render profiling is on. + +Every backend renders through the same call, so a profile can compare them under one scope name +instead of one internal name per backend. ``wp.ScopedTimer`` prints one ``" took X.XX ms"`` +line per call, which ``scripts/benchmarks/benchmark_renderer.py`` parses back out of the run log. +""" + +_RENDER_PROFILE_ENABLED = os.environ.get("ISAACLAB_RENDER_PROFILE", "0") != "0" +"""Whether to time and print :data:`RENDER_PROFILE_SCOPE`, read once from ``ISAACLAB_RENDER_PROFILE``. + +Off by default because the timer synchronizes the device on entry and exit. That is what lets it +measure completed device work rather than submitted work, but it also removes CPU/GPU overlap, so +an enabled run is a profiling aid and not a throughput measurement. +""" + @wp.kernel(enable_backward=False) def _write_material( @@ -340,9 +357,20 @@ def render_into_camera( camera_data: CameraData, physics_step_count: int, ) -> None: - """Sync scene state, render, and read outputs into ``camera_data``.""" + """Sync scene state, render, and read outputs into ``camera_data``. + + Only the render itself is bracketed by :data:`RENDER_PROFILE_SCOPE`, so a profile + attributes neither the scene-state sync before it nor the output readback after it to + rendering. See :data:`_RENDER_PROFILE_ENABLED` for how to turn the timer on. + """ self.update_scene_state(physics_step_count) - renderer.render(render_data) + with wp.ScopedTimer( + RENDER_PROFILE_SCOPE, + active=_RENDER_PROFILE_ENABLED, + print=True, + synchronize=True, + ): + renderer.render(render_data) renderer.read_output(render_data, camera_data) def reset_stage_prepare_flag(self) -> None: diff --git a/source/isaaclab/test/renderers/test_simulation_render_context.py b/source/isaaclab/test/renderers/test_simulation_render_context.py index 7aff3c4c9099..eed7bd67dd1f 100644 --- a/source/isaaclab/test/renderers/test_simulation_render_context.py +++ b/source/isaaclab/test/renderers/test_simulation_render_context.py @@ -7,10 +7,12 @@ from __future__ import annotations +import re from typing import Any, cast import pytest +from isaaclab.renderers import render_context from isaaclab.renderers.base_renderer import BaseRenderer from isaaclab.renderers.output_contract import RenderBufferKind, RenderBufferSpec from isaaclab.renderers.render_context import RenderContext @@ -207,6 +209,38 @@ def test_render_into_camera_calls_update_render_read_order(): assert events == ["ut", "geo", "render", "read", "render", "read"] +def test_render_into_camera_call_order_unaffected_by_render_profile_flag(monkeypatch): + """Enabling ``ISAACLAB_RENDER_PROFILE`` only wraps the render call in a printed timer. + + The wrapping must not change the call order or drop the render call itself. + """ + monkeypatch.setattr(render_context, "_RENDER_PROFILE_ENABLED", True) + + ctx = RenderContext() + events: list[str] = [] + cfg = IsaacRtxRendererCfg() + fake = _FakeBackend(event_log=events) + _set_entries(ctx, (cfg, fake)) + + ctx.render_into_camera(cast(BaseRenderer, fake), object(), CameraData(), physics_step_count=1) + + assert events == ["ut", "geo", "render", "read"] + + +def test_render_into_camera_prints_timing_line_when_render_profile_enabled(monkeypatch, capsys): + """The printed line must match the format ``scripts/benchmarks/benchmark_renderer.py`` parses.""" + monkeypatch.setattr(render_context, "_RENDER_PROFILE_ENABLED", True) + + ctx = RenderContext() + cfg = IsaacRtxRendererCfg() + fake = _FakeBackend() + _set_entries(ctx, (cfg, fake)) + + ctx.render_into_camera(cast(BaseRenderer, fake), object(), CameraData(), physics_step_count=1) + + assert re.search(rf"{re.escape(render_context.RENDER_PROFILE_SCOPE)} took [\d.]+ ms", capsys.readouterr().out) + + def test_reset_stage_prepare_flag_allows_second_prepare_stage(): """After reset_stage_prepare_flag, ensure_prepare_stage invokes prepare_stage again.""" ctx = RenderContext() diff --git a/source/isaaclab_tasks/changelog.d/benchmark-task-package.minor.rst b/source/isaaclab_tasks/changelog.d/benchmark-task-package.minor.rst new file mode 100644 index 000000000000..43b0be9ed46c --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/benchmark-task-package.minor.rst @@ -0,0 +1,10 @@ +Changed +^^^^^^^ + +* Moved benchmark-only tasks into a new ``isaaclab_tasks.benchmark`` package so they are no longer mixed in with + the trainable ``core`` and ``contrib`` task families. The ``IsaacContrib-Reorient-Cube-Shadow-Camera-Benchmark-Direct`` + task id is unchanged, but its configuration moved from + ``isaaclab_tasks.contrib.reorient.config.shadow_hand.shadow_hand_camera_benchmark_env_cfg`` to + ``isaaclab_tasks.benchmark.shadow_hand_camera.shadow_hand_camera_benchmark_env_cfg``. Code that imports the + configuration class directly must update the import path; code that resolves the task through ``gym.make`` or + ``load_cfg_from_registry`` needs no change. diff --git a/source/isaaclab_tasks/changelog.d/render-benchmark-task.rst b/source/isaaclab_tasks/changelog.d/render-benchmark-task.rst new file mode 100644 index 000000000000..ccbd42cd669f --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/render-benchmark-task.rst @@ -0,0 +1,5 @@ +Added +^^^^^ + +* Added the ``Isaac-RenderBenchmark-Franka-Cabinet`` direct task, an animated Franka-and-cabinet scene with no + policy or reward used to compare rendering backends under ``scripts/benchmarks/benchmark_renderer.py``. diff --git a/source/isaaclab_tasks/isaaclab_tasks/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/__init__.py index 02cb223e544c..19ec4b1f023f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/__init__.py +++ b/source/isaaclab_tasks/isaaclab_tasks/__init__.py @@ -10,6 +10,9 @@ - ``core``: Core task families maintained as part of Isaac Lab. - ``contrib``: Contributed task families. These may depend on ``core`` tasks, but ``core`` tasks never depend on ``contrib`` tasks. +- ``benchmark``: Benchmark-only task families used to measure simulation and rendering + throughput. These may depend on ``core`` and ``contrib`` tasks, but neither depends + on ``benchmark`` tasks. - ``utils``: These include utility functions for the tasks. """ diff --git a/source/isaaclab_tasks/isaaclab_tasks/benchmark/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/benchmark/__init__.py new file mode 100644 index 000000000000..4bf835f15131 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/benchmark/__init__.py @@ -0,0 +1,10 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Benchmark-only task families. + +These tasks exist to measure simulation and rendering throughput rather than to train +policies. They may depend on ``core`` and ``contrib`` tasks, but neither depends on them. +""" diff --git a/source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/__init__.py new file mode 100644 index 000000000000..7ab3de636b7a --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Render-only benchmark scene for OVRTX / Newton-Warp comparison.""" + +import gymnasium as gym + +gym.register( + id="Isaac-RenderBenchmark-Franka-Cabinet", + entry_point=f"{__name__}.render_benchmark_env:RenderBenchmarkEnv", + disable_env_checker=True, + kwargs={"env_cfg_entry_point": f"{__name__}.render_benchmark_env_cfg:RenderBenchmarkFrankaCabinetEnvCfg"}, +) diff --git a/source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/render_benchmark_env.py b/source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/render_benchmark_env.py new file mode 100644 index 000000000000..4c71338e4f81 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/render_benchmark_env.py @@ -0,0 +1,190 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Direct environment for render-only benchmarking.""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +import torch + +import isaaclab.sim as sim_utils +from isaaclab import cloner +from isaaclab.assets import Articulation, RigidObject, RigidObjectCfg +from isaaclab.envs import DirectRLEnv +from isaaclab.sensors import Camera, save_images_to_file + +if TYPE_CHECKING: + from .render_benchmark_env_cfg import RenderBenchmarkFrankaCabinetEnvCfg + + +class RenderBenchmarkEnv(DirectRLEnv): + """Environment that animates its articulations so a renderer can be profiled on them. + + Every articulation in :attr:`RenderBenchmarkFrankaCabinetEnvCfg.articulations` is driven by a + sinusoid around its default joint positions. Actions are ignored and rewards are zero: the + only output that matters is the camera image, and the only cost that matters is the time + spent producing it. + """ + + cfg: RenderBenchmarkFrankaCabinetEnvCfg + + # --- scene construction -------------------------------------------------- + + def _setup_scene(self): + # Per-(env, joint) sinusoid phases [rad] and the elapsed animation time [s]. The phases + # are filled on the first step; see :meth:`_sample_animation_phases`. + self._anim_phases: dict[str, torch.Tensor] | None = None + self._anim_time: float = 0.0 + + self._articulations: dict[str, Articulation] = { + name: Articulation(articulation_cfg) for name, articulation_cfg in self.cfg.articulations.items() + } + self._tiled_camera = Camera(self.cfg.tiled_camera) + self._ground = RigidObject(self._ground_cfg()) + self._spawn_lights() + + # Assets must exist before planning: the plan keys its rows off the cfgs they queued. + src, dest = "/World/envs/env_0", "/World/envs/env_{}" + pos = cloner.grid_transforms(self.scene.num_envs, self.scene.cfg.env_spacing)[0] + plan = cloner.clone_plan_from_env_0(src, dest, self.scene.num_envs, pos) + cloner.replicate(plan) + + # PhysX replication requires explicit collision filtering between environments. + if "physx" in self.scene.physics_backend: + self.scene.filter_collisions(global_prim_paths=[]) + + for name, articulation in self._articulations.items(): + self.scene.articulations[name] = articulation + self.scene.rigid_objects["ground"] = self._ground + self.scene.sensors["tiled_camera"] = self._tiled_camera + + def _ground_cfg(self) -> RigidObjectCfg: + """Build the per-environment ground cuboid. + + A flat cuboid routed through :class:`~isaaclab.assets.RigidObject` keeps the comparison + apples-to-apples: a USD ground plane is invisible to Warp's renderer, which only knows + about simulation meshes, so the two renderers would otherwise disagree on primary-ray + misses and shadow-ray counts. + + The tile is clamped to the env spacing so neighboring clones tile edge-to-edge instead of + overlapping. An oversized tile would leave every environment's camera looking at several + redundant, coplanar ground meshes -- extra geometry that neither backend needs to resolve + in the real scene, and whose BVH/intersection cost grows with the environment count. + """ + spacing = self.scene.cfg.env_spacing + size_x = min(self.cfg.ground_size[0], spacing) + size_y = min(self.cfg.ground_size[1], spacing) + thickness = self.cfg.ground_thickness + return RigidObjectCfg( + prim_path="{ENV_REGEX_NS}/Ground", + spawn=sim_utils.CuboidCfg( + size=(size_x, size_y, thickness), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=self.cfg.ground_color, metallic=0.0), + rigid_props=sim_utils.RigidBodyPropertiesCfg(disable_gravity=True, kinematic_enabled=True), + mass_props=sim_utils.MassPropertiesCfg(mass=1.0), + collision_props=sim_utils.CollisionPropertiesCfg(), + ), + init_state=RigidObjectCfg.InitialStateCfg(pos=(0.0, 0.0, self.cfg.ground_top_z - thickness / 2.0)), + ) + + def _spawn_lights(self) -> None: + """Spawn the ambient dome light and, when configured, the directional light above it.""" + dome_cfg = sim_utils.DomeLightCfg(intensity=self.cfg.dome_light_intensity, color=(0.75, 0.75, 0.75)) + dome_cfg.func("/World/Light", dome_cfg) + if self.cfg.light_cfg is not None: + self.cfg.light_cfg.func( + "/World/LightDirectional", + self.cfg.light_cfg, + orientation=self.cfg.light_orientation, + ) + + # --- joint animation ----------------------------------------------------- + + def _pre_physics_step(self, actions: torch.Tensor) -> None: + if self.cfg.joint_animation_amplitude <= 0.0: + return + if self._anim_phases is None: + self._anim_phases = self._sample_animation_phases() + self._animate_joints() + + def _apply_action(self) -> None: + pass + + def _sample_animation_phases(self) -> dict[str, torch.Tensor]: + """Derive a phase offset [rad] for every (env, joint) pair, keyed by articulation name. + + The phases come from a deterministic hash of the indices rather than the torch RNG so a + given frame holds the same pose in every run. The default RNG state varies between + processes, which would leave an OVRTX run and a Warp run rendering different poses at the + same frame even though the solver itself is deterministic, making their timings + incomparable. Multiplying by two coprime primes spreads the phases pseudo-uniformly. + + Deferred to the first step because it reads articulation data, which the simulation only + populates after :meth:`_setup_scene` returns. + """ + phases = {} + for name, articulation in self._articulations.items(): + default_pos = articulation.data.default_joint_pos.torch + num_envs, num_joints = default_pos.shape + env_idx = torch.arange(num_envs, device=self.device, dtype=default_pos.dtype).unsqueeze(1) + joint_idx = torch.arange(num_joints, device=self.device, dtype=default_pos.dtype).unsqueeze(0) + phases[name] = ((env_idx * 7919.0 + joint_idx * 6553.0) % 10007.0) * (2.0 * math.pi / 10007.0) + return phases + + def _animate_joints(self) -> None: + """Advance the animation clock and drive every joint to its sinusoidal target.""" + self._anim_time += self.cfg.sim.dt * self.cfg.decimation + omega = 2.0 * math.pi * self.cfg.joint_animation_freq_hz + for name, articulation in self._articulations.items(): + default_pos = articulation.data.default_joint_pos.torch + offset = self.cfg.joint_animation_amplitude * torch.sin(omega * self._anim_time + self._anim_phases[name]) + soft_limits = articulation.data.soft_joint_pos_limits.torch + target = torch.clamp(default_pos + offset, soft_limits[..., 0], soft_limits[..., 1]) + articulation.actuators.target_command.set_position_index(value=target) + + # --- DirectRLEnv plumbing ------------------------------------------------ + + def _get_observations(self) -> dict: + # Sensor buffers update lazily, so reading the camera's data is what drives the render. + # This access is the work the benchmark measures: keep it unconditional even when no + # image is written, or the profile records a scene that was never rendered. + output = self._tiled_camera.data.output + if self.cfg.write_image_to_file: + self._write_camera_image(output) + return {"policy": torch.zeros((self.num_envs, 1), device=self.device)} + + def _write_camera_image(self, output: dict) -> None: + """Dump the current camera frame to a PNG. + + Purely a debugging aid, so any failure is reported and swallowed rather than allowed to + abort a benchmark run. + + Args: + output: Rendered outputs from the tiled camera, keyed by data type. + """ + data_type = self.cfg.tiled_camera.data_types[0] + if not isinstance(output, dict) or data_type not in output: + return + try: + image = output[data_type] + if not torch.is_tensor(image): + image = torch.from_dlpack(image) + image = image.float() + # Renderers hand back either 0-255 or already-normalized data. + if image.max() > 1.5: + image = image / 255.0 + save_images_to_file(image[:, ..., :3], f"render_benchmark_{data_type}.{self.common_step_counter:06d}.png") + except Exception as error: + print(f"[render_benchmark] write_image_to_file failed: {error}") + + def _get_rewards(self) -> torch.Tensor: + return torch.zeros(self.num_envs, device=self.device) + + def _get_dones(self): + time_out = self.episode_length_buf >= self.max_episode_length - 1 + return torch.zeros_like(time_out), time_out diff --git a/source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/render_benchmark_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/render_benchmark_env_cfg.py new file mode 100644 index 000000000000..d5459c316dd8 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/benchmark/render_benchmark/render_benchmark_env_cfg.py @@ -0,0 +1,213 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +import os + +from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.renderers import NewtonWarpRendererCfg +from isaaclab_physx.physics import PhysxCfg + +import isaaclab.sim as sim_utils +from isaaclab.actuators import ImplicitActuatorCfg +from isaaclab.assets import ArticulationCfg +from isaaclab.envs import DirectRLEnvCfg +from isaaclab.physics import PhysxAutoCfg +from isaaclab.scene import InteractiveSceneCfg +from isaaclab.sensors import CameraCfg +from isaaclab.sim import SimulationCfg +from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR +from isaaclab.utils.configclass import configclass + +from isaaclab_tasks.utils import PresetCfg +from isaaclab_tasks.utils.presets import MultiBackendRendererCfg + +from isaaclab_assets.robots.franka import FRANKA_PANDA_HIGH_PD_CFG + + +@configclass +class RenderBenchmarkPhysicsCfg(PresetCfg): + """Physics backend presets. + + Pick via ``presets=newton_mjwarp`` (default) or ``presets=physx``, which resolves to the + concrete PhysX backend at launch. Use ``presets=isaacsim_physx`` to pin Isaac Sim PhysX. + The BVH constructors are read from the environment so ``benchmark_renderer.py`` can sweep + them without a separate preset per combination. + """ + + newton_mjwarp: NewtonCfg = NewtonCfg( + solver_cfg=MJWarpSolverCfg(solver="newton", integrator="implicitfast", njmax=200, nconmax=70), + num_substeps=2, + bvh_constructor_geometry=os.getenv("NEWTON_BVH_GEOMETRY", "cubql"), + bvh_constructor_gaussian=os.getenv("NEWTON_BVH_GAUSSIAN", "cubql"), + bvh_constructor_scene=os.getenv("NEWTON_BVH_SCENE", "sah"), + use_cuda_graph=os.getenv("NEWTON_USE_CUDA_GRAPH", "0") == "1", + ) + isaacsim_physx: PhysxCfg = PhysxCfg() + physx: PhysxAutoCfg = PhysxAutoCfg(isaacsim_physx=isaacsim_physx) + default = newton_mjwarp + + +@configclass +class RenderBenchmarkTiledCameraCfg(PresetCfg): + """Render-target presets — pick via ``presets=rgb`` (default), ``presets=depth``, and so on.""" + + @configclass + class BaseRenderBenchmarkCameraCfg(CameraCfg): + """Front view of the workspace, pitch-only (no yaw, no roll). + + The camera sits on the drawer-facing ``+X`` side of the cabinet, elevated and pitched + ~34.5 deg down so the workspace reads as a slight bird's-eye. ``rot`` is ``(qx, qy, qz, qw)`` + in ``convention="world"`` (``+X`` camera-forward): with ``qw=0`` and only ``qx``/``qz`` set it + is a 180 deg rotation about the ``(-X, 0, +Z)`` axis, aiming the camera at ``(0.4, 0, 0.4)``. + """ + + prim_path: str = "{ENV_REGEX_NS}/Camera" + offset: CameraCfg.OffsetCfg = CameraCfg.OffsetCfg( + pos=(2.0, 0.0, 1.5), rot=(-0.296, 0.0, 0.955, 0.0), convention="world" + ) + data_types: list[str] = [] + spawn: sim_utils.PinholeCameraCfg = sim_utils.PinholeCameraCfg( + focal_length=24.0, focus_distance=400.0, horizontal_aperture=20.955, clipping_range=(0.05, 50.0) + ) + width: int = int(os.getenv("BENCHMARK_RENDER_RESOLUTION", "256")) + height: int = int(os.getenv("BENCHMARK_RENDER_RESOLUTION", "256")) + renderer_cfg: MultiBackendRendererCfg = MultiBackendRendererCfg( + newton_renderer=NewtonWarpRendererCfg(enable_shadows=True), + ) + + default = BaseRenderBenchmarkCameraCfg(data_types=["rgb"]) + rgb = default + albedo = BaseRenderBenchmarkCameraCfg(data_types=["albedo"]) + depth = BaseRenderBenchmarkCameraCfg(data_types=["depth"]) + simple_shading_constant_diffuse = BaseRenderBenchmarkCameraCfg(data_types=["simple_shading_constant_diffuse"]) + simple_shading_diffuse_mdl = BaseRenderBenchmarkCameraCfg(data_types=["simple_shading_diffuse_mdl"]) + simple_shading_full_mdl = BaseRenderBenchmarkCameraCfg(data_types=["simple_shading_full_mdl"]) + + +@configclass +class RenderBenchmarkFrankaCabinetEnvCfg(DirectRLEnvCfg): + """Franka Panda and Sektion cabinet, animated for renderer benchmarking. + + The cabinet contributes four articulated joints (two drawers, two doors) on top of the + Franka's seven, and a sinusoidal animation drives all of them so every rendered frame has + moving articulated geometry rather than a static scene. There is no policy: actions are + ignored, rewards are zero, and the episode only ends on time-out. + + A mirrored layout of the canonical ``Isaac-Franka-Cabinet-Direct-v0`` task, which places the + Franka at the origin facing its default ``+X``: here the Franka sits at ``(1.0, 0, 0)`` rotated + 180 deg about Z (facing ``-X``, toward the cabinet), and the cabinet sits at the origin in its + default USD orientation. + """ + + decimation: int = 2 + episode_length_s: float = 60.0 + + action_space: int = 1 + observation_space: int = 1 + state_space: int = 0 + + sim: SimulationCfg = SimulationCfg(dt=1.0 / 120.0, render_interval=2, physics=RenderBenchmarkPhysicsCfg()) + + scene: InteractiveSceneCfg = InteractiveSceneCfg(num_envs=4, env_spacing=3.0, replicate_physics=True) + + tiled_camera: RenderBenchmarkTiledCameraCfg = RenderBenchmarkTiledCameraCfg() + + articulations: dict[str, ArticulationCfg] = { + # High-PD variant so the joints track the sinusoidal targets smoothly. + "robot": FRANKA_PANDA_HIGH_PD_CFG.replace( + prim_path="{ENV_REGEX_NS}/Robot", + init_state=FRANKA_PANDA_HIGH_PD_CFG.init_state.replace( + pos=(1.0, 0.0, 0.0), + rot=(0.0, 0.0, 1.0, 0.0), # 180 deg about Z, xyzw + ), + ), + # Loaded as an ArticulationCfg rather than a static USD reference so its four joints + # animate and it clones to every env through the standard Isaac Lab path. + "cabinet": ArticulationCfg( + prim_path="{ENV_REGEX_NS}/Cabinet", + spawn=sim_utils.UsdFileCfg( + usd_path=f"{ISAAC_NUCLEUS_DIR}/Props/Sektion_Cabinet/sektion_cabinet_instanceable.usd", + activate_contact_sensors=False, + ), + init_state=ArticulationCfg.InitialStateCfg( + pos=(0.0, 0.0, 0.4), + rot=(0.0, 0.0, 0.0, 1.0), # identity: cabinet's default USD orientation + joint_pos={ + "door_left_joint": 0.0, + "door_right_joint": 0.0, + "drawer_bottom_joint": 0.0, + "drawer_top_joint": 0.0, + }, + ), + actuators={ + "drawers": ImplicitActuatorCfg( + joint_names_expr=["drawer_top_joint", "drawer_bottom_joint"], + joint_effort_limit=87.0, + stiffness=10.0, + damping=1.0, + ), + "doors": ImplicitActuatorCfg( + joint_names_expr=["door_left_joint", "door_right_joint"], + joint_effort_limit=87.0, + stiffness=10.0, + damping=2.5, + ), + }, + ), + } + """Articulations spawned into every environment, keyed by scene name.""" + + ground_top_z: float = 0.0 + """Height of the ground's top surface [m].""" + + ground_size: tuple[float, float] = (50.0, 50.0) + """Requested extent of the per-environment ground cuboid in XY [m]. + + Clamped to :attr:`scene.env_spacing` when the cuboid is built, so neighboring environments' + ground tiles meet at the boundary instead of overlapping. + """ + + ground_thickness: float = 0.1 + """Thickness of the ground cuboid along Z [m].""" + + ground_color: tuple[float, float, float] = (0.5, 0.5, 0.5) + """Diffuse color of the ground, as linear RGB in ``[0, 1]``.""" + + dome_light_intensity: float = 2000.0 + """Intensity of the ambient dome light. + + Newton's renderer has no tone mapping or ambient defaults of its own, so without this + unlit surfaces render pure black. + """ + + light_cfg: sim_utils.LightCfg | None = sim_utils.DistantLightCfg( + intensity=200.0, + exposure=0.0, + angle=0.0, + color=(1.0, 1.0, 1.0), + normalize=True, + ) + """Directional light spawned on top of the ambient dome light, or ``None`` for dome only.""" + + light_orientation: tuple[float, float, float, float] = (0.3251, 0.3251, 0.0, 0.8881) + """Orientation of :attr:`light_cfg` as ``(qx, qy, qz, qw)``. + + Rotates a USD ``DistantLight``'s default ``-Z`` onto ``(-0.57735, 0.57735, -0.57735)``, the + direction Warp's renderer hard-codes, so both renderers light the scene identically. + """ + + joint_animation_amplitude: float = 0.4 + """Peak sinusoidal offset from each joint's default position [m or rad, depending on joint type]. + + Values at or below zero disable the animation, leaving a static scene. + """ + + joint_animation_freq_hz: float = 0.35 + """Frequency of the joint animation [Hz].""" + + write_image_to_file: bool = os.getenv("BENCHMARK_SAVE_IMAGE", "0") == "1" + """Whether to dump each rendered frame to a PNG, for eyeballing renderer output.""" diff --git a/source/isaaclab_tasks/isaaclab_tasks/benchmark/shadow_hand_camera/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/benchmark/shadow_hand_camera/__init__.py new file mode 100644 index 000000000000..0c377d8ded6d --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/benchmark/shadow_hand_camera/__init__.py @@ -0,0 +1,21 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Rendering-throughput benchmark variant of the Shadow Hand camera task.""" + +import gymnasium as gym + +from isaaclab_tasks.core.reorient.config.shadow_hand import agents as core_agents + +gym.register( + id="IsaacContrib-Reorient-Cube-Shadow-Camera-Benchmark-Direct", + entry_point="isaaclab_tasks.core.reorient.config.shadow_hand.shadow_hand_direct_camera_env:ShadowHandCameraEnv", + disable_env_checker=True, + kwargs={ + "env_cfg_entry_point": f"{__name__}.shadow_hand_camera_benchmark_env_cfg:ShadowHandCameraBenchmarkEnvCfg", + "rsl_rl_cfg_entry_point": f"{core_agents.__name__}.rsl_rl_ppo_cfg:ShadowHandCameraFFPPORunnerCfg", + "rl_games_cfg_entry_point": f"{core_agents.__name__}:rl_games_ppo_camera_cfg.yaml", + }, +) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/reorient/config/shadow_hand/shadow_hand_camera_benchmark_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/benchmark/shadow_hand_camera/shadow_hand_camera_benchmark_env_cfg.py similarity index 100% rename from source/isaaclab_tasks/isaaclab_tasks/contrib/reorient/config/shadow_hand/shadow_hand_camera_benchmark_env_cfg.py rename to source/isaaclab_tasks/isaaclab_tasks/benchmark/shadow_hand_camera/shadow_hand_camera_benchmark_env_cfg.py diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/reorient/config/shadow_hand/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/reorient/config/shadow_hand/__init__.py index cf604bdc6274..25de2b02b8a6 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/reorient/config/shadow_hand/__init__.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/reorient/config/shadow_hand/__init__.py @@ -3,12 +3,11 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Contributed Shadow Hand tasks: the rendering-throughput benchmark and the OpenAI variant.""" +"""Contributed Shadow Hand tasks: the OpenAI variants.""" import gymnasium as gym from isaaclab_tasks.contrib.reorient.config.shadow_hand import agents as contrib_agents -from isaaclab_tasks.core.reorient.config.shadow_hand import agents as core_agents gym.register( id="IsaacContrib-Reorient-Cube-Shadow-OpenAI-FF-Direct", @@ -31,13 +30,3 @@ "rsl_rl_cfg_entry_point": f"{contrib_agents.__name__}.rsl_rl_ppo_cfg:ShadowHandAsymLSTMPPORunnerCfg", }, ) -gym.register( - id="IsaacContrib-Reorient-Cube-Shadow-Camera-Benchmark-Direct", - entry_point="isaaclab_tasks.core.reorient.config.shadow_hand.shadow_hand_direct_camera_env:ShadowHandCameraEnv", - disable_env_checker=True, - kwargs={ - "env_cfg_entry_point": f"{__name__}.shadow_hand_camera_benchmark_env_cfg:ShadowHandCameraBenchmarkEnvCfg", - "rsl_rl_cfg_entry_point": f"{core_agents.__name__}.rsl_rl_ppo_cfg:ShadowHandCameraFFPPORunnerCfg", - "rl_games_cfg_entry_point": f"{core_agents.__name__}:rl_games_ppo_camera_cfg.yaml", - }, -) diff --git a/source/isaaclab_tasks/test/benchmark/test_render_benchmark_cfg.py b/source/isaaclab_tasks/test/benchmark/test_render_benchmark_cfg.py new file mode 100644 index 000000000000..431cdfa4730e --- /dev/null +++ b/source/isaaclab_tasks/test/benchmark/test_render_benchmark_cfg.py @@ -0,0 +1,130 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Tests for the ``Isaac-RenderBenchmark-Franka-Cabinet`` task's registration and presets. + +No Kit/GPU required: these only load and resolve the config through the registry, the same way +:mod:`scripts.benchmarks.benchmark_renderer` selects a preset before launching a run. +""" + +from types import SimpleNamespace + +import gymnasium as gym +import pytest +from isaaclab_newton.physics import NewtonCfg +from isaaclab_newton.renderers import NewtonWarpRendererCfg +from isaaclab_physx.physics import PhysxCfg + +from isaaclab.physics import PhysxAutoCfg + +import isaaclab_tasks # noqa: F401 +from isaaclab_tasks.benchmark.render_benchmark.render_benchmark_env import RenderBenchmarkEnv +from isaaclab_tasks.utils.hydra import collect_presets, resolve_presets +from isaaclab_tasks.utils.parse_cfg import load_cfg_from_registry + +_TASK = "Isaac-RenderBenchmark-Franka-Cabinet" + + +def _load_cfg(): + return load_cfg_from_registry(_TASK, "env_cfg_entry_point") + + +def test_task_registered_with_direct_entry_point(): + spec = gym.spec(_TASK) + + assert spec.kwargs["env_cfg_entry_point"] == ( + "isaaclab_tasks.benchmark.render_benchmark.render_benchmark_env_cfg:RenderBenchmarkFrankaCabinetEnvCfg" + ) + assert spec.disable_env_checker is True + + +def test_default_scene_and_articulations(): + cfg = _load_cfg() + + assert cfg.decimation == 2 + assert cfg.episode_length_s == pytest.approx(60.0) + assert cfg.scene.num_envs == 4 + assert set(cfg.articulations) == {"robot", "cabinet"} + assert cfg.joint_animation_amplitude == pytest.approx(0.4) + + +@pytest.mark.parametrize( + ("presets", "expected_type"), + [ + ((), NewtonCfg), + (("newton_mjwarp",), NewtonCfg), + (("physx",), PhysxAutoCfg), + (("isaacsim_physx",), PhysxCfg), + ], +) +def test_physics_presets_resolve_to_expected_backend(presets, expected_type): + cfg = resolve_presets(_load_cfg(), selected=presets) + + assert isinstance(cfg.sim.physics, expected_type) + + +_CAMERA_DATA_TYPE_PRESETS = [ + ("default", ["rgb"]), + ("rgb", ["rgb"]), + ("albedo", ["albedo"]), + ("depth", ["depth"]), + ("simple_shading_constant_diffuse", ["simple_shading_constant_diffuse"]), + ("simple_shading_diffuse_mdl", ["simple_shading_diffuse_mdl"]), + ("simple_shading_full_mdl", ["simple_shading_full_mdl"]), +] + + +@pytest.fixture(scope="module") +def render_benchmark_presets(): + """Collect every preset once, before any preset selection mutates the config in place.""" + return collect_presets(_load_cfg()) + + +@pytest.mark.parametrize("preset_name,expected_data_types", _CAMERA_DATA_TYPE_PRESETS) +def test_camera_presets_resolve_to_expected_data_types(render_benchmark_presets, preset_name, expected_data_types): + camera_presets = render_benchmark_presets["tiled_camera"] + assert preset_name in camera_presets, f"Preset '{preset_name}' not found in tiled_camera presets" + resolved = camera_presets[preset_name] + + assert resolved.data_types == expected_data_types + assert resolved.width > 0 + assert resolved.height > 0 + assert isinstance(resolved.renderer_cfg.newton_renderer, NewtonWarpRendererCfg) + + +def test_camera_resolution_defaults_to_256(): + """``BENCHMARK_RENDER_RESOLUTION`` is read once at import, so its effect is process-scoped; + this documents the default a fresh process gets when the env var is unset.""" + cfg = resolve_presets(_load_cfg(), selected=()) + + assert cfg.tiled_camera.width == 256 + assert cfg.tiled_camera.height == 256 + + +def _fake_env_for_ground_cfg(ground_size: tuple[float, float], env_spacing: float) -> SimpleNamespace: + return SimpleNamespace( + cfg=SimpleNamespace( + ground_size=ground_size, ground_thickness=0.1, ground_color=(0.5, 0.5, 0.5), ground_top_z=0.0 + ), + scene=SimpleNamespace(cfg=SimpleNamespace(env_spacing=env_spacing)), + ) + + +def test_ground_cfg_clamps_size_to_env_spacing(): + """An oversized ground tile must be clamped, or cloned environments' grounds overlap.""" + fake_env = _fake_env_for_ground_cfg(ground_size=(50.0, 50.0), env_spacing=3.0) + + ground_cfg = RenderBenchmarkEnv._ground_cfg(fake_env) + + assert ground_cfg.spawn.size[:2] == (3.0, 3.0) + + +def test_ground_cfg_keeps_size_smaller_than_env_spacing(): + """A tile already smaller than the env spacing must not be inflated.""" + fake_env = _fake_env_for_ground_cfg(ground_size=(2.0, 1.5), env_spacing=3.0) + + ground_cfg = RenderBenchmarkEnv._ground_cfg(fake_env) + + assert ground_cfg.spawn.size[:2] == (2.0, 1.5) From 46f23f762290ec83a210b6288c4ecd3018aef8b7 Mon Sep 17 00:00:00 2001 From: Henry Hu Date: Wed, 16 Sep 2026 19:16:21 +0800 Subject: [PATCH 09/13] Fix Newton joint positions in DOF space, and enable the Digit velocity tasks (#7520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## The problem `ArticulationData._sim_bind_joint_pos` is bound straight to `ArticulationView.get_dof_positions()`: ```python # isaaclab_newton/assets/articulation/articulation_data.py:1594 self._sim_bind_joint_pos = self._root_view.get_dof_positions(SimulationManager.get_state_0())[:, 0] # newton/_src/utils/selection.py:1587 def get_dof_positions(self, source): """Get the joint coordinate positions (DoF positions) ...""" return self._get_attribute_values("joint_q", source) ``` The name says DOF, the return is `joint_q` which is coordinate space. Newton (like MuJoCo) stores a ball joint as a 4-component unit quaternion against 3 DOFs, so for an articulation containing one the array is wider than `num_joints`, while `joint_names`, `joint_vel`, `default_joint_pos` and every joint gain stay in DOF space. On Agility Digit (6 ball joints) that is **56 coordinates against 50 DOFs**. PhysX is unaffected: its articulations are reduced-coordinate, a spherical joint is 3 scalars, `nq == nv`, and the string `joint_q` does not appear anywhere in `isaaclab_physx`. The MDP layer was written against that assumption and the Newton backend inherited it without a conversion. IsaacLab addresses joints by DOF index everywhere in `joint_names`, `find_joints`, `SceneEntityCfg.joint_ids`, so both directions break, and they break silently. ### Reads Every MDP term indexes both arrays with the same ids: ```python # isaaclab/envs/mdp/rewards.py:186 asset.data.joint_pos.torch[:, asset_cfg.joint_ids] - asset.data.default_joint_pos.torch[:, asset_cfg.joint_ids] ``` The two arrays are in different spaces, so past the first ball joint they refer to different joints. On Digit `joint_pos[13]`, `left_leg_knee`, reads a quaternion `w`, which barely leaves 1.0, so the knee angle never reaches the policy or the rewards. `sync_torque_telemetry` carries the same offset. ### Writes `write_joint_position_to_sim_*` scatters DOF-indexed values into coordinate slots, so `reset_joints_by_scale` corrupts the pose on every reset: the loop-closure residual goes from 0.0004 mm to 828 mm and the solver diverges within 10 steps. ## The fix `joint_coordinates.py` adds `JointCoordinateMap`, which builds the coordinate ↔ DOF index tables once per articulation and converts with four small Warp kernels. Ball joints go through rotation vector ↔ quaternion; the gather forces `w >= 0` first, because a quaternion double-covers SO(3) and without that a fixed pose can decode to `+θ` one step and `-(2π − θ)` the next, injecting discontinuities into the observation. Rotation vector is the representation consistent with `joint_qd`, which already holds angular velocity in the joint frame. `required` is False when every joint has one coordinate per DOF — i.e. every articulation without a ball joint. Those keep the existing zero-copy view onto `joint_q` and cost nothing. Four call sites: the binding in `articulation_data.py`, a lazy `_refresh_joint_positions()` in `update()` and the `joint_pos` property, a `_flush_joint_positions()` after each of the four joint position writers in `articulation.py`, and a forced refresh in `_post_actuator` (it runs inside the step, before `update()` bumps the timestamp). The scatter is scoped to the environments each write touched. Resets are staggered, so an unscoped scatter would round-trip every environment's ball joints through the log map on nearly every step, and the float32 rounding lands on the loop-closure constraints. ## The Digit tasks `DigitPhysicsCfg` gains a `newton_mjwarp` branch, so `presets=newton_mjwarp` selects it on the shipped tasks; the PhysX default is unchanged. Digit was never PhysX-only by design — that missing branch was the whole implementation of it. Four items specific to `digit_v4.usd`, all gated on the preset: * 32 `CollisionAPI` prims, every one a `/Visual/` decoration mesh on a RealSense camera mount. They become 57% of the robot's shapes and produced 3e7 N contact forces 1.4 m above the ground. * Ten joints ship with armature below MJWarp's explicit-damping bound `c·h/I < 2` — `wrist_yaw` at 7.86, eight more at 2.74. They get their own actuator group with `armature = preset(default=None, newton_mjwarp=0.10)`. * No `articulation_props`, so Newton filtered all 253 intra-articulation shape pairs and the legs passed through each other. * `entropy_coef = preset(default=0.01, newton_mjwarp=0.005)`. MJWarp is less forgiving of the action tail than PhysX, and at 0.01 a seed occasionally diverges late in training while the policy itself is still healthy. ## Result on the task this came from Digit was listed in `docs/source/refs/issues.rst` as "not currently validated" on Newton, attributed to its closed kinematic loops. The loops import and solve fine; this was the actual blocker. With it fixed (3 seeds each, 4096 envs, `Metrics/success_rate` at the final iteration): | | before | after | |---|---|---| | Velocity-Flat-Digit | 0.000 / 0.000 / 0.000 | **1.000 / 1.000 / 1.000** | | Velocity-Rough-Digit | 0.019 / 0.005 / 0.002 | **0.992 / 0.993 / 1.000** | Episode length 985–1000 of 1000, tip-over termination 0.008 (was 1.000 on rough), terrain curriculum climbing to 3.5 (was pinned at 0). - [x] I have read and understood the [contribution guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html) - [x] bug fixed - [ ] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] I have added a changelog fragment under `source//changelog.d/` for every touched package (do **not** edit `CHANGELOG.rst` or bump `extension.toml` — CI handles that) - [x] I have added my name to the `CONTRIBUTORS.md` or my name already exists there --------- Co-authored-by: ooctipus (cherry picked from commit 9d1896d3e4ee9438807ea2d953f24ec0c2c0ae8b) --- docs/source/refs/issues.rst | 15 +- .../fix-newton-ball-joint-dof-space.rst | 6 + .../isaaclab_assets/robots/agility.py | 34 ++- .../hehu-newton-ball-joint-dof-space.rst | 18 ++ .../assets/articulation/actuator_control.py | 71 +++--- .../assets/articulation/articulation.py | 60 ++++- .../assets/articulation/articulation_data.py | 62 ++++- .../assets/articulation/joint_coordinates.py | 232 ++++++++++++++++++ .../fixtures/views/mock_articulation_view.py | 33 ++- .../test/assets/test_joint_coordinates.py | 210 ++++++++++++++++ .../hehu-digit-newton-velocity-tasks.rst | 17 ++ .../config/digit/agents/rsl_rl_ppo_cfg.py | 3 +- .../velocity/config/digit/rough_env_cfg.py | 41 +++- 13 files changed, 731 insertions(+), 71 deletions(-) create mode 100644 source/isaaclab_assets/changelog.d/fix-newton-ball-joint-dof-space.rst create mode 100644 source/isaaclab_newton/changelog.d/hehu-newton-ball-joint-dof-space.rst create mode 100644 source/isaaclab_newton/isaaclab_newton/assets/articulation/joint_coordinates.py create mode 100644 source/isaaclab_newton/test/assets/test_joint_coordinates.py create mode 100644 source/isaaclab_tasks/changelog.d/hehu-digit-newton-velocity-tasks.rst diff --git a/docs/source/refs/issues.rst b/docs/source/refs/issues.rst index 09f574ffe9d9..8d17d43e06cc 100644 --- a/docs/source/refs/issues.rst +++ b/docs/source/refs/issues.rst @@ -118,22 +118,13 @@ OpenUSD provider that contains the fix or to Isaac Sim 6.1 or later. .. _known-issues-closed-loop-newton: -Closed-loop articulations are not available on Newton (e.g. Agility Digit) +Closed-loop articulations are not validated on Kamino (e.g. Agility Digit) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -**Affects:** ``physics=newton_mjwarp``, ``physics=newton_kamino``. +**Affects:** ``physics=newton_kamino``. Robots whose USD encodes a closed kinematic loop — such as the achilles rod and toe push-rods -on the Agility Digit — are not currently validated on the Newton backends. The Digit-based -contrib tasks are PhysX-only and do not expose a Newton preset at all: - -* ``IsaacContrib-Velocity-Flat-Digit`` -* ``IsaacContrib-Velocity-Rough-Digit`` -* ``IsaacContrib-Tracking-LocoManip-Digit`` - -Passing ``presets=newton_mjwarp`` to these tasks is rejected, because the preset never -selected a Newton backend on them; it only stripped a center-of-mass randomization from a -PhysX run. Use the default PhysX configuration for Digit-based environments. +on the Agility Digit — are not validated on ``newton_kamino``. Renderers diff --git a/source/isaaclab_assets/changelog.d/fix-newton-ball-joint-dof-space.rst b/source/isaaclab_assets/changelog.d/fix-newton-ball-joint-dof-space.rst new file mode 100644 index 000000000000..fb2e3c7b3922 --- /dev/null +++ b/source/isaaclab_assets/changelog.d/fix-newton-ball-joint-dof-space.rst @@ -0,0 +1,6 @@ +Changed +^^^^^^^ + +* Changed :obj:`~isaaclab_assets.robots.agility.DIGIT_V4_CFG` to disable colliders on the + RealSense camera mount's decoration meshes and to split actuators into a stable-armature and a + low-armature group, matching what the Digit velocity tasks previously configured per-task. diff --git a/source/isaaclab_assets/isaaclab_assets/robots/agility.py b/source/isaaclab_assets/isaaclab_assets/robots/agility.py index 2c85a42ec681..ed67e16dfba0 100644 --- a/source/isaaclab_assets/isaaclab_assets/robots/agility.py +++ b/source/isaaclab_assets/isaaclab_assets/robots/agility.py @@ -6,6 +6,7 @@ import isaaclab.sim as sim_utils from isaaclab.actuators import ImplicitActuatorCfg from isaaclab.assets.articulation import ArticulationCfg +from isaaclab.sim.schemas import UsdPhysicsCollisionCfg from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR LEG_JOINT_NAMES = [ @@ -19,21 +20,50 @@ ARM_JOINT_NAMES = [".*_arm_.*"] +# On MJWarp these ten joints diverge at the armature the USD authors. With the damping the asset +# carries (c = 57.3) and the velocity tasks' substep (h = sim.dt / num_substeps = 0.0025 s), the +# observed threshold is c*h/I > 2: wrist_yaw at I = 0.01822 [kg m^2] gives 7.86 and grew 6.9x per +# substep, the other eight at 0.05228 give 2.74, and every joint at or above I = 0.0716 was stable. +# 0.10 leaves margin. Re-measure if sim.dt, num_substeps or the asset's damping change. +# Together these cover exactly ``LEG_JOINT_NAMES + ARM_JOINT_NAMES``. +_LOW_ARMATURE_JOINT_NAMES = [".*_arm_wrist_.*", ".*_toe_a", ".*_toe_b"] +_STABLE_ARMATURE_JOINT_NAMES = [ + ".*_hip_roll", + ".*_hip_yaw", + ".*_hip_pitch", + ".*_knee", + ".*_arm_shoulder_.*", + ".*_arm_elbow", +] +_MIN_STABLE_ARMATURE = 0.10 + DIGIT_V4_CFG = ArticulationCfg( spawn=sim_utils.UsdFileCfg( usd_path=f"{ISAAC_NUCLEUS_DIR}/Robots/Agility/Digit/digit_v4.usd", activate_contact_sensors=True, + # digit_v4.usd applies CollisionAPI to 32 prims, every one a decoration mesh on a + # RealSense camera mount -- glass, USB-C, case halves. They are 32 of the robot's 55 + # collision shapes while the arms, hips and rods carry none, and produced 3e7 N contact + # forces on bodies 1.4 m above the ground. Colliders on a camera's glass are an authoring + # error on either backend. + collision_props={"/.*camera_mount/.*/Visual/.*": [UsdPhysicsCollisionCfg(collision_enabled=False)]}, ), init_state=ArticulationCfg.InitialStateCfg( pos=(0.0, 0.0, 1.05), ), soft_joint_pos_limit_factor=0.9, actuators={ - "all": ImplicitActuatorCfg( - joint_names_expr=".*", + "legs_arms": ImplicitActuatorCfg( + joint_names_expr=_STABLE_ARMATURE_JOINT_NAMES, + stiffness=None, + damping=None, + ), + "low_armature": ImplicitActuatorCfg( + joint_names_expr=_LOW_ARMATURE_JOINT_NAMES, stiffness=None, damping=None, + armature=_MIN_STABLE_ARMATURE, ), }, ) diff --git a/source/isaaclab_newton/changelog.d/hehu-newton-ball-joint-dof-space.rst b/source/isaaclab_newton/changelog.d/hehu-newton-ball-joint-dof-space.rst new file mode 100644 index 000000000000..c9190865df83 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/hehu-newton-ball-joint-dof-space.rst @@ -0,0 +1,18 @@ +Fixed +^^^^^ + +* Fixed :attr:`~isaaclab_newton.assets.articulation.articulation_data.ArticulationData.joint_pos` + exposing Newton's ``joint_q`` -- joint *coordinate* space -- rather than DOF positions. A ball + joint occupies 4 quaternion components against 3 DOFs, so on an articulation containing one the + array was wider than ``num_joints`` while ``joint_names``, ``joint_vel``, ``default_joint_pos`` + and the joint gains stayed in DOF space, and every consumer indexing them with the same joint ids + read a different joint past the first ball joint. Added + :class:`~isaaclab_newton.assets.articulation.joint_coordinates.JointCoordinateMap`, which converts + between the two spaces on read and on write. Articulations whose joints all have one coordinate + per DOF keep the existing zero-copy view and are unaffected. + +* Fixed joint position targets being written in DOF space into Newton's coordinate-layout + ``joint_target_q``. That array follows ``newton.use_coord_layout_targets``, which defaults to + ``True`` from Newton 1.6, so on a ball-jointed articulation the actuators were writing 50 DOF + targets into a 56-wide coordinate array. Targets now go through a DOF-shaped staging buffer and + the same coordinate map. diff --git a/source/isaaclab_newton/isaaclab_newton/assets/articulation/actuator_control.py b/source/isaaclab_newton/isaaclab_newton/assets/articulation/actuator_control.py index 9921ca150915..f69b6ad35068 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/articulation/actuator_control.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/actuator_control.py @@ -22,6 +22,7 @@ from isaaclab.assets.articulation import ordering_kernels from isaaclab.sim.schemas.schemas_actuators import _validate_newton_native_actuator_cfgs +from isaaclab_newton.assets.articulation.joint_coordinates import scatter_joint_coordinates from isaaclab_newton.physics import NewtonManager as SimulationManager if TYPE_CHECKING: @@ -93,6 +94,9 @@ def finalize_native_actuators(self, collection: ActuatorCollection) -> NewtonAct ) def _post_actuator() -> None: + # Telemetry reads _sim_bind_joint_pos inside the decimation loop, ahead of the + # post-step gather, so the DOF-space view is re-derived here. + articulation._data._gather_joint_coordinates() wp.launch( actuator_kernels.sync_torque_telemetry, dim=(self.num_instances, self.num_joints), @@ -134,7 +138,9 @@ def compute_native_actuators(self, collection: ActuatorCollection, dt: float) -> return self._native_actuator_path_active def submit_commands(self, collection: ActuatorCollection) -> None: + """Publish the collection's targets to the backend arrays.""" articulation = self._articulation + data = articulation.data if self._native_actuator_path_active: # Newton consumes raw explicit-actuator targets through joint_act. user_effort = collection._joint_effort_target @@ -143,12 +149,6 @@ def submit_commands(self, collection: ActuatorCollection) -> None: write_pos_target = True write_vel_target = True write_joint_act = True - if not articulation.data.has_joint_ordering: - articulation.data._sim_bind_joint_position_target.assign(collection._joint_pos_target) - articulation.data._sim_bind_joint_velocity_target.assign(collection._joint_vel_target) - articulation.data._sim_bind_joint_act.assign(collection._joint_effort_target) - articulation.data._sim_bind_joint_effort.assign(collection._joint_effort_target) - return else: # Lab executors publish processed targets; only implicit joints use # the backend position and velocity drives. @@ -158,28 +158,43 @@ def submit_commands(self, collection: ActuatorCollection) -> None: write_pos_target = collection.has_implicit_actuators write_vel_target = collection.has_implicit_actuators write_joint_act = False - if not articulation.data.has_joint_ordering: - articulation.data._sim_bind_joint_effort.assign(collection._joint_effort_target_sim) - if collection.has_implicit_actuators: - articulation.data._sim_bind_joint_position_target.assign(collection._joint_pos_target_sim) - articulation.data._sim_bind_joint_velocity_target.assign(collection._joint_vel_target_sim) - return - - ordering_kernels.launch_reorder_joint_targets_user_to_backend( - user_effort=user_effort, - user_pos_target=user_pos_target, - user_vel_target=user_vel_target, - backend_to_user=articulation._joint_backend_to_user_map(), - write_effort=True, - write_pos_target=write_pos_target, - write_vel_target=write_vel_target, - write_joint_act=write_joint_act, - backend_effort=articulation.data._sim_bind_joint_effort, - backend_pos_target=articulation.data._sim_bind_joint_position_target, - backend_vel_target=articulation.data._sim_bind_joint_velocity_target, - backend_joint_act=articulation.data._sim_bind_joint_act, - device=self.device, - ) + + if data.has_joint_ordering: + ordering_kernels.launch_reorder_joint_targets_user_to_backend( + user_effort=user_effort, + user_pos_target=user_pos_target, + user_vel_target=user_vel_target, + backend_to_user=articulation._joint_backend_to_user_map(), + write_effort=True, + write_pos_target=write_pos_target, + write_vel_target=write_vel_target, + write_joint_act=write_joint_act, + backend_effort=data._sim_bind_joint_effort, + backend_pos_target=data._sim_bind_joint_position_target, + backend_vel_target=data._sim_bind_joint_velocity_target, + backend_joint_act=data._sim_bind_joint_act, + device=self.device, + ) + else: + data._sim_bind_joint_effort.assign(user_effort) + if write_pos_target: + data._sim_bind_joint_position_target.assign(user_pos_target) + if write_vel_target: + data._sim_bind_joint_velocity_target.assign(user_vel_target) + if write_joint_act: + data._sim_bind_joint_act.assign(user_effort) + + # Newton takes coordinate-layout position targets from 1.6 on; every branch above writes + # DOF-indexed targets, so the staging buffer has to be scattered across unconditionally. + # Sequenced after the writes above (not a try/finally) so an exception mid-write leaves the + # staging buffer unflushed rather than scattering a half-written buffer into joint_target_q. + if articulation.data._joint_targets_need_conversion: + scatter_joint_coordinates( + articulation.data._joint_coord_map, + articulation.data._sim_bind_joint_position_target, + articulation.data._sim_bind_joint_target_coords, + articulation._ALL_ENV_MASK, + ) def reset_native_actuators(self, env_ids: Sequence[int] | slice) -> None: if self._native_actuator_path_active and SimulationManager._adapter is not None: diff --git a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py index 53b23d09ee06..84d107b7e347 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation.py @@ -12,7 +12,7 @@ import re import warnings from collections.abc import Sequence -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import numpy as np import torch @@ -36,6 +36,7 @@ from isaaclab_newton.assets import kernels as shared_kernels from isaaclab_newton.assets.articulation import kernels as articulation_kernels +from isaaclab_newton.assets.articulation.joint_coordinates import scatter_joint_coordinates from isaaclab_newton.physics import NewtonManager as SimulationManager from .actuator_control import NewtonActuatorControl @@ -1200,6 +1201,14 @@ def write_joint_state_to_sim_index( ], device=self.device, ) + # The write landed in DOF space; push it back into Newton's joint coordinates. + if self.data._joint_coord_map.required: + scatter_joint_coordinates( + self.data._joint_coord_map, + self.data._sim_bind_joint_pos, + self.data._sim_bind_joint_coords, + self._env_ids_to_mask(env_ids), + ) # Let the data class handle the invalidation of the pose and velocity related properties. if not skip_forward: self.data._reset_pose(env_ids=env_ids) @@ -1266,6 +1275,14 @@ def write_joint_state_to_sim_mask( ], device=self.device, ) + # The write landed in DOF space; push it back into Newton's joint coordinates. + if self.data._joint_coord_map.required: + scatter_joint_coordinates( + self.data._joint_coord_map, + self.data._sim_bind_joint_pos, + self.data._sim_bind_joint_coords, + env_mask, + ) # Let the data class handle the invalidation of the pose and velocity related properties. if not skip_forward: self.data._reset_pose(env_mask=env_mask) @@ -1319,6 +1336,14 @@ def write_joint_position_to_sim_index( self.data._sim_bind_joint_pos, device=self.device, ) + # The write landed in DOF space; push it back into Newton's joint coordinates. + if self.data._joint_coord_map.required: + scatter_joint_coordinates( + self.data._joint_coord_map, + self.data._sim_bind_joint_pos, + self.data._sim_bind_joint_coords, + self._env_ids_to_mask(env_ids), + ) # Let the data class handle the invalidation of pose- and velocity-dependent properties. if not skip_forward: self.data._reset_pose(env_ids=env_ids) @@ -1369,6 +1394,14 @@ def write_joint_position_to_sim_mask( self.data._sim_bind_joint_pos, device=self.device, ) + # The write landed in DOF space; push it back into Newton's joint coordinates. + if self.data._joint_coord_map.required: + scatter_joint_coordinates( + self.data._joint_coord_map, + self.data._sim_bind_joint_pos, + self.data._sim_bind_joint_coords, + env_mask, + ) # Let the data class handle the invalidation of pose- and velocity-dependent properties. if not skip_forward: self.data._reset_pose(env_mask=env_mask) @@ -2249,14 +2282,24 @@ def write_joint_viscous_friction_coefficient_to_sim_mask( @staticmethod @wp.kernel(enable_backward=False) - def _build_env_mask_kernel(mask: wp.array(dtype=wp.bool), indices: wp.array(dtype=wp.int32)): + def _build_env_mask_kernel(mask: wp.array(dtype=wp.bool), indices: wp.array(dtype=Any)): i = wp.tid() - mask[indices[i]] = True + mask[wp.int32(indices[i])] = True - def _env_ids_to_mask(self, env_ids: wp.array) -> wp.array: - """Convert warp env_ids to a boolean Warp mask.""" + def _env_ids_to_mask(self, env_ids: wp.array | torch.Tensor) -> wp.array: + """Convert env_ids to a boolean Warp mask. + + Args: + env_ids: Environment indices as returned by :meth:`_resolve_env_ids`, which may be a + warp array or a torch tensor of any integer width. + + Returns: + A per-environment boolean mask. + """ if env_ids is self._ALL_INDICES: return self._ALL_ENV_MASK + if isinstance(env_ids, torch.Tensor): + env_ids = wp.from_torch(env_ids) mask = wp.zeros(self.num_instances, dtype=wp.bool, device=self.device) wp.launch(self._build_env_mask_kernel, dim=env_ids.shape[0], inputs=[mask, env_ids], device=self.device) return mask @@ -3444,13 +3487,16 @@ def _create_buffers(self): ) # Republish the Tier-1 backend->user state shadows inside the stepped # (and captured) region after the last solver substep. Registering only - # when ordering is non-identity keeps identity-ordering scenes at zero + # when ordering is non-identity or a ball joint needs the coordinate + # gather keeps a plain identity-ordering, non-ball-joint scene at zero # overhead (empty callback list). The reorders are then recorded into # every captured graph, so passthrough state getters never replay stale. # The stored handle is the exact bound method ``_clear_callbacks`` later # deregisters. + # A ball-jointed articulation also needs the slot: its DOF-space joint_pos is derived, not + # sim-bound, so it has to be republished after every step just like the ordering shadows. self._post_step_callback = None - if self.data.has_joint_ordering or self.data.has_body_ordering: + if self.data.has_joint_ordering or self.data.has_body_ordering or self.data._joint_coord_map.required: self._post_step_callback = self._data._refresh_user_order_state SimulationManager.register_post_step_callback(self._post_step_callback) # tendon names are set in _process_tendons function diff --git a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py index 04a29718fc3e..b17e37f3c782 100644 --- a/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/articulation_data.py @@ -22,6 +22,10 @@ from isaaclab_newton.assets import kernels as shared_kernels from isaaclab_newton.assets.articulation import kernels as articulation_kernels +from isaaclab_newton.assets.articulation.joint_coordinates import ( + build_ball_joint_coordinate_map, + gather_joint_coordinates, +) from isaaclab_newton.physics import NewtonManager as SimulationManager if TYPE_CHECKING: @@ -1591,7 +1595,23 @@ def _create_simulation_bindings(self) -> None: "joint_effort_limit", SimulationManager.get_model() )[:, 0] # -- joint states - self._sim_bind_joint_pos = self._root_view.get_dof_positions(SimulationManager.get_state_0())[:, 0] + # ``get_dof_positions`` returns Newton's ``joint_q``, which is *coordinate* space: a ball + # joint occupies 4 quaternion components against 3 DOFs, so the array is wider than + # ``num_joints`` whenever the articulation has one. IsaacLab addresses joints by DOF + # index everywhere, so keep the coordinate array separate and publish a DOF-space view. + self._sim_bind_joint_coords = self._root_view.get_dof_positions(SimulationManager.get_state_0())[:, 0] + # The view's per-joint counts are already in the column order of the array above and + # already exclude the free root, fixed joints and loop-closing joints. + self._joint_coord_map = build_ball_joint_coordinate_map( + self._root_view.joint_coord_counts, self._root_view.joint_dof_counts, self.device + ) + if self._joint_coord_map.required: + self._sim_bind_joint_pos = wp.zeros( + (self._num_instances, self._num_joints), dtype=wp.float32, device=self.device + ) + gather_joint_coordinates(self._joint_coord_map, self._sim_bind_joint_coords, self._sim_bind_joint_pos) + else: + self._sim_bind_joint_pos = self._sim_bind_joint_coords self._sim_bind_joint_vel = self._root_view.get_dof_velocities(SimulationManager.get_state_0())[:, 0] # -- joint commands (sent to the simulation) self._sim_bind_joint_effort = self._root_view.get_attribute("joint_f", SimulationManager.get_control())[ @@ -1601,6 +1621,19 @@ def _create_simulation_bindings(self) -> None: self._sim_bind_joint_position_target = self._root_view.get_attribute( "joint_target_q", SimulationManager.get_control() )[:, 0] + # ``joint_target_q`` follows ``newton.use_coord_layout_targets``, which defaults to + # True from Newton 1.6. Under that layout the array is coordinate-shaped, exactly like + # ``joint_q``, so actuators keep writing DOF-indexed targets into a staging buffer and + # the same map scatters them across. + self._sim_bind_joint_target_coords = self._sim_bind_joint_position_target + self._joint_targets_need_conversion = self._sim_bind_joint_target_coords.shape[1] != self._num_joints + if self._joint_targets_need_conversion: + self._sim_bind_joint_position_target = wp.zeros( + (self._num_instances, self._num_joints), dtype=wp.float32, device=self.device + ) + gather_joint_coordinates( + self._joint_coord_map, self._sim_bind_joint_target_coords, self._sim_bind_joint_position_target + ) self._sim_bind_joint_velocity_target = self._root_view.get_attribute( "joint_target_qd", SimulationManager.get_control() )[:, 0] @@ -1629,13 +1662,20 @@ def _create_simulation_bindings(self) -> None: self._sim_bind_joint_effort_limits_sim = wp.zeros( (self._num_instances, 0), dtype=wp.float32, device=self.device ) - self._sim_bind_joint_pos = wp.zeros((self._num_instances, 0), dtype=wp.float32, device=self.device) + # No joints: the view reports no counts, so the map has nothing to convert. + self._sim_bind_joint_coords = self._sim_bind_joint_pos = wp.zeros( + (self._num_instances, 0), dtype=wp.float32, device=self.device + ) + self._joint_coord_map = build_ball_joint_coordinate_map( + self._root_view.joint_coord_counts, self._root_view.joint_dof_counts, self.device + ) self._sim_bind_joint_vel = wp.zeros((self._num_instances, 0), dtype=wp.float32, device=self.device) self._sim_bind_joint_effort = wp.zeros((self._num_instances, 0), dtype=wp.float32, device=self.device) self._sim_bind_joint_act = wp.zeros((self._num_instances, 0), dtype=wp.float32, device=self.device) self._sim_bind_joint_position_target = wp.zeros( (self._num_instances, 0), dtype=wp.float32, device=self.device ) + self._joint_targets_need_conversion = False self._sim_bind_joint_velocity_target = wp.zeros( (self._num_instances, 0), dtype=wp.float32, device=self.device ) @@ -2157,8 +2197,18 @@ def _refresh_user_order_body_state(self) -> None: outputs=[self._body_link_pose_w_user, self._body_com_vel_w_user], ) + def _gather_joint_coordinates(self) -> None: + """Re-derive the DOF-space joint positions from Newton's coordinate array. + + A no-op unless the articulation has a ball joint; without one ``_sim_bind_joint_pos`` is a + zero-copy view onto ``joint_q`` and is always current. + """ + if self._joint_coord_map.required: + gather_joint_coordinates(self._joint_coord_map, self._sim_bind_joint_coords, self._sim_bind_joint_pos) + def _refresh_user_order_state(self) -> None: - """Republish all Tier-1 user-order state shadows from live backend state. + """Republish all Tier-1 user-order state shadows from live backend state, and gather any + ball-joint DOF positions ahead of them. Registered as a post-step callback (see :meth:`isaaclab_newton.physics.NewtonManager.register_post_step_callback`) @@ -2166,8 +2216,12 @@ def _refresh_user_order_state(self) -> None: the last solver substep. With no Python freshness guard the launches are recorded into every captured graph and replayed on each tick, so the passthrough ``joint_pos`` / ``joint_vel`` / ``body_link_pose_w`` / - ``body_com_vel_w`` shadows behave exactly like sim-bound memory. + ``body_com_vel_w`` shadows behave exactly like sim-bound memory. For an + identity-ordered ball-joint articulation the coordinate gather is the only + work this method does -- the two reorder calls below are no-ops. """ + # Ahead of the reorder: the user-order shadows have to gather from a current DOF buffer. + self._gather_joint_coordinates() self._refresh_user_order_joint_state() self._refresh_user_order_body_state() diff --git a/source/isaaclab_newton/isaaclab_newton/assets/articulation/joint_coordinates.py b/source/isaaclab_newton/isaaclab_newton/assets/articulation/joint_coordinates.py new file mode 100644 index 000000000000..cb7f041217bb --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/assets/articulation/joint_coordinates.py @@ -0,0 +1,232 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Conversion between Newton's joint coordinate space and Isaac Lab's DOF space. + +Newton stores a ball joint as a 4-component unit quaternion against 3 DOFs, so an articulation +containing one has more joint coordinates than DOFs. Free and distance joints have the same kind +of mismatch, but Isaac Lab's view excludes free joints and this module rejects distance joints, so +ball is the only mismatched layout this map converts. Every other joint type has one coordinate +per DOF, so the two spaces coincide for most assets and :func:`build_ball_joint_coordinate_map` +reports ``required = False`` for them. + +Isaac Lab addresses joints by DOF index throughout -- ``joint_names``, ``find_joints``, +``SceneEntityCfg.joint_ids`` -- so joint positions have to be exposed in DOF space to stay +consistent with ``joint_vel``, ``default_joint_pos`` and the joint gains, all of which already are. + +The rotation vector is the representation consistent with ``joint_qd``, which holds angular velocity +in the joint frame. PhysX also reports spherical-joint positions as an axis-angle vector projected +onto the DOF axes; sign and frame parity with PhysX has not been verified numerically. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import warp as wp + +_BALL_LAYOUT = (4, 3) +"""``(coordinates, DOFs)`` of a ball joint -- the only mismatched layout this map converts; free +joints are excluded from Lab's view, and distance joints (7 against 6) are rejected.""" + + +@wp.kernel(enable_backward=False) +def gather_single_coord_dofs( + coords: wp.array2d(dtype=wp.float32), + dof_index: wp.array(dtype=wp.int32), + coord_index: wp.array(dtype=wp.int32), + dofs: wp.array2d(dtype=wp.float32), +): + """Copy joints whose coordinate count equals their DOF count.""" + env, i = wp.tid() + dofs[env, dof_index[i]] = coords[env, coord_index[i]] + + +@wp.kernel(enable_backward=False) +def gather_ball_dofs( + coords: wp.array2d(dtype=wp.float32), + dof_index: wp.array(dtype=wp.int32), + coord_index: wp.array(dtype=wp.int32), + dofs: wp.array2d(dtype=wp.float32), +): + """Quaternion -> rotation vector for each ball joint. + + ``wp.quat_to_axis_angle`` resolves the double cover itself -- it flips the axis with the sign of + ``w`` and returns an angle in ``[0, pi]`` -- so a fixed pose always decodes to the same vector. + """ + env, i = wp.tid() + c = coord_index[i] + d = dof_index[i] + axis, angle = wp.quat_to_axis_angle( + wp.quat(coords[env, c + 0], coords[env, c + 1], coords[env, c + 2], coords[env, c + 3]) + ) + rotvec = axis * angle + dofs[env, d + 0] = rotvec[0] + dofs[env, d + 1] = rotvec[1] + dofs[env, d + 2] = rotvec[2] + + +@wp.kernel(enable_backward=False) +def scatter_single_coord_dofs( + dofs: wp.array2d(dtype=wp.float32), + env_mask: wp.array(dtype=wp.bool), + dof_index: wp.array(dtype=wp.int32), + coord_index: wp.array(dtype=wp.int32), + coords: wp.array2d(dtype=wp.float32), +): + """Inverse of :func:`gather_single_coord_dofs`, for the selected environments.""" + env, i = wp.tid() + if env_mask[env]: + coords[env, coord_index[i]] = dofs[env, dof_index[i]] + + +@wp.kernel(enable_backward=False) +def scatter_ball_dofs( + dofs: wp.array2d(dtype=wp.float32), + env_mask: wp.array(dtype=wp.bool), + dof_index: wp.array(dtype=wp.int32), + coord_index: wp.array(dtype=wp.int32), + coords: wp.array2d(dtype=wp.float32), +): + """Rotation vector -> quaternion for each ball joint, for the selected environments.""" + env, i = wp.tid() + if not env_mask[env]: + return + c = coord_index[i] + d = dof_index[i] + rotvec = wp.vec3(dofs[env, d + 0], dofs[env, d + 1], dofs[env, d + 2]) + angle = wp.length(rotvec) + q = wp.quat_identity() + if angle > 1.0e-9: + q = wp.quat_from_axis_angle(rotvec / angle, angle) + coords[env, c + 0] = q[0] + coords[env, c + 1] = q[1] + coords[env, c + 2] = q[2] + coords[env, c + 3] = q[3] + + +@dataclass(frozen=True) +class BallJointCoordinateMap: + """Index tables mapping an articulation view's ball-joint coordinates to their DOFs and back. + + A plain data record, not an object with behavior -- :func:`build_ball_joint_coordinate_map` + produces one, and :func:`gather_joint_coordinates` / :func:`scatter_joint_coordinates` consume + one. Fields are only meaningful when ``required`` is True; the ``as_wp`` conversion in + :func:`build_ball_joint_coordinate_map` is skipped otherwise, so on a required=False map the + remaining fields are placeholder empty arrays. The frozen dataclass prevents field + reassignment, but the Warp arrays remain mutable objects. + """ + + required: bool + """Whether the articulation has a ball joint and the remaining fields are populated.""" + + single_dof: wp.array + """DOF indices of joints whose coordinate count equals their DOF count, dtype ``wp.int32``.""" + + single_coord: wp.array + """Matching coordinate indices for :attr:`single_dof`, dtype ``wp.int32``.""" + + ball_dof: wp.array + """First DOF index of each ball joint, dtype ``wp.int32``.""" + + ball_coord: wp.array + """First coordinate index of each ball joint, dtype ``wp.int32``.""" + + +def build_ball_joint_coordinate_map(coord_counts: list[int], dof_counts: list[int], device) -> BallJointCoordinateMap: + """Build the index tables mapping an articulation view's joint coordinates to its DOFs and back. + + Built from the view's own per-joint counts, which are in the column order of + :meth:`~newton.ArticulationView.get_dof_positions` and already exclude the free root joint, + fixed joints and loop-closing joints. + + Args: + coord_counts: Coordinates per selected joint (``ArticulationView.joint_coord_counts``). + dof_counts: DOFs per selected joint (``ArticulationView.joint_dof_counts``). + device: Device to allocate the index tables on. + + Returns: + The index tables, with ``required`` set when the articulation has a ball joint. + + Raises: + NotImplementedError: If a joint's coordinate and DOF counts differ in any way other than the + ball-joint layout, which would otherwise be silently decoded as a quaternion. + """ + single_dof: list[int] = [] + single_coord: list[int] = [] + ball_dof: list[int] = [] + ball_coord: list[int] = [] + + coord, dof = 0, 0 + for n_coords, n_dofs in zip(coord_counts, dof_counts, strict=True): + if n_coords == n_dofs: + single_dof.extend(range(dof, dof + n_dofs)) + single_coord.extend(range(coord, coord + n_coords)) + elif (n_coords, n_dofs) == _BALL_LAYOUT: + ball_dof.append(dof) + ball_coord.append(coord) + else: + raise NotImplementedError( + f"Joint with {n_coords} coordinates against {n_dofs} DOFs has no coordinate" + " conversion; only ball joints (4 against 3) are supported." + ) + coord += n_coords + dof += n_dofs + + required = bool(ball_dof) + if not required: + empty = wp.array([], dtype=wp.int32, device=device) + return BallJointCoordinateMap(False, empty, empty, empty, empty) + as_wp = lambda values: wp.array(values, dtype=wp.int32, device=device) # noqa: E731 + return BallJointCoordinateMap(True, as_wp(single_dof), as_wp(single_coord), as_wp(ball_dof), as_wp(ball_coord)) + + +def gather_joint_coordinates(coord_map: BallJointCoordinateMap, coords: wp.array, dofs: wp.array) -> None: + """Write the DOF-space view of ``coords`` into ``dofs``. + + Args: + coord_map: Index tables from :func:`build_ball_joint_coordinate_map`. + coords: Newton's joint coordinate array for this view. + dofs: DOF-space destination [rad or m, depending on joint type]. + """ + num_envs = coords.shape[0] + for kernel, dof_index, coord_index in ( + (gather_single_coord_dofs, coord_map.single_dof, coord_map.single_coord), + (gather_ball_dofs, coord_map.ball_dof, coord_map.ball_coord), + ): + wp.launch( + kernel, + dim=(num_envs, dof_index.shape[0]), + inputs=[coords, dof_index, coord_index, dofs], + device=coords.device, + ) + + +def scatter_joint_coordinates( + coord_map: BallJointCoordinateMap, dofs: wp.array, coords: wp.array, env_mask: wp.array +) -> None: + """Write ``dofs`` back into ``coords`` for the selected environments. + + Scoping this to the written environments matters: resets are staggered, and ``dofs`` for + the other environments only holds the last post-step gather. An all-environment scatter + would overwrite their live ``joint_q`` with an exp(log(q)) round trip of itself, perturbing + state that was never written. + + Args: + coord_map: Index tables from :func:`build_ball_joint_coordinate_map`. + dofs: DOF-space joint positions [rad or m, depending on joint type]. + coords: Newton's joint coordinate array to write into. + env_mask: Per-environment boolean selection of the environments that were written. + """ + for kernel, dof_index, coord_index in ( + (scatter_single_coord_dofs, coord_map.single_dof, coord_map.single_coord), + (scatter_ball_dofs, coord_map.ball_dof, coord_map.ball_coord), + ): + wp.launch( + kernel, + dim=(env_mask.shape[0], dof_index.shape[0]), + inputs=[dofs, env_mask, dof_index, coord_index, coords], + device=coords.device, + ) diff --git a/source/isaaclab_newton/isaaclab_newton/test/fixtures/views/mock_articulation_view.py b/source/isaaclab_newton/isaaclab_newton/test/fixtures/views/mock_articulation_view.py index 3820642a0422..7300f3f2175b 100644 --- a/source/isaaclab_newton/isaaclab_newton/test/fixtures/views/mock_articulation_view.py +++ b/source/isaaclab_newton/isaaclab_newton/test/fixtures/views/mock_articulation_view.py @@ -240,6 +240,7 @@ def __init__( joint_names: list[str] | None = None, body_names: list[str] | None = None, tendon_names: list[str] | None = None, + joint_coord_counts: list[int] | None = None, ): """Initialize the mock Newton articulation view. @@ -253,6 +254,10 @@ def __init__( joint_names: Names of joints. Defaults to ["joint_0", ...]. body_names: Names of bodies. Defaults to ["body_0", ...]. tendon_names: Names of fixed tendons. Defaults to ["tendon_0", ...]. + joint_coord_counts: Coordinate count per selected joint, e.g. ``[1, 4, 1]`` for a + three-joint articulation with a ball joint in the middle. Must sum with its + matching per-joint DOF counts (inferred as 3 in place of any ``4``, 1 otherwise) + to ``num_joints`` DOFs. Defaults to one coordinate per DOF -- no ball joints. """ self._count = num_instances self._link_count = num_bodies @@ -261,6 +266,10 @@ def __init__( self._device = device self._is_fixed_base = is_fixed_base self._noop_setters = False + self._joint_coord_counts_override = joint_coord_counts + # Real Newton's ``get_dof_positions`` returns ``joint_q`` (coordinate space), wider than the + # DOF count whenever a ball joint is present; fake that width here too. + self._joint_coord_count_total = sum(joint_coord_counts) if joint_coord_counts is not None else num_joints # Set joint and body names self._joint_dof_names = joint_names if joint_names is not None else [f"joint_{i}" for i in range(num_joints)] @@ -320,6 +329,20 @@ def joint_dof_count(self) -> int: """Number of DOFs (joints) per instance.""" return self._joint_dof_count + @property + def joint_dof_counts(self) -> list[int]: + """DOF count per selected joint. One DOF per joint unless a ball layout was requested.""" + if self._joint_coord_counts_override is None: + return [1] * self._joint_dof_count + return [3 if n_coords == 4 else 1 for n_coords in self._joint_coord_counts_override] + + @property + def joint_coord_counts(self) -> list[int]: + """Coordinate count per selected joint. Equal to :attr:`joint_dof_counts` unless a ball layout was requested.""" + if self._joint_coord_counts_override is None: + return [1] * self._joint_dof_count + return list(self._joint_coord_counts_override) + @property def is_fixed_base(self) -> bool: """Whether the articulation has a fixed base.""" @@ -396,10 +419,10 @@ def _ensure_link_velocities(self) -> wp.array | None: return self._link_velocities def _ensure_dof_positions(self) -> wp.array: - """Lazily create DOF positions.""" + """Lazily create DOF positions, in coordinate space (see ``_joint_coord_count_total``).""" if self._dof_positions is None: self._dof_positions = wp.zeros( - (self._count, 1, self._joint_dof_count), dtype=wp.float32, device=self._device + (self._count, 1, self._joint_coord_count_total), dtype=wp.float32, device=self._device ) return self._dof_positions @@ -719,8 +742,10 @@ def set_random_mock_data(self) -> None: link_vel_np = np.random.randn(N, 1, L, 6).astype(np.float32) self._link_velocities = wp.array(link_vel_np, dtype=wp.spatial_vectorf, device=dev) - # DOF state - self._dof_positions = wp.array(np.random.randn(N, 1, J).astype(np.float32), dtype=wp.float32, device=dev) + # DOF state -- positions are coordinate-space width (see ``_joint_coord_count_total``), + # velocities are always DOF-space width. + C = self._joint_coord_count_total + self._dof_positions = wp.array(np.random.randn(N, 1, C).astype(np.float32), dtype=wp.float32, device=dev) self._dof_velocities = wp.array(np.random.randn(N, 1, J).astype(np.float32), dtype=wp.float32, device=dev) # Body properties diff --git a/source/isaaclab_newton/test/assets/test_joint_coordinates.py b/source/isaaclab_newton/test/assets/test_joint_coordinates.py new file mode 100644 index 000000000000..216ca4b7c5ee --- /dev/null +++ b/source/isaaclab_newton/test/assets/test_joint_coordinates.py @@ -0,0 +1,210 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Kitless tests for the Newton joint coordinate/DOF conversion.""" + +from unittest.mock import MagicMock + +import numpy as np +import pytest +import warp as wp +from isaaclab_newton.assets.articulation.joint_coordinates import ( + BallJointCoordinateMap, + build_ball_joint_coordinate_map, + gather_joint_coordinates, + scatter_joint_coordinates, +) +from scipy.spatial.transform import Rotation + +# One revolute, one ball, one revolute -- the layout that hides an off-by-one when the tables are +# built by walking the model instead of the view's own per-joint counts. +COORD_COUNTS = [1, 4, 1] +DOF_COUNTS = [1, 3, 1] + +# Two balls, straddling a revolute on each side -- the second ball's offsets (ball_coord[1] = 5, +# ball_dof[1] = 4) are not exercised by the single-ball layout above. +TWO_BALL_COORD_COUNTS = [1, 4, 4, 1] +TWO_BALL_DOF_COUNTS = [1, 3, 3, 1] + + +def _map() -> BallJointCoordinateMap: + return build_ball_joint_coordinate_map(COORD_COUNTS, DOF_COUNTS, "cpu") + + +def test_tables_cover_every_dof() -> None: + """Every DOF of every joint must be tabulated exactly once.""" + m = _map() + covered = list(m.single_dof.numpy()) + [b + k for b in m.ball_dof.numpy() for k in range(3)] + assert sorted(covered) == list(range(sum(DOF_COUNTS))) + assert sorted(list(m.single_coord.numpy()) + [b + k for b in m.ball_coord.numpy() for k in range(4)]) == list( + range(sum(COORD_COUNTS)) + ) + + +def test_two_ball_tables_cover_every_dof() -> None: + """A second ball joint's offsets are not a simple repeat of the first's.""" + m = build_ball_joint_coordinate_map(TWO_BALL_COORD_COUNTS, TWO_BALL_DOF_COUNTS, "cpu") + assert list(m.ball_dof.numpy()) == [1, 4] + assert list(m.ball_coord.numpy()) == [1, 5] + covered = list(m.single_dof.numpy()) + [b + k for b in m.ball_dof.numpy() for k in range(3)] + assert sorted(covered) == list(range(sum(TWO_BALL_DOF_COUNTS))) + assert sorted(list(m.single_coord.numpy()) + [b + k for b in m.ball_coord.numpy() for k in range(4)]) == list( + range(sum(TWO_BALL_COORD_COUNTS)) + ) + + +def test_zero_rotation_vector_scatters_to_identity_quaternion() -> None: + """The ``angle > 1e-9`` guard's else-branch -- taken on every reset of a passive ball joint at + ``default_joint_pos = 0`` -- must produce the identity quaternion, not ``0/0``.""" + m = _map() + n_dofs, n_coords = sum(DOF_COUNTS), sum(COORD_COUNTS) + dofs = wp.zeros((1, n_dofs), dtype=wp.float32, device="cpu") + coords = wp.zeros((1, n_coords), dtype=wp.float32, device="cpu") + mask = wp.array(np.array([True]), dtype=wp.bool, device="cpu") + + scatter_joint_coordinates(m, dofs, coords, mask) + + ball_coord = int(m.ball_coord.numpy()[0]) + np.testing.assert_array_equal(coords.numpy()[0, ball_coord : ball_coord + 4], [0.0, 0.0, 0.0, 1.0]) + + +@pytest.mark.parametrize("sign", [1.0, -1.0]) +def test_identity_quaternion_gathers_to_zero_rotation_vector(sign: float) -> None: + """The inverse of the guard above: the identity quaternion (either hemisphere) decodes to the + zero rotation vector, not a NaN from a degenerate axis normalization.""" + m = _map() + n_dofs, n_coords = sum(DOF_COUNTS), sum(COORD_COUNTS) + coords_np = np.zeros((1, n_coords), dtype=np.float32) + ball_coord = int(m.ball_coord.numpy()[0]) + coords_np[0, ball_coord : ball_coord + 4] = np.array([0.0, 0.0, 0.0, 1.0]) * sign + out = wp.zeros((1, n_dofs), dtype=wp.float32, device="cpu") + + gather_joint_coordinates(m, wp.array(coords_np, dtype=wp.float32, device="cpu"), out) + + ball_dof = int(m.ball_dof.numpy()[0]) + np.testing.assert_array_equal(out.numpy()[0, ball_dof : ball_dof + 3], [0.0, 0.0, 0.0]) + + +def test_joint_pos_is_dof_shaped_on_a_ball_jointed_mock() -> None: + """Regression for the original bug: :attr:`ArticulationData.joint_pos` must be DOF-shaped + even when the underlying view's ``joint_q`` is wider (a ball joint). This exercises + ``_create_simulation_bindings`` end to end, unlike the coordinate-table-only tests + above -- it fails on the pre-fix binding, which read ``get_dof_positions()`` (coordinate + space) straight into ``joint_pos`` with no conversion, so ``joint_pos.shape[1]`` would be + ``sum(TWO_BALL_COORD_COUNTS) == 10`` instead of ``sum(TWO_BALL_DOF_COUNTS) == 8``. + """ + import isaaclab_newton.assets.articulation.articulation_data as newton_data_module + from isaaclab_newton.assets.articulation.articulation_data import ArticulationData + from isaaclab_newton.test.fixtures.views import MockNewtonArticulationView + + num_instances = 2 + num_dofs = sum(TWO_BALL_DOF_COUNTS) + num_bodies = len(TWO_BALL_DOF_COUNTS) + 1 + + mock_view = MockNewtonArticulationView( + num_instances=num_instances, + num_bodies=num_bodies, + num_joints=num_dofs, + device="cpu", + is_fixed_base=True, + joint_coord_counts=TWO_BALL_COORD_COUNTS, + ) + mock_view.set_random_mock_data() + mock_view._noop_setters = True + + mock_model = MagicMock() + mock_model.world_count = num_instances + mock_model.gravity = wp.array( + np.tile(np.array([[0.0, 0.0, -9.81]], dtype=np.float32), (num_instances + 1, 1)), + dtype=wp.vec3f, + device="cpu", + ) + mock_model.articulation_count = num_instances + mock_model.max_joints_per_articulation = num_bodies + mock_model.max_dofs_per_articulation = num_dofs + mock_model.joint_dof_count = num_instances * num_dofs + mock_model.body_count = num_instances * num_bodies + + mock_manager = MagicMock() + mock_manager.get_model.return_value = mock_model + mock_manager.get_state_0.return_value = MagicMock() + mock_manager.get_state_1.return_value = MagicMock() + mock_manager.get_control.return_value = MagicMock() + + original_sim_manager = newton_data_module.SimulationManager + newton_data_module.SimulationManager = mock_manager + try: + data = ArticulationData(mock_view, "cpu") + finally: + newton_data_module.SimulationManager = original_sim_manager + + assert data.joint_pos.torch.shape[1] == num_dofs + + +def test_map_is_inert_without_ball_joints() -> None: + """An articulation whose joints all have one coordinate per DOF needs no conversion.""" + assert not build_ball_joint_coordinate_map([1, 1, 1], [1, 1, 1], "cpu").required + assert not build_ball_joint_coordinate_map([], [], "cpu").required + + +def test_unsupported_layout_is_rejected() -> None: + """A distance joint (7 coordinates, 6 DOFs) must not be decoded as a quaternion.""" + with pytest.raises(NotImplementedError, match="7 coordinates against 6 DOFs"): + build_ball_joint_coordinate_map([7], [6], "cpu") + + +@pytest.mark.parametrize("num_envs", [1, 2]) +def test_scatter_then_gather_round_trips(num_envs: int) -> None: + """DOF values survive a trip through coordinate space, at one environment and at two.""" + m = _map() + n_dofs, n_coords = sum(DOF_COUNTS), sum(COORD_COUNTS) + rng = np.random.default_rng(0) + dofs_np = rng.uniform(-0.7, 0.7, size=(num_envs, n_dofs)).astype(np.float32) + dofs = wp.array(dofs_np, dtype=wp.float32, device="cpu") + coords = wp.zeros((num_envs, n_coords), dtype=wp.float32, device="cpu") + mask = wp.array(np.ones(num_envs, dtype=bool), dtype=wp.bool, device="cpu") + + scatter_joint_coordinates(m, dofs, coords, mask) + # The quaternion the scatter wrote must match an independent exp map. + ball_coord = int(m.ball_coord.numpy()[0]) + ball_dof = int(m.ball_dof.numpy()[0]) + for env in range(num_envs): + expected = Rotation.from_rotvec(dofs_np[env, ball_dof : ball_dof + 3]).as_quat() + np.testing.assert_allclose(coords.numpy()[env, ball_coord : ball_coord + 4], expected, atol=1e-6) + + out = wp.zeros((num_envs, n_dofs), dtype=wp.float32, device="cpu") + gather_joint_coordinates(m, coords, out) + np.testing.assert_allclose(out.numpy(), dofs_np, atol=1e-5) + + +def test_gather_is_invariant_to_quaternion_sign() -> None: + """``q`` and ``-q`` are the same rotation and must decode to the same rotation vector.""" + m = _map() + n_dofs, n_coords = sum(DOF_COUNTS), sum(COORD_COUNTS) + c = int(m.ball_coord.numpy()[0]) + base = np.zeros((1, n_coords), dtype=np.float32) + base[0, c : c + 4] = Rotation.from_rotvec([0.2, -0.5, 0.1]).as_quat() + + decoded = [] + for sign in (1.0, -1.0): + coords = base.copy() + coords[0, c : c + 4] *= sign + out = wp.zeros((1, n_dofs), dtype=wp.float32, device="cpu") + gather_joint_coordinates(m, wp.array(coords, dtype=wp.float32, device="cpu"), out) + decoded.append(out.numpy().copy()) + np.testing.assert_allclose(decoded[0], decoded[1], atol=1e-6) + + +def test_scatter_only_touches_masked_environments() -> None: + """Resets are staggered, so an unmasked environment's coordinates must not move.""" + m = _map() + n_dofs, n_coords = sum(DOF_COUNTS), sum(COORD_COUNTS) + coords_np = np.full((2, n_coords), 0.25, dtype=np.float32) + coords = wp.array(coords_np, dtype=wp.float32, device="cpu") + dofs = wp.array(np.full((2, n_dofs), 0.3, dtype=np.float32), dtype=wp.float32, device="cpu") + + scatter_joint_coordinates(m, dofs, coords, wp.array(np.array([True, False]), dtype=wp.bool, device="cpu")) + assert not np.allclose(coords.numpy()[0], coords_np[0]) + np.testing.assert_array_equal(coords.numpy()[1], coords_np[1]) diff --git a/source/isaaclab_tasks/changelog.d/hehu-digit-newton-velocity-tasks.rst b/source/isaaclab_tasks/changelog.d/hehu-digit-newton-velocity-tasks.rst new file mode 100644 index 000000000000..db6ccfdd69ae --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/hehu-digit-newton-velocity-tasks.rst @@ -0,0 +1,17 @@ +Added +^^^^^ + +* Added a ``newton_mjwarp`` branch to ``DigitPhysicsCfg``, so the Digit velocity tasks run on the + Newton backend with ``presets=newton_mjwarp``. Gated on that preset: ``self_collision_enabled`` + (the asset authors ``enabledSelfCollisions=False`` and ``DIGIT_V4_CFG`` sets no + ``articulation_props``, so Newton filters all 253 intra-articulation shape pairs). An armature + floor for the ten joints below MJWarp's observed stability threshold (expressed as a second + actuator group) and ``entropy_coef = 0.005`` apply on **both** backends, consistent with #7607's + direction of not special-casing PhysX for values that do not hurt it there. + +Fixed +^^^^^ + +* Fixed 32 ``CollisionAPI`` prims on Digit's RealSense camera decoration meshes -- glass, USB-C and + case halves -- becoming collision shapes. This applies on **both** backends, so the PhysX contact + behaviour of the Digit tasks changes: those 32 shapes no longer collide. diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/digit/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/digit/agents/rsl_rl_ppo_cfg.py index f22b239d7aab..fffbd52389c5 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/digit/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/digit/agents/rsl_rl_ppo_cfg.py @@ -30,7 +30,8 @@ class DigitRoughPPORunnerCfg(RslRlOnPolicyRunnerCfg): value_loss_coef=1.0, use_clipped_value_loss=True, clip_param=0.2, - entropy_coef=0.01, + # MJWarp needs a lower entropy floor than PhysX here (0.005 vs the usual 0.01) -- see #7520. + entropy_coef=0.005, num_learning_epochs=5, num_mini_batches=4, learning_rate=1.0e-3, diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/digit/rough_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/digit/rough_env_cfg.py index 5da30aab2947..c39dd646f0ea 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/digit/rough_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/velocity/config/digit/rough_env_cfg.py @@ -5,25 +5,34 @@ import math +from isaaclab_newton.sim.schemas import NewtonArticulationCfg from isaaclab_physx.physics import PhysxCfg -from isaaclab.actuators import ImplicitActuatorCfg -from isaaclab.managers import ObservationGroupCfg, ObservationTermCfg, RewardTermCfg, SceneEntityCfg, TerminationTermCfg +from isaaclab.managers import ( + ObservationGroupCfg, + ObservationTermCfg, + RewardTermCfg, + SceneEntityCfg, + TerminationTermCfg, +) from isaaclab.physics import PhysxAutoCfg from isaaclab.sim import SimulationCfg from isaaclab.utils import configclass from isaaclab.utils.noise import UniformNoiseCfg as Unoise import isaaclab_tasks.core.velocity.mdp as mdp -from isaaclab_tasks.core.velocity.velocity_env_cfg import LocomotionVelocityRoughEnvCfg -from isaaclab_tasks.utils import PresetCfg +from isaaclab_tasks.core.velocity.velocity_env_cfg import LocomotionVelocityRoughEnvCfg, RoughPhysicsCfg +from isaaclab_tasks.utils import PresetCfg, preset from isaaclab_assets.robots.agility import ARM_JOINT_NAMES, DIGIT_V4_CFG, LEG_JOINT_NAMES +_ROUGH_NEWTON_MJWARP = RoughPhysicsCfg().newton_mjwarp +"""Bound once so ``DigitPhysicsCfg`` does not construct ``RoughPhysicsCfg`` twice.""" + @configclass class DigitPhysicsCfg(PresetCfg): - """PhysX-only physics configuration for the Digit velocity environments.""" + """Physics configuration for the Digit velocity environments.""" isaacsim_physx = PhysxCfg( gpu_max_rigid_patch_count=10 * 2**15, @@ -31,6 +40,13 @@ class DigitPhysicsCfg(PresetCfg): gpu_total_aggregate_pairs_capacity=2**23, ) physx = PhysxAutoCfg(isaacsim_physx=isaacsim_physx) + # ``class_type`` is reset to ``None`` because ``NewtonCfg.__post_init__`` re-derives it from + # ``solver_cfg`` and refuses an explicit value -- ``replace()`` would otherwise carry the + # already-derived value from the source instance forward as one. + newton_mjwarp = _ROUGH_NEWTON_MJWARP.replace( + class_type=None, + solver_cfg=_ROUGH_NEWTON_MJWARP.solver_cfg.replace(njmax=5000, nconmax=2000), + ) default = isaacsim_physx @@ -244,14 +260,6 @@ def __post_init__(self): self.scene.contact_forces.history_length = self.decimation self.scene.contact_forces.update_period = self.sim.dt self.scene.height_scanner.update_period = self.decimation * self.sim.dt - # target only actuated joints explicitly — ".*" mis-indexes Digit's ball-joint DoFs - self.scene.robot.actuators = { - "legs_arms": ImplicitActuatorCfg( - joint_names_expr=LEG_JOINT_NAMES + ARM_JOINT_NAMES, - stiffness=None, - damping=None, - ), - } # commands self.commands.base_velocity.ranges.lin_vel_x = (-0.8, 0.8) self.commands.base_velocity.ranges.lin_vel_y = (-0.5, 0.5) @@ -262,4 +270,11 @@ def __post_init__(self): self.events.add_base_mass.params["asset_cfg"].body_names = "torso_base" self.events.base_external_force_torque.params["asset_cfg"].body_names = "torso_base" self.events.base_com.params["asset_cfg"].body_names = "torso_base" + # The asset authors enabledSelfCollisions=False and DIGIT_V4_CFG sets no + # articulation_props, so Newton filters every intra-articulation shape pair -- 253 of + # them, exactly C(23,2) for its 23 colliding shapes -- and the legs pass through each + # other. Which links carry colliders is left as the asset authored it. + self.scene.robot.spawn.articulation_props = preset( + default=None, newton_mjwarp=[NewtonArticulationCfg(self_collision_enabled=True)] + ) self.events.reset_robot_joints.params["position_range"] = (1.0, 1.0) From 13754a5a4bded9c9ecc3e8e39587056f096bab62 Mon Sep 17 00:00:00 2001 From: Kelly Guo Date: Thu, 17 Sep 2026 10:33:02 +0100 Subject: [PATCH 10/13] Align multitask config imports with configclass export --- .../contrib/multitask_manipulation/agents/rsl_rl_ppo_cfg.py | 2 +- .../contrib/multitask_manipulation/mdp/actions_cfg.py | 2 +- .../contrib/multitask_manipulation/mdp/commands_cfg.py | 2 +- .../contrib/multitask_manipulation/multitask_env_cfg.py | 2 +- .../contrib/multitask_manipulation/selection_utils.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/agents/rsl_rl_ppo_cfg.py index b0b931e324d8..918b9b387223 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/agents/rsl_rl_ppo_cfg.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -from isaaclab.utils.configclass import configclass +from isaaclab.utils import configclass from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/actions_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/actions_cfg.py index 66e231c0a8ee..2fe08e503db8 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/actions_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/actions_cfg.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING from isaaclab.managers import ActionTermCfg -from isaaclab.utils.configclass import configclass +from isaaclab.utils import configclass if TYPE_CHECKING: from .actions import SelectedBinaryJointPositionAction, SelectedJointPositionAction diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/commands_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/commands_cfg.py index a18ba66eb4fa..8a9598f7a0bd 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/commands_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/mdp/commands_cfg.py @@ -12,7 +12,7 @@ from isaaclab.managers import CommandTermCfg from isaaclab.markers import FRAME_MARKER_CFG, VisualizationMarkersCfg -from isaaclab.utils.configclass import configclass +from isaaclab.utils import configclass from ..selection_utils import SceneEntitySelectionCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/multitask_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/multitask_env_cfg.py index c1a1677c1c19..87139e8fe849 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/multitask_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/multitask_env_cfg.py @@ -21,7 +21,7 @@ from isaaclab.markers import FRAME_MARKER_CFG, SPHERE_MARKER_CFG, VisualizationMarkersCfg from isaaclab.scene import InteractiveSceneCfg from isaaclab.scene import add as add_scene -from isaaclab.utils.configclass import configclass +from isaaclab.utils import configclass from isaaclab.visualizers import VisualizerCfg from isaaclab_tasks.contrib.lift.config.openarm.joint_pos_env_cfg import OpenArmCubeLiftEnvCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/selection_utils.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/selection_utils.py index 432de01e0d4c..d9b7af533fd9 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/selection_utils.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/multitask_manipulation/selection_utils.py @@ -14,7 +14,7 @@ from isaaclab.cloner.path import match from isaaclab.managers.scene_entity_cfg import SceneEntityCfg -from isaaclab.utils.configclass import configclass +from isaaclab.utils import configclass if TYPE_CHECKING: from isaaclab.assets import BaseArticulation, BaseRigidObject, BaseRigidObjectCollection From cd295940c046be6710a5a44accf5fd9d8b57a5e6 Mon Sep 17 00:00:00 2001 From: Kelly Guo Date: Thu, 17 Sep 2026 10:35:57 +0100 Subject: [PATCH 11/13] Remove backported task preview from repository --- .../manipulation/multitask_manipulation.jpg | Bin 83192 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 docs/source/_static/tasks/manipulation/multitask_manipulation.jpg diff --git a/docs/source/_static/tasks/manipulation/multitask_manipulation.jpg b/docs/source/_static/tasks/manipulation/multitask_manipulation.jpg deleted file mode 100644 index 8c1a17192262f954b18ed3a347d92a83b0c393ea..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 83192 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 Date: Thu, 17 Sep 2026 16:05:07 +0200 Subject: [PATCH 12/13] Fix Franka Pour collider variant (#7866) # Description Pin the Franka Pour robot asset to its authored `Colliders=convex_hulls` variant. The primitive default omits the arm collision meshes (`link0_c` through `link7_c`) required by the task, preventing the current Newton MPM environment from starting correctly. This keeps the current 70% / 735-particle layout unchanged. ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Release backport - [x] Backport this pull request to the active release branch after it merges into `develop` ## Screenshots Not applicable. ## Validation - `uv run --extra test python -m pytest -q source/isaaclab_tasks/test/contrib/test_franka_pour_env_cfg.py` (13 passed) - `uv run isaaclab -f` - Local Newton GL playback with the published `model_6997.pt` checkpoint and one current-layout environment ## Checklist - [x] I have read and understood the contribution guidelines - [x] I have run the pre-commit checks - [x] Documentation changes are not required for this task configuration fix - [x] My changes generate no new warnings - [x] I have added a test that proves the asset variant contract - [x] I have added a changelog fragment for `isaaclab_tasks` - [x] My name already exists in `CONTRIBUTORS.md` (cherry picked from commit ad8b371d9828f74a19da9e0c54f529ebe1efa827) --- .../changelog.d/fix-franka-pour-convex-hulls.rst | 5 +++++ .../isaaclab_tasks/contrib/franka_pour/pour_env_cfg.py | 1 + .../isaaclab_tasks/test/contrib/test_franka_pour_env_cfg.py | 1 + 3 files changed, 7 insertions(+) create mode 100644 source/isaaclab_tasks/changelog.d/fix-franka-pour-convex-hulls.rst diff --git a/source/isaaclab_tasks/changelog.d/fix-franka-pour-convex-hulls.rst b/source/isaaclab_tasks/changelog.d/fix-franka-pour-convex-hulls.rst new file mode 100644 index 000000000000..3826c316199c --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/fix-franka-pour-convex-hulls.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed the Franka Pour task selecting the primitive robot collider variant, + which omitted the arm collision meshes required by the task. diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/franka_pour/pour_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/franka_pour/pour_env_cfg.py index 2662affebd7a..775aeb222ec8 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/franka_pour/pour_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/franka_pour/pour_env_cfg.py @@ -339,6 +339,7 @@ class PourSceneCfg(InteractiveSceneCfg): ) robot = FRANKA_PANDA_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") robot.spawn.usd_path = FRANKA_POUR_ROBOT_USD_PATH + robot.spawn.variants = {"Colliders": "convex_hulls"} robot.spawn.func = spawn_franka_with_arm_collisions robot.spawn.articulation_props.enabled_self_collisions = True robot.actuators = { diff --git a/source/isaaclab_tasks/test/contrib/test_franka_pour_env_cfg.py b/source/isaaclab_tasks/test/contrib/test_franka_pour_env_cfg.py index 17e936d5f5b1..37e4f5fd78dd 100644 --- a/source/isaaclab_tasks/test/contrib/test_franka_pour_env_cfg.py +++ b/source/isaaclab_tasks/test/contrib/test_franka_pour_env_cfg.py @@ -111,6 +111,7 @@ def test_reset_dataset_contract_stores_root_relative_robot_asset_path(): robot_asset = _reset_dataset_task_contract(cfg)["robot_asset"] assert robot_asset == "Robots/FrankaEmika/franka_panda.usda" assert f"{ISAACLAB_NUCLEUS_DIR}/{robot_asset}" == FRANKA_POUR_ROBOT_ASSET_ID + assert cfg.scene.robot.spawn.variants == {"Colliders": "convex_hulls"} def test_capacity_resolution_only_updates_world_dependent_solver_limits(): From 71ad92aac5bf9c19356989a748861ed8cb103c58 Mon Sep 17 00:00:00 2001 From: ooctipus Date: Thu, 17 Sep 2026 07:09:10 -0700 Subject: [PATCH 13/13] Fix Franka Lift reset sampling after the collider default changed (#7870) Franka Lift can stall while filling its reset-state bank: the updated `franka_panda.usda` defaults to `Colliders=primitives`, whose first-link capsule extends 6 cm below the ground. The existing ground-clearance criterion rejects every candidate, leaving the RSL-RL export batch at `0/2048` until its 600-second timeout. This PR selects `Colliders=convex_hulls` on the robot configuration shared by rigid Franka Lift and Reorient, restoring the previous collision geometry and successful reset sampling. The affected asset and reset setup originated in [#6457](https://github.com/isaac-sim/IsaacLab/pull/6457) ([564a0083a0](https://github.com/isaac-sim/IsaacLab/commit/564a0083a00a11a07a47c469ddda3b41331a0ccf)), then moved into the current Lift tasks in [#6732](https://github.com/isaac-sim/IsaacLab/pull/6732) ([73106da3f7](https://github.com/isaac-sim/IsaacLab/commit/73106da3f71679a3d174e9219b855b02aa87390e)). The triggering change is external: the production Franka asset's top layer now selects primitive colliders and reports `Last-Modified: 2026-09-15 22:49:27 GMT`. Controlled comparisons found 0 valid states in 128 draws with the new default; explicit convex hulls and the preserved old asset both produced 63 valid states from the same draws. The same reset hang appeared in CI for #7838, #7608, #7832, #7834, and #7866. [The #7866 RL log](https://github.com/isaac-sim/IsaacLab/actions/runs/35202342707/job/105139978507) shows the batch blocked on Franka Lift after environment setup. #7866 fixes the independently configured Franka Pour task; it does not change Lift's robot configuration. This change covers Lift/Reorient only. Validation: - Fresh regression check on this branch: both Lift/Reorient cases failed without the production fix; all 9 tests in `test_lift_env_cfg.py` passed with it. - `uv run isaaclab -f` passed. - Earlier GPU validation of the identical Lift change completed the original 2,048-state harvest and reset in 22.169 seconds; the existing four-task RSL-RL export batch passed in 107.92 seconds. - Local GPU validation used PyTorch 2.11+cu128, Newton 1.6rc1, and RSL-RL 5.4.1. The identical Lift fix also passed [the full locked-environment RL CI job in #7865](https://github.com/isaac-sim/IsaacLab/actions/runs/35207648168/job/105158711330). Reset-bank sizes, clearance criteria, export behavior, the global Franka defaults, and Pour remain unchanged. (cherry picked from commit c5d33efb97cb5bc2db9c976d8636c0751ad4f2b8) --- .../fix-franka-lift-collider-variant.rst | 5 ++++ .../core/lift/config/franka/franka_env_cfg.py | 2 ++ .../test/core/test_lift_env_cfg.py | 27 +++++++++++++++++++ 3 files changed, 34 insertions(+) create mode 100644 source/isaaclab_tasks/changelog.d/fix-franka-lift-collider-variant.rst diff --git a/source/isaaclab_tasks/changelog.d/fix-franka-lift-collider-variant.rst b/source/isaaclab_tasks/changelog.d/fix-franka-lift-collider-variant.rst new file mode 100644 index 000000000000..c52a004b6e7e --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/fix-franka-lift-collider-variant.rst @@ -0,0 +1,5 @@ +Fixed +^^^^^ + +* Fixed Franka Lift and Reorient reset sampling with updated Franka assets by explicitly selecting the convex-hull + arm colliders used by the tasks' clearance criteria. diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/lift/config/franka/franka_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/lift/config/franka/franka_env_cfg.py index 26c063d78da2..b244c84dcab0 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/lift/config/franka/franka_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/lift/config/franka/franka_env_cfg.py @@ -24,6 +24,8 @@ # legacy asset so the upstream franka tasks keep their demos and baselines. FRANKA_PANDA_LIFT_CFG = FRANKA_PANDA_CFG.copy() FRANKA_PANDA_LIFT_CFG.spawn.usd_path = f"{ISAACLAB_NUCLEUS_DIR}/Robots/FrankaEmika/franka_panda.usda" +# Reset clearance was calibrated for these arm meshes; the asset's primitive colliders intersect the ground. +FRANKA_PANDA_LIFT_CFG.spawn.variants = {"Colliders": "convex_hulls"} FRANKA_PANDA_LIFT_CFG.actuators = { # Inspired by libfranka's joint_impedance_control.cpp. ``actuator_velocity_limit`` # remains the soft task-limit snapshot; ``joint_velocity_limit`` is the diff --git a/source/isaaclab_tasks/test/core/test_lift_env_cfg.py b/source/isaaclab_tasks/test/core/test_lift_env_cfg.py index e9ab35c1dbba..eb6de255122b 100644 --- a/source/isaaclab_tasks/test/core/test_lift_env_cfg.py +++ b/source/isaaclab_tasks/test/core/test_lift_env_cfg.py @@ -10,9 +10,13 @@ import pytest import torch +from pxr import Usd + from isaaclab.managers import CommandTerm +from isaaclab.sim import select_usd_variants from isaaclab_tasks.core.lift import mdp +from isaaclab_tasks.core.lift.config.franka.franka_env_cfg import FrankaLiftEnvCfg, FrankaReorientEnvCfg from isaaclab_tasks.core.lift.config.franka_soft.franka_soft_env_cfg import FrankaSoftEnvCfg from isaaclab_tasks.core.lift.mdp.commands.pose_commands import ( CableUniformPoseCommand, @@ -58,6 +62,29 @@ def test_franka_soft_robot_physics_variant_matches_backend( assert cfg.scene.robot.spawn.variants == {"Physics": expected_physics} +@pytest.mark.parametrize("cfg_type", [FrankaLiftEnvCfg, FrankaReorientEnvCfg]) +def test_franka_rigid_tasks_select_collision_meshes_for_reset_clearance(cfg_type) -> None: + """Reset validation keeps the original arm meshes when the asset defaults to capsules.""" + cfg = cfg_type() + stage = Usd.Stage.CreateInMemory() + robot = stage.DefinePrim("/Robot", "Xform") + colliders = robot.GetVariantSets().AddVariantSet("Colliders") + for selection, prim_path, prim_type in ( + ("convex_hulls", "/Robot/link1_c/link1_c", "Mesh"), + ("primitives", "/Robot/link1_capsule", "Capsule"), + ): + colliders.AddVariant(selection) + colliders.SetVariantSelection(selection) + with colliders.GetVariantEditContext(): + stage.DefinePrim(prim_path, prim_type) + colliders.SetVariantSelection("primitives") + + select_usd_variants("/Robot", cfg.scene.robot.spawn.variants or {}, stage=stage) + + assert stage.GetPrimAtPath("/Robot/link1_c/link1_c").IsValid() + assert not stage.GetPrimAtPath("/Robot/link1_capsule").IsValid() + + def test_camera_normalization_is_stationary() -> None: """RGB and depth normalization must not depend on per-frame statistics.""" rgb = torch.tensor([0.0, 127.5, 255.0])