From a2ba7a1266942e609c9505e562dec82d95cca1c3 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Mon, 26 Oct 2026 02:52:23 -0700 Subject: [PATCH 1/9] Separate recorder capture from batched episode storage --- docs/source/api/lab/isaaclab.managers.rst | 22 ++ .../isaaclab_mimic/annotate_demos.py | 9 +- .../changelog.d/recorder-capture.changed.rst | 9 + .../changelog.d/recorder-capture.major | 0 .../recorder-inference-lifetime.fixed.rst | 2 + .../isaaclab/envs/manager_based_rl_env.py | 4 +- .../isaaclab/envs/mdp/recorders/recorders.py | 10 + .../isaaclab/managers/recorder_manager.py | 344 ++++++++++-------- .../envs/test_manager_based_rl_env_unit.py | 95 +++++ .../test/managers/test_recorder_manager.py | 141 ++++++- .../changelog.d/recorder-capture.changed.rst | 2 + .../envs/manager_based_rl_env_warp.py | 12 +- 12 files changed, 489 insertions(+), 161 deletions(-) create mode 100644 source/isaaclab/changelog.d/recorder-capture.changed.rst create mode 100644 source/isaaclab/changelog.d/recorder-capture.major create mode 100644 source/isaaclab/changelog.d/recorder-inference-lifetime.fixed.rst create mode 100644 source/isaaclab_experimental/changelog.d/recorder-capture.changed.rst diff --git a/docs/source/api/lab/isaaclab.managers.rst b/docs/source/api/lab/isaaclab.managers.rst index d378386b86de..f83a4bd85b8e 100644 --- a/docs/source/api/lab/isaaclab.managers.rst +++ b/docs/source/api/lab/isaaclab.managers.rst @@ -231,6 +231,28 @@ Curriculum Manager Recorder Manager ---------------- +Recorder terms select which tensors to capture at the existing pre/post step and reset callbacks. +For an online consumer, a term can keep its latest batch in preallocated tensors and return +``(None, None)`` from its callbacks. The training root obtains that term with +``env.recorder_manager.get_term(name)`` and copies the batch into its own replay storage. +The term owns the lifetime of its published tensors; consumers must copy them before the next capture. + +In an RL environment, post-step callbacks run after reward and termination computation and before +automatic reset. A term reading physical tensors directly should set +``requires_post_step_observations = False``. This avoids an extra observation computation and its +noise draws. Existing terms default to ``True``. Callback dispatch is independent of this setting. + +Returning a field path and a batched tensor (or nested dictionary of tensors) retains samples in +the manager. Each field has a time-major allocation and independent per-world insertion indices, +so reset and per-step fields can have different sample rates. ``buffer_initial_capacity`` controls +the first allocation per field: choose it with the payload size and expected episode length in mind, +especially for images. Storage grows without dropping samples and is reused across resets. + +``get_episode(env_id)`` returns an owned snapshot whose leaves are tensors with a leading sample +dimension. Reading a snapshot can synchronize device indices and copy data. Changes to that snapshot +do not change live recording; use ``add_to_episodes`` and ``set_success_to_episodes`` for live writes. +File export materializes these snapshots; the hot capture path does not construct per-world episodes. + .. autoclass:: RecorderManager :members: :inherited-members: diff --git a/scripts/imitation_learning/isaaclab_mimic/annotate_demos.py b/scripts/imitation_learning/isaaclab_mimic/annotate_demos.py index 1659510f7412..5a232f90fbc2 100644 --- a/scripts/imitation_learning/isaaclab_mimic/annotate_demos.py +++ b/scripts/imitation_learning/isaaclab_mimic/annotate_demos.py @@ -515,7 +515,6 @@ def annotate_episode_in_manual_mode( print(f'\tThe episode will be replayed again for re-marking subtask signals for the eef "{eef_name}".\n') - annotated_episode = env.recorder_manager.get_episode(0) for ( subtask_term_signal_name, subtask_term_signal_action_index, @@ -523,7 +522,9 @@ def annotate_episode_in_manual_mode( # subtask termination signal is false until subtask is complete, and true afterwards subtask_signals = torch.ones(len(episode.data["actions"]), dtype=torch.bool) subtask_signals[:subtask_term_signal_action_index] = False - annotated_episode.add(f"obs/datagen_info/subtask_term_signals/{subtask_term_signal_name}", subtask_signals) + env.recorder_manager.add_to_episodes( + f"obs/datagen_info/subtask_term_signals/{subtask_term_signal_name}", subtask_signals.unsqueeze(0), [0] + ) if args_cli.annotate_subtask_start_signals: for ( @@ -532,8 +533,8 @@ def annotate_episode_in_manual_mode( ) in subtask_start_signal_action_indices.items(): subtask_signals = torch.ones(len(episode.data["actions"]), dtype=torch.bool) subtask_signals[:subtask_start_signal_action_index] = False - annotated_episode.add( - f"obs/datagen_info/subtask_start_signals/{subtask_start_signal_name}", subtask_signals + env.recorder_manager.add_to_episodes( + f"obs/datagen_info/subtask_start_signals/{subtask_start_signal_name}", subtask_signals.unsqueeze(0), [0] ) return True diff --git a/source/isaaclab/changelog.d/recorder-capture.changed.rst b/source/isaaclab/changelog.d/recorder-capture.changed.rst new file mode 100644 index 000000000000..d0eebccaa3cd --- /dev/null +++ b/source/isaaclab/changelog.d/recorder-capture.changed.rst @@ -0,0 +1,9 @@ +* Replaced per-world recorder episode containers with batched field allocations and + independent insertion indices, reusing storage across resets and growing without dropping samples. +* Added :meth:`~isaaclab.managers.RecorderManager.get_term` and the term capability + :attr:`~isaaclab.managers.RecorderTerm.requires_post_step_observations` so physical-state + capture can avoid extra observation computation and noise draws. +* **Breaking:** Changed :meth:`~isaaclab.managers.RecorderManager.get_episode` to return an owned + snapshot with tensor leaves. To update live recording, use ``add_to_episodes`` and + ``set_success_to_episodes``; do not mutate the snapshot. Set ``buffer_initial_capacity`` + to control the initial allocation per field. diff --git a/source/isaaclab/changelog.d/recorder-capture.major b/source/isaaclab/changelog.d/recorder-capture.major new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab/changelog.d/recorder-inference-lifetime.fixed.rst b/source/isaaclab/changelog.d/recorder-inference-lifetime.fixed.rst new file mode 100644 index 000000000000..1302fe5c27d3 --- /dev/null +++ b/source/isaaclab/changelog.d/recorder-inference-lifetime.fixed.rst @@ -0,0 +1,2 @@ +* Kept retained recorder allocations writable after leaving PyTorch inference mode, including + grown buffers and schema changes, so subsequent reset and export can finalize those episodes. diff --git a/source/isaaclab/isaaclab/envs/manager_based_rl_env.py b/source/isaaclab/isaaclab/envs/manager_based_rl_env.py index 859cb837cd5b..122fb5087a6d 100644 --- a/source/isaaclab/isaaclab/envs/manager_based_rl_env.py +++ b/source/isaaclab/isaaclab/envs/manager_based_rl_env.py @@ -237,8 +237,8 @@ def step(self, action: torch.Tensor) -> VecEnvStepReturn: self.reward_buf = self.reward_manager.compute(dt=self.step_dt) if len(self.recorder_manager.active_terms) > 0: - # update observations for recording if needed - self.obs_buf = self.observation_manager.compute() + if self.recorder_manager.requires_post_step_observations: + self.obs_buf = self.observation_manager.compute() self.recorder_manager.record_post_step() # -- reset envs that terminated/timed-out and log the episode information diff --git a/source/isaaclab/isaaclab/envs/mdp/recorders/recorders.py b/source/isaaclab/isaaclab/envs/mdp/recorders/recorders.py index a9315bbca63d..44252e55a030 100644 --- a/source/isaaclab/isaaclab/envs/mdp/recorders/recorders.py +++ b/source/isaaclab/isaaclab/envs/mdp/recorders/recorders.py @@ -14,6 +14,8 @@ class InitialStateRecorder(RecorderTerm): """Recorder term that records the initial state of the environment after reset.""" + requires_post_step_observations = False + def record_post_reset(self, env_ids: Sequence[int] | None): def extract_env_ids_values(value): nonlocal env_ids @@ -27,6 +29,8 @@ def extract_env_ids_values(value): class PostStepStatesRecorder(RecorderTerm): """Recorder term that records the state of the environment at the end of each step.""" + requires_post_step_observations = False + def record_post_step(self): return "states", self._env.scene.get_state(is_relative=True) @@ -34,6 +38,8 @@ def record_post_step(self): class PreStepActionsRecorder(RecorderTerm): """Recorder term that records the actions in the beginning of each step.""" + requires_post_step_observations = False + def record_pre_step(self): return "actions", self._env.action_manager.action @@ -41,6 +47,8 @@ def record_pre_step(self): class PreStepFlatPolicyObservationsRecorder(RecorderTerm): """Recorder term that records the policy group observations in each step.""" + requires_post_step_observations = False + def record_pre_step(self): return "obs", self._env.obs_buf["policy"] @@ -48,6 +56,8 @@ def record_pre_step(self): class PostStepProcessedActionsRecorder(RecorderTerm): """Recorder term that records processed actions at the end of each step.""" + requires_post_step_observations = False + def record_post_step(self): processed_actions = None diff --git a/source/isaaclab/isaaclab/managers/recorder_manager.py b/source/isaaclab/isaaclab/managers/recorder_manager.py index f4894840afb9..3ac51533f963 100644 --- a/source/isaaclab/isaaclab/managers/recorder_manager.py +++ b/source/isaaclab/isaaclab/managers/recorder_manager.py @@ -54,6 +54,14 @@ class RecorderManagerBaseCfg: export_in_close: bool = False """Whether to export episodes in the close call.""" + buffer_initial_capacity: int = 64 + """Initial samples per world and field. Must be positive. + + Fields allocate on first use and double their capacity when necessary, retaining all samples. + Resets reuse storage. Set this to the expected episode length (including decimation callbacks) + to avoid growth during recording. Each field has independent write indices. + """ + dataset_compression: bool = True """Enable dataset compression.""" @@ -71,6 +79,13 @@ class RecorderTerm(ManagerTermBase): * Post-step recording: This callback is invoked at the end of `env.step()` when all the managers are processed. """ + requires_post_step_observations: bool = True + """Whether the RL environment must compute observations before this term's post-step callback. + + Terms that only read physical tensors should set this to False. The default preserves the + observation access of existing terms; disabling it avoids extra noise draws and computation. + """ + def __init__(self, cfg: RecorderTermCfg, env: ManagerBasedEnv): """Initialize the recorder term. @@ -163,6 +178,8 @@ def __init__(self, cfg: object, env: ManagerBasedEnv): """ self._term_names: list[str] = [] self._terms: dict[str, RecorderTerm] = {} + self._dataset_file_handler = None + self._failed_episode_dataset_file_handler = None # Do nothing if cfg is None or an empty dict if not cfg: @@ -177,30 +194,28 @@ def __init__(self, cfg: object, env: ManagerBasedEnv): if not isinstance(cfg, RecorderManagerBaseCfg): raise TypeError("Configuration for the recorder manager is not of type RecorderManagerBaseCfg.") - # create episode data buffer indexed by environment id - self._episodes: dict[int, EpisodeData] = dict() - for env_id in range(env.num_envs): - self._episodes[env_id] = EpisodeData() + if cfg.buffer_initial_capacity < 1: + raise ValueError("buffer_initial_capacity must be positive.") + self._fields: list[_RecorderField] = [] + self._env_ids = torch.arange(env.num_envs, device=env.device) + # -1 denotes an episode whose success has not been set. + self._success = torch.full((env.num_envs,), -1, dtype=torch.int8, device=env.device) + self._exported_counts = torch.zeros(2, dtype=torch.long, device=env.device) env_name = getattr(env.cfg, "env_name", None) - self._dataset_file_handler = None if cfg.dataset_export_mode != DatasetExportMode.EXPORT_NONE: self._dataset_file_handler = cfg.dataset_file_handler_class_type() self._dataset_file_handler.create( os.path.join(cfg.dataset_export_dir_path, cfg.dataset_filename), env_name=env_name ) - self._failed_episode_dataset_file_handler = None if cfg.dataset_export_mode == DatasetExportMode.EXPORT_SUCCEEDED_FAILED_IN_SEPARATE_FILES: self._failed_episode_dataset_file_handler = cfg.dataset_file_handler_class_type() self._failed_episode_dataset_file_handler.create( os.path.join(cfg.dataset_export_dir_path, f"{cfg.dataset_filename}_failed"), env_name=env_name ) - self._exported_successful_episode_count = {} - self._exported_failed_episode_count = {} - def __str__(self) -> str: """Returns: A string representation for recorder manager.""" msg = f" contains {len(self._term_names)} active terms.\n" @@ -232,36 +247,31 @@ def active_terms(self) -> list[str]: return self._term_names @property - def exported_successful_episode_count(self, env_id=None) -> int: - """Number of successful episodes. + def requires_post_step_observations(self) -> bool: + """Whether any active recorder term requires post-step observations.""" + return any(term.requires_post_step_observations for term in self._terms.values()) - Args: - env_id: The environment id. Defaults to None, in which case all environments are considered. + @property + def exported_successful_episode_count(self) -> int: + """Number of successful episodes. Returns: The number of successful episodes. """ - if not hasattr(self, "_exported_successful_episode_count"): + if not hasattr(self, "_exported_counts"): return 0 - if env_id is not None: - return self._exported_successful_episode_count.get(env_id, 0) - return sum(self._exported_successful_episode_count.values()) + return int(self._exported_counts[1].item()) @property - def exported_failed_episode_count(self, env_id=None) -> int: + def exported_failed_episode_count(self) -> int: """Number of failed episodes. - Args: - env_id: The environment id. Defaults to None, in which case all environments are considered. - Returns: The number of failed episodes. """ - if not hasattr(self, "_exported_failed_episode_count"): + if not hasattr(self, "_exported_counts"): return 0 - if env_id is not None: - return self._exported_failed_episode_count.get(env_id, 0) - return sum(self._exported_failed_episode_count.values()) + return int(self._exported_counts[0].item()) """ Operations. @@ -280,96 +290,81 @@ def reset(self, env_ids: Sequence[int] | slice | None = None) -> dict[str, torch # Do nothing if no active recorder terms are provided if len(self.active_terms) == 0: return {} - # resolve environment ids - if env_ids is None: - env_ids = slice(None) - if isinstance(env_ids, slice): - env_ids = list(range(self._env.num_envs)[env_ids]) - if isinstance(env_ids, torch.Tensor): - env_ids = env_ids.tolist() - for term in self._terms.values(): term.reset(env_ids=env_ids) - - for env_id in env_ids: - self._episodes[env_id] = EpisodeData() - - # nothing to log here + self._reset_buffers(env_ids) return {} - def get_episode(self, env_id: int) -> EpisodeData: - """Returns the episode data for the given environment id. + def get_term(self, name: str) -> RecorderTerm: + """Return the configured recorder term with the given name.""" + return self._terms[name] - Args: - env_id: The environment id. + def get_episode(self, env_id: int) -> EpisodeData: + """Return an owned snapshot of a world's current episode. - Returns: - The episode data for the given environment id. + Leaves are tensors with a leading sample dimension, matching the exported dataset. + Reading synchronizes write indices and copies the recorded samples. Later recording, + reset, or snapshot mutation cannot affect the other copy. Add live data through + :meth:`add_to_episodes`, and set success through :meth:`set_success_to_episodes`. """ - return self._episodes.get(env_id, EpisodeData()) + episode = EpisodeData() + if not self.active_terms or not 0 <= env_id < self._env.num_envs: + return episode + for field in self._fields: + length = int(field.lengths[env_id].item()) + if length: + data = episode.data + keys = field.name.split("/") + for key in keys[:-1]: + data = data.setdefault(key, {}) + data[keys[-1]] = field.data[:length, env_id].clone() + success = int(self._success[env_id].item()) + episode.success = None if success < 0 else bool(success) + return episode def add_to_episodes(self, key: str, value: torch.Tensor | dict, env_ids: Sequence[int] | slice | None = None): - """Adds the given key-value pair to the episodes for the given environment ids. + """Append a batch to preallocated storage, advancing selected worlds' write indices. Args: - key: The key of the given value to be added to the episodes. The key can contain nested keys - separated by '/'. For example, "obs/joint_pos" would add the given value under ['obs']['policy'] - in the underlying dictionary in the episode data. - value: The value to be added to the episodes. The value can be a tensor or a nested dictionary of tensors. - The shape of a tensor in the value is (env_ids, ...). - env_ids: The environment ids. Defaults to None, in which case all environments are considered. + key: Field path, with nested keys separated by '/'. + value: Tensor of shape (selected worlds, ...) or nested dictionary of tensors. + Warp arrays are also accepted. Storage copies the values and preserves dtype/device. + A field's shape, dtype, and device must remain constant while it contains samples. + env_ids: Unique world indices in the same order as the value batch. None selects all worlds. """ - # Do nothing if no active recorder terms are provided - if len(self.active_terms) == 0: + if not self.active_terms or key is None: return - - # resolve environment ids - if key is None: - return - if env_ids is None: - env_ids = slice(None) - if isinstance(env_ids, slice): - env_ids = list(range(self._env.num_envs)[env_ids]) - if isinstance(env_ids, torch.Tensor): - env_ids = env_ids.tolist() - if isinstance(value, dict): for sub_key, sub_value in value.items(): - if isinstance(sub_value, wp.array): - sub_value = wp.to_torch(sub_value) self.add_to_episodes(f"{key}/{sub_key}", sub_value, env_ids) return - if isinstance(value, wp.array): value = wp.to_torch(value) - value = value.clone() # Clone once for all envs - for value_index, env_id in enumerate(env_ids): - if env_id not in self._episodes: - self._episodes[env_id] = EpisodeData() - self._episodes[env_id].env_id = env_id - self._episodes[env_id].add(key, value[value_index], clone=False) + num_selected = ( + self._env.num_envs + if env_ids is None + else (len(range(self._env.num_envs)[env_ids]) if isinstance(env_ids, slice) else len(env_ids)) + ) + if value.ndim < 1 or value.shape[0] != num_selected: + raise ValueError(f"Field '{key}' requires {num_selected} rows, got {tuple(value.shape)}.") + if not num_selected: + return + field = next((field for field in self._fields if field.name == key), None) + if field is None: + # Reject ambiguous leaf/subtree paths before allocating any storage. + if any(key.startswith(f"{f.name}/") or f.name.startswith(f"{key}/") for f in self._fields): + raise ValueError(f"Recorder field '{key}' conflicts with an existing field path.") + field = _RecorderField(key, value, self._env.num_envs, self.cfg.buffer_initial_capacity) + self._fields.append(field) + field.append(value, env_ids) def set_success_to_episodes(self, env_ids: Sequence[int] | slice | None, success_values: torch.Tensor): - """Sets the task success values to the episodes for the given environment ids. - - Args: - env_ids: The environment ids. Defaults to None, in which case all environments are considered. - success_values: The task success values to be set to the episodes. The shape of the tensor is (env_ids, 1). - """ - # Do nothing if no active recorder terms are provided - if len(self.active_terms) == 0: + """Set selected worlds' success flags from a tensor of shape (selected worlds,) or (selected worlds, 1).""" + if not self.active_terms: return - - # resolve environment ids if env_ids is None: env_ids = slice(None) - if isinstance(env_ids, slice): - env_ids = list(range(self._env.num_envs)[env_ids]) - if isinstance(env_ids, torch.Tensor): - env_ids = env_ids.tolist() - - for value_index, env_id in enumerate(env_ids): - self._episodes[env_id].success = success_values[value_index].item() + self._success[env_ids] = success_values.reshape(-1).to(device=self._success.device, dtype=torch.int8) def record_pre_step(self) -> None: """Trigger recorder terms for pre-step functions.""" @@ -412,11 +407,9 @@ def record_pre_reset(self, env_ids: Sequence[int] | slice | None, force_export_o return if env_ids is None: - env_ids = slice(None) - if isinstance(env_ids, slice): - env_ids = list(range(self._env.num_envs)[env_ids]) - if isinstance(env_ids, torch.Tensor): - env_ids = env_ids.tolist() + env_ids = self._env_ids + elif isinstance(env_ids, slice): + env_ids = self._env_ids[env_ids] for term in self._terms.values(): key, value = term.record_pre_reset(env_ids) @@ -444,7 +437,7 @@ def record_post_reset(self, env_ids: Sequence[int] | slice | None) -> None: return if isinstance(env_ids, slice): - env_ids = list(range(self._env.num_envs)[env_ids]) + env_ids = self._env_ids[env_ids] for term in self._terms.values(): key, value = term.record_post_reset(env_ids) @@ -485,11 +478,11 @@ def export_episodes( return if env_ids is None: - env_ids = slice(None) - if isinstance(env_ids, slice): - env_ids = list(range(self._env.num_envs)[env_ids]) - if isinstance(env_ids, torch.Tensor): - env_ids = env_ids.tolist() + env_ids = self._env_ids + elif isinstance(env_ids, slice): + env_ids = self._env_ids[env_ids] + else: + env_ids = torch.as_tensor(env_ids, dtype=torch.long, device=self._env_ids.device) # Handle demo_ids processing if demo_ids is not None: @@ -502,53 +495,43 @@ def export_episodes( duplicates = [x for i, x in enumerate(demo_ids) if demo_ids.index(x) != i] raise ValueError(f"demo_ids must be unique. Found duplicates: {list(set(duplicates))}") - # Export episode data through dataset exporter - need_to_flush = False + present = torch.zeros(self._env.num_envs, dtype=torch.bool, device=self._env_ids.device) + for field in self._fields: + present |= (field.lengths > 0).to(present.device) + succeeded = self._success == 1 - if any(env_id in self._episodes and not self._episodes[env_id].is_empty() for env_id in env_ids): + # Only file export materializes per-world snapshots. EXPORT_NONE stays batched. + need_to_flush = False + mode = self.cfg.dataset_export_mode + if mode != DatasetExportMode.EXPORT_NONE and present[env_ids].any().item(): ep_meta = self.get_ep_meta() - if self._dataset_file_handler is not None: - self._dataset_file_handler.add_env_args(ep_meta) - if self._failed_episode_dataset_file_handler is not None: - self._failed_episode_dataset_file_handler.add_env_args(ep_meta) - - for i, env_id in enumerate(env_ids): - if env_id in self._episodes and not self._episodes[env_id].is_empty(): - self._episodes[env_id].pre_export() - - episode_succeeded = self._episodes[env_id].success - target_dataset_file_handler = None - if (self.cfg.dataset_export_mode == DatasetExportMode.EXPORT_ALL) or ( - self.cfg.dataset_export_mode == DatasetExportMode.EXPORT_SUCCEEDED_ONLY and episode_succeeded - ): - target_dataset_file_handler = self._dataset_file_handler - elif self.cfg.dataset_export_mode == DatasetExportMode.EXPORT_SUCCEEDED_FAILED_IN_SEPARATE_FILES: - if episode_succeeded: - target_dataset_file_handler = self._dataset_file_handler - else: - target_dataset_file_handler = self._failed_episode_dataset_file_handler - if target_dataset_file_handler is not None: - # Use corresponding demo_id if provided, otherwise None - current_demo_id = demo_ids[i] if demo_ids is not None else None - target_dataset_file_handler.write_episode( - self._episodes[env_id], current_demo_id, self.cfg.dataset_compression - ) - need_to_flush = True - # Update episode count - if episode_succeeded: - self._exported_successful_episode_count[env_id] = ( - self._exported_successful_episode_count.get(env_id, 0) + 1 - ) - else: - self._exported_failed_episode_count[env_id] = self._exported_failed_episode_count.get(env_id, 0) + 1 - # Reset the episode buffer for the given environment after export - self._episodes[env_id] = EpisodeData() - + for handler in (self._dataset_file_handler, self._failed_episode_dataset_file_handler): + if handler is not None: + handler.add_env_args(ep_meta) + selected = env_ids.tolist() + selected_present = present[env_ids].tolist() + selected_success = succeeded[env_ids].tolist() + for i, env_id in enumerate(selected): + if not selected_present[i]: + continue + handler = self._dataset_file_handler + if mode == DatasetExportMode.EXPORT_SUCCEEDED_FAILED_IN_SEPARATE_FILES: + if not selected_success[i]: + handler = self._failed_episode_dataset_file_handler + elif mode == DatasetExportMode.EXPORT_SUCCEEDED_ONLY and not selected_success[i]: + continue + episode = self.get_episode(env_id) + demo_id = demo_ids[i] if demo_ids is not None else None + handler.write_episode(episode, demo_id, self.cfg.dataset_compression) + need_to_flush = True + + self._exported_counts[0] += (present & ~succeeded)[env_ids].sum() + self._exported_counts[1] += (present & succeeded)[env_ids].sum() + self._reset_buffers(env_ids) if need_to_flush: - if self._dataset_file_handler is not None: - self._dataset_file_handler.flush() - if self._failed_episode_dataset_file_handler is not None: - self._failed_episode_dataset_file_handler.flush() + for handler in (self._dataset_file_handler, self._failed_episode_dataset_file_handler): + if handler is not None: + handler.flush() def close(self): """Closes the recorder manager by exporting any remaining data to file as well as properly @@ -570,6 +553,12 @@ def close(self): Helper functions. """ + def _reset_buffers(self, env_ids): + """Discard selected samples by resetting indices; retain allocations for the next episodes.""" + for field in self._fields: + field.reset(env_ids) + self._success[slice(None) if env_ids is None else env_ids] = -1 + def _prepare_terms(self): """Prepares a list of recorder terms.""" # check if config is dict already @@ -587,6 +576,7 @@ def _prepare_terms(self): "export_in_record_pre_reset", "export_in_close", "dataset_compression", + "buffer_initial_capacity", ]: continue # check if term config is None @@ -605,3 +595,65 @@ def _prepare_terms(self): # add term name and parameters self._term_names.append(term_name) self._terms[term_name] = term + + +class _RecorderField: + """One field's time-major allocation and independent per-world insertion indices. + + No per-world Python containers or tensor views are created during append. The host upper + bound avoids reading device indices in steady state; only capacity exhaustion checks synchronize. + """ + + def __init__(self, name: str, value: torch.Tensor, num_envs: int, capacity: int): + self.name = name + # Retained storage outlives the caller's inference context: reset/export may run outside it. + with torch.inference_mode(False): + self.data = torch.empty((capacity, num_envs, *value.shape[1:]), dtype=value.dtype, device=value.device) + self.lengths = torch.zeros(num_envs, dtype=torch.long, device=value.device) + self.worlds = torch.arange(num_envs, device=value.device) + self.upper_bound = 0 + + @torch.no_grad() + def append(self, value: torch.Tensor, env_ids): + if value.shape[1:] != self.data.shape[2:] or value.dtype != self.data.dtype or value.device != self.data.device: + if self.lengths.any().item(): + raise ValueError(f"Recorder field '{self.name}' changed shape, dtype, or device with active samples.") + with torch.inference_mode(False): + self.data = torch.empty( + (*self.data.shape[:2], *value.shape[1:]), dtype=value.dtype, device=value.device + ) + self.lengths = self.lengths.to(value.device) + self.worlds = self.worlds.to(value.device) + self.upper_bound = 0 + if env_ids is None: + worlds, indices = self.worlds, self.lengths + else: + if not isinstance(env_ids, slice): + env_ids = torch.as_tensor(env_ids, dtype=torch.long, device=self.data.device) + worlds = self.worlds[env_ids] + indices = self.lengths[worlds] + capacity = self.data.shape[0] + if self.upper_bound >= capacity: + self.upper_bound = int(self.lengths.max().item()) + if self.upper_bound >= capacity: + with torch.inference_mode(False): + data = self.data.new_empty((2 * capacity, *self.data.shape[1:])) + data[:capacity].copy_(self.data) + self.data = data + self.data[indices, worlds] = value + if env_ids is None: + self.lengths.add_(1) + else: + self.lengths[worlds] = indices + 1 + self.upper_bound += 1 + + def reset(self, env_ids): + if env_ids is None or (isinstance(env_ids, slice) and env_ids == slice(None)): + self.lengths.zero_() + self.upper_bound = 0 + else: + if not isinstance(env_ids, slice): + env_ids = torch.as_tensor(env_ids, dtype=torch.long, device=self.data.device) + self.lengths[env_ids] = 0 + if not isinstance(env_ids, slice) and len(env_ids) == len(self.lengths): + self.upper_bound = 0 diff --git a/source/isaaclab/test/envs/test_manager_based_rl_env_unit.py b/source/isaaclab/test/envs/test_manager_based_rl_env_unit.py index e8042d488292..df28cf38669c 100644 --- a/source/isaaclab/test/envs/test_manager_based_rl_env_unit.py +++ b/source/isaaclab/test/envs/test_manager_based_rl_env_unit.py @@ -12,8 +12,20 @@ import gymnasium as gym import numpy as np import pytest +import torch from isaaclab.envs import ManagerBasedRLEnv +from isaaclab.managers import ( + DatasetExportMode, + ObservationGroupCfg, + ObservationManager, + ObservationTermCfg, + RecorderManager, + RecorderManagerBaseCfg, + RecorderTerm, + RecorderTermCfg, +) +from isaaclab.utils.noise import UniformNoiseCfg pytestmark = pytest.mark.unit @@ -102,3 +114,86 @@ def test_obs_space_follows_clip_constraint(): assert term_space.shape == expected_shapes[term_name] assert np.all(term_space.low == low) assert np.all(term_space.high == high) + + +def _state_observation(env): + return env.scene.state + + +class _PhysicalRecorder(RecorderTerm): + requires_post_step_observations = False + + def record_post_step(self): + self.latest = self._env.scene.state.clone() + return None, None + + +class _ObservationRecorder(RecorderTerm): + def record_post_step(self): + self.latest = self._env.obs_buf["policy"].clone() + return None, None + + +def _make_stepping_env(recorder_type=None): + """Exercise the real step/observation/recorder owners with a minimal deterministic physics leaf.""" + env = object.__new__(ManagerBasedRLEnv) + env._is_closed = True + env.cfg = SimpleNamespace(decimation=1, sim=SimpleNamespace(dt=0.02, render_interval=1), compute_final_obs=False) + env.scene = SimpleNamespace(num_envs=3, state=torch.zeros(3, 2)) + env.scene.write_data_to_sim = lambda: None + env.scene.update = lambda dt: None + env.sim = SimpleNamespace( + device="cpu", + is_playing=lambda: True, + is_rendering=False, + step=lambda render: env.scene.state.add_(1), + consume_reset_request=lambda: False, + ) + env._physics_handles_decimation = False + env._sim_step_counter = env.common_step_counter = 0 + env.episode_length_buf = torch.zeros(3, dtype=torch.long) + env.render_enabled = False + env.video_recorders = [] + env.extras = {} + env.action_manager = SimpleNamespace(process_action=lambda action: None, apply_action=lambda: None) + mask = torch.zeros(3, dtype=torch.bool) + env.termination_manager = SimpleNamespace(compute=lambda: mask, terminated=mask, time_outs=mask) + env.reward_manager = SimpleNamespace(compute=lambda dt: torch.zeros(3)) + env.command_manager = SimpleNamespace(compute=lambda dt: None) + env.event_manager = SimpleNamespace(active_terms=[]) + group = ObservationGroupCfg(history_length=3, flatten_history_dim=False, enable_corruption=True) + group.state = ObservationTermCfg(func=_state_observation, noise=UniformNoiseCfg(n_min=-0.1, n_max=0.1)) + env.observation_manager = ObservationManager({"policy": group}, env) + cfg = RecorderManagerBaseCfg(dataset_export_mode=DatasetExportMode.EXPORT_NONE) + if recorder_type is not None: + cfg.capture = RecorderTermCfg(class_type=recorder_type) + env.recorder_manager = RecorderManager(cfg, env) + env.obs_buf = env.observation_manager.compute(update_history=True) + return env + + +def test_raw_capture_preserves_observation_noise_history_and_rng(): + """Raw capture dispatch must not consume extra noise or advance the real policy history.""" + outputs, states = [], [] + for recorder_type in (None, _PhysicalRecorder, _ObservationRecorder): + torch.manual_seed(17) + env = _make_stepping_env(recorder_type) + history = [env.step(torch.empty(3, 0))[0]["policy"].clone() for _ in range(4)] + outputs.append(torch.stack(history)) + states.append(torch.get_rng_state()) + if recorder_type is not None: + term = env.recorder_manager.get_term("capture") + assert not env.recorder_manager._fields + if recorder_type is _PhysicalRecorder: + assert torch.equal(term.latest, torch.full((3, 2), 4.0)) + else: + # The default-compatible path really supplies observations to its callback. + assert term.latest.shape == (3, 3, 2) + env.recorder_manager.close() + assert torch.equal(outputs[0], outputs[1]) + assert torch.equal(states[0], states[1]) + assert not torch.equal(outputs[0], outputs[2]) + assert not torch.equal(states[0], states[2]) + # After four steps the three history entries contain steps 2, 3 and 4, each noised once. + expected = torch.tensor([2.0, 3.0, 4.0])[None, :, None] + assert torch.all((outputs[1][-1] - expected).abs() <= 0.1) diff --git a/source/isaaclab/test/managers/test_recorder_manager.py b/source/isaaclab/test/managers/test_recorder_manager.py index c07dfba99d10..43e05f8aa320 100644 --- a/source/isaaclab/test/managers/test_recorder_manager.py +++ b/source/isaaclab/test/managers/test_recorder_manager.py @@ -4,6 +4,8 @@ # SPDX-License-Identifier: BSD-3-Clause from __future__ import annotations +import ast +import inspect import os import shutil import tempfile @@ -11,11 +13,14 @@ from collections.abc import Sequence from typing import TYPE_CHECKING, cast +import h5py import pytest import torch +import warp as wp from isaaclab.managers import DatasetExportMode, RecorderManager, RecorderManagerBaseCfg, RecorderTerm, RecorderTermCfg from isaaclab.utils import configclass +from isaaclab.utils.datasets import EpisodeData pytestmark = pytest.mark.unit @@ -164,8 +169,8 @@ def test_record(dataset_dir): # check the recorded data for env_id in range(env.num_envs): episode = recorder_manager.get_episode(env_id) - assert torch.stack(episode.data["record_pre_step"]).shape == (2, 4) - assert torch.stack(episode.data["record_post_step"]).shape == (2, 5) + assert episode.data["record_pre_step"].shape == (2, 4) + assert episode.data["record_post_step"].shape == (2, 5) # Trigger pre-reset callbacks which then export and clean the episode data recorder_manager.record_pre_reset(env_ids=slice(None)) @@ -176,8 +181,138 @@ def test_record(dataset_dir): recorder_manager.record_post_reset(env_ids=slice(None)) for env_id in range(env.num_envs): episode = recorder_manager.get_episode(env_id) - assert torch.stack(episode.data["record_post_reset"]).shape == (1, 3) + assert episode.data["record_post_reset"].shape == (1, 3) recorder_manager.reset(slice(1, None, 2)) for env_id in range(env.num_envs): assert recorder_manager.get_episode(env_id).is_empty() == (env_id % 2 == 1) recorder_manager.close() + + +def test_batched_episode_indices_and_ownership(monkeypatch): + """Asynchronous resets, field cadence, growth and snapshots must preserve exact samples.""" + cfg = DummyRecorderManagerCfg(dataset_export_mode=DatasetExportMode.EXPORT_NONE, buffer_initial_capacity=2) + manager = RecorderManager(cfg, create_dummy_env(num_envs=3)) + manager.add_to_episodes("unused", torch.empty(0, 7), []) + assert not manager._fields + with pytest.raises(ValueError, match="requires 3 rows"): + manager.add_to_episodes("invalid", torch.empty(2, 7)) + assert not manager._fields + source = torch.tensor([[1.0, -0.0], [2.0, float("nan")], [3.0, 4.0]]) + # Policies commonly collect under inference_mode, then reset/export after leaving it. + with torch.inference_mode(): + manager.add_to_episodes("obs", {"state": source}) + wp.init() + manager.add_to_episodes("initial_state", wp.from_torch(torch.tensor([[10], [20], [30]], dtype=torch.int32))) + source.fill_(99) + manager.add_to_episodes("obs/state", source) + snapshot = manager.get_episode(1) + assert snapshot.data["obs"]["state"][0, 0] == 2 + assert torch.isnan(snapshot.data["obs"]["state"][0, 1]) + assert torch.signbit(manager.get_episode(0).data["obs"]["state"][0, 1]) + assert snapshot.data["initial_state"].dtype == torch.int32 + assert snapshot.success is None + + manager.reset(torch.tensor([1])) + with torch.inference_mode(): + manager.add_to_episodes("obs/state", torch.tensor([[42.0, 43.0], [52.0, 53.0]]), [2, 1]) + manager.add_to_episodes("obs/state", torch.tensor([[62.0, 63.0]]), slice(0, 1)) + assert torch.equal(manager.get_episode(1).data["obs"]["state"], torch.tensor([[52.0, 53.0]])) + assert manager.get_episode(1).data.keys() == {"obs"} + assert torch.equal(manager.get_episode(2).data["obs"]["state"][:, 0], torch.tensor([3.0, 99.0, 42.0])) + assert torch.equal(manager.get_episode(0).data["obs"]["state"][:, 0], torch.tensor([1.0, 99.0, 62.0])) + assert snapshot.data["obs"]["state"].shape == (2, 2) + snapshot = manager.get_episode(1) + snapshot.data["obs"]["state"].fill_(-100) + assert manager.get_episode(1).data["obs"]["state"][0, 0] == 52 + + with pytest.raises(ValueError, match="changed shape"): + manager.add_to_episodes("obs/state", torch.zeros(3, 5)) + with pytest.raises(ValueError, match="requires 3 rows"): + manager.add_to_episodes("obs/state", torch.zeros(2, 2)) + with pytest.raises(ValueError, match="conflicts"): + manager.add_to_episodes("obs", torch.zeros(3, 2)) + manager.add_to_episodes("obs/state", torch.empty(0, 2), []) + assert manager.get_episode(0).data["obs"]["state"].shape == (3, 2) + + manager.reset() + # A new schema is allowed once every world has released this field, e.g. manual annotation. + with torch.inference_mode(): + manager.add_to_episodes("obs/state", torch.zeros(3, 5, dtype=torch.bool)) + assert manager.get_episode(0).data["obs"]["state"].dtype == torch.bool + manager.reset() + + def forbidden(*args, **kwargs): + raise AssertionError("Steady-state recording must reuse storage without episode objects or host index reads") + + # Architectural/performance gate: preallocated insertion and reset need neither episode objects, + # per-world container appends, nor device-to-host scalar reads. Export-none also stays batched. + with monkeypatch.context() as patch: + patch.setattr(EpisodeData, "__init__", forbidden) + patch.setattr(EpisodeData, "add", forbidden) + patch.setattr(torch.Tensor, "item", forbidden) + patch.setattr(torch, "empty", forbidden) + manager.add_to_episodes("obs/state", torch.zeros(3, 5, dtype=torch.bool)) + manager.set_success_to_episodes([0, 2], torch.tensor([[True], [False]])) + manager.export_episodes([0, 2]) + manager.reset([1]) + assert manager.exported_successful_episode_count == 1 + assert manager.exported_failed_episode_count == 1 + assert all(manager.get_episode(i).is_empty() for i in range(3)) + manager.close() + + +@pytest.mark.parametrize("mode", list(DatasetExportMode)) +def test_export_batched_episodes(dataset_dir, mode): + """Export modes, custom IDs, nested tensor schema and counters survive partial finalization.""" + cfg = DummyRecorderManagerCfg(dataset_export_dir_path=dataset_dir, dataset_export_mode=mode, export_in_close=True) + manager = RecorderManager(cfg, create_dummy_env(num_envs=3)) + values = torch.tensor([[1.0, -0.0], [2.0, float("nan")], [3.0, 4.0]]) + manager.add_to_episodes("actions", values) + manager.add_to_episodes("obs", {"flag": torch.tensor([True, False, True])}) + manager.set_success_to_episodes(None, torch.tensor([[True], [False], [True]])) + with pytest.raises(ValueError, match="must match"): + manager.export_episodes([2, 1], [30]) + with pytest.raises(ValueError, match="must be unique"): + manager.export_episodes([2, 1], [30, 30]) + manager.export_episodes(torch.tensor([2, 1]), [30, 10]) + assert manager.get_episode(1).is_empty() + assert manager.get_episode(2).is_empty() + assert manager.get_episode(0).success is True + manager.export_episodes(slice(0, 1), [20]) + manager.export_episodes() # Empty episodes do not affect counters. + assert manager.exported_successful_episode_count == 2 + assert manager.exported_failed_episode_count == 1 + manager.close() + if mode == DatasetExportMode.EXPORT_NONE: + assert os.listdir(dataset_dir) == [] + return + with h5py.File(os.path.join(dataset_dir, "dataset.hdf5")) as file: + expected = {"demo_30", "demo_20"} + if mode == DatasetExportMode.EXPORT_ALL: + expected.add("demo_10") + assert set(file["data"]) == expected + assert file["data/demo_20/actions"][()].tobytes() == values[0:1].numpy().tobytes() + assert bool(file["data/demo_30/obs/flag"][0]) + assert bool(file["data/demo_20"].attrs["success"]) + if mode == DatasetExportMode.EXPORT_SUCCEEDED_FAILED_IN_SEPARATE_FILES: + with h5py.File(os.path.join(dataset_dir, "dataset_failed.hdf5")) as file: + assert set(file["data"]) == {"demo_10"} + assert file["data/demo_10/actions"][()].tobytes() == values[1:2].numpy().tobytes() + assert not bool(file["data/demo_10"].attrs["success"]) + + +def test_recorder_storage_boundary(): + """Do not reintroduce per-world episode ownership or Python world loops in the append path.""" + import isaaclab.managers.recorder_manager as module + + source = ast.parse(inspect.getsource(module)) + assert not any(isinstance(node, ast.Attribute) and node.attr == "_episodes" for node in ast.walk(source)) + forbidden = ("physicsnemo", "supermimic", "isaaclab_physicsnemo") + for node in ast.walk(source): + if isinstance(node, ast.Import): + assert not any(alias.name.startswith(forbidden) for alias in node.names) + elif isinstance(node, ast.ImportFrom): + assert not (node.module or "").startswith(forbidden) + field = next(node for node in source.body if isinstance(node, ast.ClassDef) and node.name == "_RecorderField") + append = next(node for node in field.body if isinstance(node, ast.FunctionDef) and node.name == "append") + assert not any(isinstance(node, (ast.For, ast.While, ast.comprehension)) for node in ast.walk(append)) diff --git a/source/isaaclab_experimental/changelog.d/recorder-capture.changed.rst b/source/isaaclab_experimental/changelog.d/recorder-capture.changed.rst new file mode 100644 index 000000000000..99e5426a4d83 --- /dev/null +++ b/source/isaaclab_experimental/changelog.d/recorder-capture.changed.rst @@ -0,0 +1,2 @@ +* Honored recorder terms' post-step observation requirements in the Warp RL environment, + allowing physical-state capture without extra observation computation. diff --git a/source/isaaclab_experimental/isaaclab_experimental/envs/manager_based_rl_env_warp.py b/source/isaaclab_experimental/isaaclab_experimental/envs/manager_based_rl_env_warp.py index be522e073a2b..b35fb410a88c 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/envs/manager_based_rl_env_warp.py +++ b/source/isaaclab_experimental/isaaclab_experimental/envs/manager_based_rl_env_warp.py @@ -321,12 +321,12 @@ def step(self, action: torch.Tensor) -> VecEnvStepReturn: ) if len(self.recorder_manager.active_terms) > 0: - # update observations for recording if needed - self._manager_call_switch.call_stage( - stage="ObservationManager_compute_no_history", - warp_call={"fn": self.observation_manager.compute, "kwargs": {"return_cloned_output": False}}, - timer=DEBUG_TIMER_STEP, - ) + if self.recorder_manager.requires_post_step_observations: + self._manager_call_switch.call_stage( + stage="ObservationManager_compute_no_history", + warp_call={"fn": self.observation_manager.compute, "kwargs": {"return_cloned_output": False}}, + timer=DEBUG_TIMER_STEP, + ) self.recorder_manager.record_post_step() # -- reset envs that terminated/timed-out and log the episode information From 6f0171290f5cc080a2ea97071eb34ce385d88ff6 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Mon, 26 Oct 2026 03:22:01 -0700 Subject: [PATCH 2/9] Add native offline NeRD for Cartpole and ANYmal C --- docs/index.rst | 1 + .../source/_static/css/environment-browser.js | 9 + docs/source/experimental-features/nerd.rst | 232 +++++++++ scripts/learning/nerd/record.py | 89 ++++ scripts/learning/nerd/train.py | 161 ++++++ .../solver-config-replacement.fixed.rst | 1 + .../custom_coupling/franka_soft_env_cfg.py | 2 - .../changelog.d/native-nerd.added.rst | 9 + .../changelog.d/native-nerd.minor | 0 .../solver-config-replacement.fixed.rst | 2 + .../isaaclab_newton/envs/mdp/__init__.pyi | 4 + .../isaaclab_newton/envs/mdp/recorders.py | 114 +++++ .../isaaclab_newton/physics/__init__.pyi | 4 + .../isaaclab_newton/physics/nerd.py | 474 ++++++++++++++++++ .../isaaclab_newton/physics/nerd_manager.py | 170 +++++++ .../physics/nerd_manager_cfg.py | 29 ++ .../physics/newton_manager_cfg.py | 8 +- .../test_newton_manager_abstraction.py | 2 + source/isaaclab_newton/test/test_nerd.py | 438 ++++++++++++++++ .../changelog.d/native-nerd.added.rst | 5 + .../isaaclab_rl/changelog.d/native-nerd.minor | 0 source/isaaclab_rl/isaaclab_rl/nerd.py | 301 +++++++++++ .../changelog.d/native-nerd.added.rst | 7 + .../changelog.d/native-nerd.minor | 0 .../solver-config-replacement.fixed.rst | 1 + .../velocity/config/digit/rough_env_cfg.py | 4 - .../isaaclab_tasks/core/cartpole/__init__.py | 17 + .../core/cartpole/cartpole_nerd_env_cfg.py | 65 +++ .../core/velocity/config/anymal_c/__init__.py | 41 ++ .../velocity/config/anymal_c/nerd_cfg.yaml | 17 + .../velocity/config/anymal_c/nerd_env_cfg.py | 212 ++++++++ .../core/velocity/velocity_env_cfg.py | 15 +- 32 files changed, 2415 insertions(+), 19 deletions(-) create mode 100644 docs/source/experimental-features/nerd.rst create mode 100644 scripts/learning/nerd/record.py create mode 100644 scripts/learning/nerd/train.py create mode 100644 source/isaaclab_contrib/changelog.d/solver-config-replacement.fixed.rst create mode 100644 source/isaaclab_newton/changelog.d/native-nerd.added.rst create mode 100644 source/isaaclab_newton/changelog.d/native-nerd.minor create mode 100644 source/isaaclab_newton/changelog.d/solver-config-replacement.fixed.rst create mode 100644 source/isaaclab_newton/isaaclab_newton/envs/mdp/recorders.py create mode 100644 source/isaaclab_newton/isaaclab_newton/physics/nerd.py create mode 100644 source/isaaclab_newton/isaaclab_newton/physics/nerd_manager.py create mode 100644 source/isaaclab_newton/isaaclab_newton/physics/nerd_manager_cfg.py create mode 100644 source/isaaclab_newton/test/test_nerd.py create mode 100644 source/isaaclab_rl/changelog.d/native-nerd.added.rst create mode 100644 source/isaaclab_rl/changelog.d/native-nerd.minor create mode 100644 source/isaaclab_rl/isaaclab_rl/nerd.py create mode 100644 source/isaaclab_tasks/changelog.d/native-nerd.added.rst create mode 100644 source/isaaclab_tasks/changelog.d/native-nerd.minor create mode 100644 source/isaaclab_tasks/changelog.d/solver-config-replacement.fixed.rst create mode 100644 source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_nerd_env_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/anymal_c/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/anymal_c/nerd_cfg.yaml create mode 100644 source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/anymal_c/nerd_env_cfg.py diff --git a/docs/index.rst b/docs/index.rst index 78e4932e35b3..aa4517b51ed1 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -165,6 +165,7 @@ Table of Contents source/experimental-features/bleeding-edge source/concepts/warp_environments + source/experimental-features/nerd source/experimental-features/ray source/experimental-features/visuo_tactile_sensor source/experimental-features/rlinf_vla_posttraining diff --git a/docs/source/_static/css/environment-browser.js b/docs/source/_static/css/environment-browser.js index edb297ec0bf1..da4d21f22b2a 100644 --- a/docs/source/_static/css/environment-browser.js +++ b/docs/source/_static/css/environment-browser.js @@ -18,6 +18,9 @@ ["Isaac-Cartpole", "rl_games,rsl_rl,skrl,sb3,torchrl", "isaacsim_physx,newton_kamino,newton_mjwarp,ovphysx", "", "", "environment-previews/cartpole.jpg", true, {}, {}, "", null, ["||", "isaacsim_physx||", "newton_kamino||", "newton_mjwarp||", "ovphysx||"]], ["Isaac-Cartpole-Camera-Direct", "rl_games,rsl_rl,skrl", "isaacsim_physx,newton_kamino,newton_mjwarp,ovphysx", "isaacsim_rtx,newton_renderer,ovrtx", "albedo,depth,rgb,semantic_segmentation,simple_shading_constant_diffuse,simple_shading_diffuse_mdl,simple_shading_full_mdl", "tasks/classic/cartpole.jpg", false, {"*": ["rgb"], "rl_games": ["depth"]}, {}, "", null, ["||", "||albedo", "||depth", "||rgb", "||semantic_segmentation", "|isaacsim_rtx|", "|isaacsim_rtx|albedo", "|isaacsim_rtx|depth", "|isaacsim_rtx|rgb", "|isaacsim_rtx|semantic_segmentation", "|isaacsim_rtx|simple_shading_constant_diffuse", "|isaacsim_rtx|simple_shading_diffuse_mdl", "|isaacsim_rtx|simple_shading_full_mdl", "|newton_renderer|", "|newton_renderer|albedo", "|newton_renderer|depth", "|newton_renderer|rgb", "|newton_renderer|semantic_segmentation", "|ovrtx|", "|ovrtx|albedo", "|ovrtx|depth", "|ovrtx|rgb", "|ovrtx|semantic_segmentation", "|ovrtx|simple_shading_constant_diffuse", "|ovrtx|simple_shading_diffuse_mdl", "|ovrtx|simple_shading_full_mdl", "isaacsim_physx||", "isaacsim_physx||albedo", "isaacsim_physx||depth", "isaacsim_physx||rgb", "isaacsim_physx||semantic_segmentation", "isaacsim_physx|isaacsim_rtx|", "isaacsim_physx|isaacsim_rtx|albedo", "isaacsim_physx|isaacsim_rtx|depth", "isaacsim_physx|isaacsim_rtx|rgb", "isaacsim_physx|isaacsim_rtx|semantic_segmentation", "isaacsim_physx|isaacsim_rtx|simple_shading_constant_diffuse", "isaacsim_physx|isaacsim_rtx|simple_shading_diffuse_mdl", "isaacsim_physx|isaacsim_rtx|simple_shading_full_mdl", "isaacsim_physx|newton_renderer|", "isaacsim_physx|newton_renderer|albedo", "isaacsim_physx|newton_renderer|depth", "isaacsim_physx|newton_renderer|rgb", "isaacsim_physx|newton_renderer|semantic_segmentation", "newton_kamino||", "newton_kamino||albedo", "newton_kamino||depth", "newton_kamino||rgb", "newton_kamino||semantic_segmentation", "newton_kamino|isaacsim_rtx|", "newton_kamino|isaacsim_rtx|albedo", "newton_kamino|isaacsim_rtx|depth", "newton_kamino|isaacsim_rtx|rgb", "newton_kamino|isaacsim_rtx|semantic_segmentation", "newton_kamino|isaacsim_rtx|simple_shading_constant_diffuse", "newton_kamino|isaacsim_rtx|simple_shading_diffuse_mdl", "newton_kamino|isaacsim_rtx|simple_shading_full_mdl", "newton_kamino|newton_renderer|", "newton_kamino|newton_renderer|albedo", "newton_kamino|newton_renderer|depth", "newton_kamino|newton_renderer|rgb", "newton_kamino|newton_renderer|semantic_segmentation", "newton_kamino|ovrtx|", "newton_kamino|ovrtx|albedo", "newton_kamino|ovrtx|depth", "newton_kamino|ovrtx|rgb", "newton_kamino|ovrtx|semantic_segmentation", "newton_kamino|ovrtx|simple_shading_constant_diffuse", "newton_kamino|ovrtx|simple_shading_diffuse_mdl", "newton_kamino|ovrtx|simple_shading_full_mdl", "newton_mjwarp||", "newton_mjwarp||albedo", "newton_mjwarp||depth", "newton_mjwarp||rgb", "newton_mjwarp||semantic_segmentation", "newton_mjwarp|isaacsim_rtx|", "newton_mjwarp|isaacsim_rtx|albedo", "newton_mjwarp|isaacsim_rtx|depth", "newton_mjwarp|isaacsim_rtx|rgb", "newton_mjwarp|isaacsim_rtx|semantic_segmentation", "newton_mjwarp|isaacsim_rtx|simple_shading_constant_diffuse", "newton_mjwarp|isaacsim_rtx|simple_shading_diffuse_mdl", "newton_mjwarp|isaacsim_rtx|simple_shading_full_mdl", "newton_mjwarp|newton_renderer|", "newton_mjwarp|newton_renderer|albedo", "newton_mjwarp|newton_renderer|depth", "newton_mjwarp|newton_renderer|rgb", "newton_mjwarp|newton_renderer|semantic_segmentation", "newton_mjwarp|ovrtx|", "newton_mjwarp|ovrtx|albedo", "newton_mjwarp|ovrtx|depth", "newton_mjwarp|ovrtx|rgb", "newton_mjwarp|ovrtx|semantic_segmentation", "newton_mjwarp|ovrtx|simple_shading_constant_diffuse", "newton_mjwarp|ovrtx|simple_shading_diffuse_mdl", "newton_mjwarp|ovrtx|simple_shading_full_mdl", "ovphysx||", "ovphysx||albedo", "ovphysx||depth", "ovphysx||rgb", "ovphysx||semantic_segmentation", "ovphysx|newton_renderer|", "ovphysx|newton_renderer|albedo", "ovphysx|newton_renderer|depth", "ovphysx|newton_renderer|rgb", "ovphysx|newton_renderer|semantic_segmentation", "ovphysx|ovrtx|", "ovphysx|ovrtx|albedo", "ovphysx|ovrtx|depth", "ovphysx|ovrtx|rgb", "ovphysx|ovrtx|semantic_segmentation", "ovphysx|ovrtx|simple_shading_constant_diffuse", "ovphysx|ovrtx|simple_shading_diffuse_mdl", "ovphysx|ovrtx|simple_shading_full_mdl"]], ["Isaac-Cartpole-Camera", "rl_games,rsl_rl", "isaacsim_physx,newton_kamino,newton_mjwarp,ovphysx", "isaacsim_rtx,newton_renderer,ovrtx", "albedo,depth,resnet18,rgb,semantic_segmentation,simple_shading_constant_diffuse,simple_shading_diffuse_mdl,simple_shading_full_mdl,theia_tiny", "tasks/classic/cartpole.jpg", false, {"*": ["rgb"], "rsl_rl": ["resnet18", "theia_tiny"]}, {}, "", null, ["||", "||albedo", "||depth", "||resnet18", "||rgb", "||semantic_segmentation", "||theia_tiny", "|isaacsim_rtx|", "|isaacsim_rtx|albedo", "|isaacsim_rtx|depth", "|isaacsim_rtx|resnet18", "|isaacsim_rtx|rgb", "|isaacsim_rtx|semantic_segmentation", "|isaacsim_rtx|simple_shading_constant_diffuse", "|isaacsim_rtx|simple_shading_diffuse_mdl", "|isaacsim_rtx|simple_shading_full_mdl", "|isaacsim_rtx|theia_tiny", "|newton_renderer|", "|newton_renderer|albedo", "|newton_renderer|depth", "|newton_renderer|resnet18", "|newton_renderer|rgb", "|newton_renderer|semantic_segmentation", "|newton_renderer|theia_tiny", "|ovrtx|", "|ovrtx|albedo", "|ovrtx|depth", "|ovrtx|resnet18", "|ovrtx|rgb", "|ovrtx|semantic_segmentation", "|ovrtx|simple_shading_constant_diffuse", "|ovrtx|simple_shading_diffuse_mdl", "|ovrtx|simple_shading_full_mdl", "|ovrtx|theia_tiny", "isaacsim_physx||", "isaacsim_physx||albedo", "isaacsim_physx||depth", "isaacsim_physx||resnet18", "isaacsim_physx||rgb", "isaacsim_physx||semantic_segmentation", "isaacsim_physx||theia_tiny", "isaacsim_physx|isaacsim_rtx|", "isaacsim_physx|isaacsim_rtx|albedo", "isaacsim_physx|isaacsim_rtx|depth", "isaacsim_physx|isaacsim_rtx|resnet18", "isaacsim_physx|isaacsim_rtx|rgb", "isaacsim_physx|isaacsim_rtx|semantic_segmentation", "isaacsim_physx|isaacsim_rtx|simple_shading_constant_diffuse", "isaacsim_physx|isaacsim_rtx|simple_shading_diffuse_mdl", "isaacsim_physx|isaacsim_rtx|simple_shading_full_mdl", "isaacsim_physx|isaacsim_rtx|theia_tiny", "isaacsim_physx|newton_renderer|", "isaacsim_physx|newton_renderer|albedo", "isaacsim_physx|newton_renderer|depth", "isaacsim_physx|newton_renderer|resnet18", "isaacsim_physx|newton_renderer|rgb", "isaacsim_physx|newton_renderer|semantic_segmentation", "isaacsim_physx|newton_renderer|theia_tiny", "newton_kamino||", "newton_kamino||albedo", "newton_kamino||depth", "newton_kamino||resnet18", "newton_kamino||rgb", "newton_kamino||semantic_segmentation", "newton_kamino||theia_tiny", "newton_kamino|isaacsim_rtx|", "newton_kamino|isaacsim_rtx|albedo", "newton_kamino|isaacsim_rtx|depth", "newton_kamino|isaacsim_rtx|resnet18", "newton_kamino|isaacsim_rtx|rgb", "newton_kamino|isaacsim_rtx|semantic_segmentation", "newton_kamino|isaacsim_rtx|simple_shading_constant_diffuse", "newton_kamino|isaacsim_rtx|simple_shading_diffuse_mdl", "newton_kamino|isaacsim_rtx|simple_shading_full_mdl", "newton_kamino|isaacsim_rtx|theia_tiny", "newton_kamino|newton_renderer|", "newton_kamino|newton_renderer|albedo", "newton_kamino|newton_renderer|depth", "newton_kamino|newton_renderer|resnet18", "newton_kamino|newton_renderer|rgb", "newton_kamino|newton_renderer|semantic_segmentation", "newton_kamino|newton_renderer|theia_tiny", "newton_kamino|ovrtx|", "newton_kamino|ovrtx|albedo", "newton_kamino|ovrtx|depth", "newton_kamino|ovrtx|resnet18", "newton_kamino|ovrtx|rgb", "newton_kamino|ovrtx|semantic_segmentation", "newton_kamino|ovrtx|simple_shading_constant_diffuse", "newton_kamino|ovrtx|simple_shading_diffuse_mdl", "newton_kamino|ovrtx|simple_shading_full_mdl", "newton_kamino|ovrtx|theia_tiny", "newton_mjwarp||", "newton_mjwarp||albedo", "newton_mjwarp||depth", "newton_mjwarp||resnet18", "newton_mjwarp||rgb", "newton_mjwarp||semantic_segmentation", "newton_mjwarp||theia_tiny", "newton_mjwarp|isaacsim_rtx|", "newton_mjwarp|isaacsim_rtx|albedo", "newton_mjwarp|isaacsim_rtx|depth", "newton_mjwarp|isaacsim_rtx|resnet18", "newton_mjwarp|isaacsim_rtx|rgb", "newton_mjwarp|isaacsim_rtx|semantic_segmentation", "newton_mjwarp|isaacsim_rtx|simple_shading_constant_diffuse", "newton_mjwarp|isaacsim_rtx|simple_shading_diffuse_mdl", "newton_mjwarp|isaacsim_rtx|simple_shading_full_mdl", "newton_mjwarp|isaacsim_rtx|theia_tiny", "newton_mjwarp|newton_renderer|", "newton_mjwarp|newton_renderer|albedo", "newton_mjwarp|newton_renderer|depth", "newton_mjwarp|newton_renderer|resnet18", "newton_mjwarp|newton_renderer|rgb", "newton_mjwarp|newton_renderer|semantic_segmentation", "newton_mjwarp|newton_renderer|theia_tiny", "newton_mjwarp|ovrtx|", "newton_mjwarp|ovrtx|albedo", "newton_mjwarp|ovrtx|depth", "newton_mjwarp|ovrtx|resnet18", "newton_mjwarp|ovrtx|rgb", "newton_mjwarp|ovrtx|semantic_segmentation", "newton_mjwarp|ovrtx|simple_shading_constant_diffuse", "newton_mjwarp|ovrtx|simple_shading_diffuse_mdl", "newton_mjwarp|ovrtx|simple_shading_full_mdl", "newton_mjwarp|ovrtx|theia_tiny", "ovphysx||", "ovphysx||albedo", "ovphysx||depth", "ovphysx||resnet18", "ovphysx||rgb", "ovphysx||semantic_segmentation", "ovphysx||theia_tiny", "ovphysx|newton_renderer|", "ovphysx|newton_renderer|albedo", "ovphysx|newton_renderer|depth", "ovphysx|newton_renderer|resnet18", "ovphysx|newton_renderer|rgb", "ovphysx|newton_renderer|semantic_segmentation", "ovphysx|newton_renderer|theia_tiny", "ovphysx|ovrtx|", "ovphysx|ovrtx|albedo", "ovphysx|ovrtx|depth", "ovphysx|ovrtx|resnet18", "ovphysx|ovrtx|rgb", "ovphysx|ovrtx|semantic_segmentation", "ovphysx|ovrtx|simple_shading_constant_diffuse", "ovphysx|ovrtx|simple_shading_diffuse_mdl", "ovphysx|ovrtx|simple_shading_full_mdl", "ovphysx|ovrtx|theia_tiny"]], + ["Isaac-Cartpole-NeRD", "rsl_rl", "", "", "", "environment-previews/cartpole.jpg"], + ["Isaac-Cartpole-NeRD-Explore", "rsl_rl", "", "", "", "environment-previews/cartpole.jpg"], + ["Isaac-Cartpole-NeRD-Learned", "rsl_rl", "", "", "", "environment-previews/cartpole.jpg"], ["Isaac-Fourbar-Pole-Swingup", "rsl_rl", "newton_kamino", "", "", "environment-previews/fourbar_pole.jpg", false, {}, {}, "", null, ["||", "newton_kamino||"]], ["Isaac-Humanoid-Direct", "rl_games,rsl_rl,skrl", "isaacsim_physx,newton_mjwarp,ovphysx", "", "", "environment-previews/humanoid.jpg", true, {}, {}, "", null, ["||", "isaacsim_physx||", "newton_mjwarp||", "ovphysx||"]], ["Isaac-Humanoid", "rl_games,rsl_rl,skrl,sb3", "isaacsim_physx,newton_mjwarp,ovphysx", "", "", "environment-previews/humanoid.jpg", true, {}, {}, "", null, ["||", "isaacsim_physx||", "newton_mjwarp||", "ovphysx||"]], @@ -46,11 +49,17 @@ ["Isaac-Reorient-KukaAllegro-Camera", "rsl_rl", "isaacsim_physx,newton_mjwarp,ovphysx", "isaacsim_rtx,newton_renderer,ovrtx", "albedo128,albedo256,albedo64,cube,depth128,depth256,depth64,duo_camera,raycaster_depth128,raycaster_depth256,raycaster_depth64,rgb128,rgb256,rgb64,semantic_segmentation128,semantic_segmentation256,semantic_segmentation64,shapes,simple_shading_constant_diffuse128,simple_shading_constant_diffuse256,simple_shading_constant_diffuse64,simple_shading_diffuse_mdl128,simple_shading_diffuse_mdl256,simple_shading_diffuse_mdl64,simple_shading_full_mdl128,simple_shading_full_mdl256,simple_shading_full_mdl64,single_camera", "environment-previews/kuka_allegro_reorient.jpg", false, {"*": ["rgb64", "shapes", "single_camera"]}, {}, "", null, ["||", "||albedo128", "||albedo256", "||albedo64", "||cube", "||depth128", "||depth256", "||depth64", "||duo_camera", "||raycaster_depth128", "||raycaster_depth256", "||raycaster_depth64", "||rgb128", "||rgb256", "||rgb64", "||semantic_segmentation128", "||semantic_segmentation256", "||semantic_segmentation64", "||shapes", "||single_camera", "|isaacsim_rtx|", "|isaacsim_rtx|albedo128", "|isaacsim_rtx|albedo256", "|isaacsim_rtx|albedo64", "|isaacsim_rtx|cube", "|isaacsim_rtx|depth128", "|isaacsim_rtx|depth256", "|isaacsim_rtx|depth64", "|isaacsim_rtx|duo_camera", "|isaacsim_rtx|rgb128", "|isaacsim_rtx|rgb256", "|isaacsim_rtx|rgb64", "|isaacsim_rtx|semantic_segmentation128", "|isaacsim_rtx|semantic_segmentation256", "|isaacsim_rtx|semantic_segmentation64", "|isaacsim_rtx|shapes", "|isaacsim_rtx|simple_shading_constant_diffuse128", "|isaacsim_rtx|simple_shading_constant_diffuse256", "|isaacsim_rtx|simple_shading_constant_diffuse64", "|isaacsim_rtx|simple_shading_diffuse_mdl128", "|isaacsim_rtx|simple_shading_diffuse_mdl256", "|isaacsim_rtx|simple_shading_diffuse_mdl64", "|isaacsim_rtx|simple_shading_full_mdl128", "|isaacsim_rtx|simple_shading_full_mdl256", "|isaacsim_rtx|simple_shading_full_mdl64", "|isaacsim_rtx|single_camera", "|newton_renderer|", "|newton_renderer|albedo128", "|newton_renderer|albedo256", "|newton_renderer|albedo64", "|newton_renderer|cube", "|newton_renderer|depth128", "|newton_renderer|depth256", "|newton_renderer|depth64", "|newton_renderer|duo_camera", "|newton_renderer|rgb128", "|newton_renderer|rgb256", "|newton_renderer|rgb64", "|newton_renderer|semantic_segmentation128", "|newton_renderer|semantic_segmentation256", "|newton_renderer|semantic_segmentation64", "|newton_renderer|shapes", "|newton_renderer|single_camera", "|ovrtx|", "|ovrtx|albedo128", "|ovrtx|albedo256", "|ovrtx|albedo64", "|ovrtx|cube", "|ovrtx|depth128", "|ovrtx|depth256", "|ovrtx|depth64", "|ovrtx|duo_camera", "|ovrtx|rgb128", "|ovrtx|rgb256", "|ovrtx|rgb64", "|ovrtx|semantic_segmentation128", "|ovrtx|semantic_segmentation256", "|ovrtx|semantic_segmentation64", "|ovrtx|shapes", "|ovrtx|simple_shading_constant_diffuse128", "|ovrtx|simple_shading_constant_diffuse256", "|ovrtx|simple_shading_constant_diffuse64", "|ovrtx|simple_shading_diffuse_mdl128", "|ovrtx|simple_shading_diffuse_mdl256", "|ovrtx|simple_shading_diffuse_mdl64", "|ovrtx|simple_shading_full_mdl128", "|ovrtx|simple_shading_full_mdl256", "|ovrtx|simple_shading_full_mdl64", "|ovrtx|single_camera", "isaacsim_physx||", "isaacsim_physx||albedo128", "isaacsim_physx||albedo256", "isaacsim_physx||albedo64", "isaacsim_physx||cube", "isaacsim_physx||depth128", "isaacsim_physx||depth256", "isaacsim_physx||depth64", "isaacsim_physx||duo_camera", "isaacsim_physx||raycaster_depth128", "isaacsim_physx||raycaster_depth256", "isaacsim_physx||raycaster_depth64", "isaacsim_physx||rgb128", "isaacsim_physx||rgb256", "isaacsim_physx||rgb64", "isaacsim_physx||semantic_segmentation128", "isaacsim_physx||semantic_segmentation256", "isaacsim_physx||semantic_segmentation64", "isaacsim_physx||shapes", "isaacsim_physx||single_camera", "isaacsim_physx|isaacsim_rtx|", "isaacsim_physx|isaacsim_rtx|albedo128", "isaacsim_physx|isaacsim_rtx|albedo256", "isaacsim_physx|isaacsim_rtx|albedo64", "isaacsim_physx|isaacsim_rtx|cube", "isaacsim_physx|isaacsim_rtx|depth128", "isaacsim_physx|isaacsim_rtx|depth256", "isaacsim_physx|isaacsim_rtx|depth64", "isaacsim_physx|isaacsim_rtx|duo_camera", "isaacsim_physx|isaacsim_rtx|rgb128", "isaacsim_physx|isaacsim_rtx|rgb256", "isaacsim_physx|isaacsim_rtx|rgb64", "isaacsim_physx|isaacsim_rtx|semantic_segmentation128", "isaacsim_physx|isaacsim_rtx|semantic_segmentation256", "isaacsim_physx|isaacsim_rtx|semantic_segmentation64", "isaacsim_physx|isaacsim_rtx|shapes", "isaacsim_physx|isaacsim_rtx|simple_shading_constant_diffuse128", "isaacsim_physx|isaacsim_rtx|simple_shading_constant_diffuse256", "isaacsim_physx|isaacsim_rtx|simple_shading_constant_diffuse64", "isaacsim_physx|isaacsim_rtx|simple_shading_diffuse_mdl128", "isaacsim_physx|isaacsim_rtx|simple_shading_diffuse_mdl256", "isaacsim_physx|isaacsim_rtx|simple_shading_diffuse_mdl64", "isaacsim_physx|isaacsim_rtx|simple_shading_full_mdl128", "isaacsim_physx|isaacsim_rtx|simple_shading_full_mdl256", "isaacsim_physx|isaacsim_rtx|simple_shading_full_mdl64", "isaacsim_physx|isaacsim_rtx|single_camera", "isaacsim_physx|newton_renderer|", "isaacsim_physx|newton_renderer|albedo128", "isaacsim_physx|newton_renderer|albedo256", "isaacsim_physx|newton_renderer|albedo64", "isaacsim_physx|newton_renderer|cube", "isaacsim_physx|newton_renderer|depth128", "isaacsim_physx|newton_renderer|depth256", "isaacsim_physx|newton_renderer|depth64", "isaacsim_physx|newton_renderer|duo_camera", "isaacsim_physx|newton_renderer|rgb128", "isaacsim_physx|newton_renderer|rgb256", "isaacsim_physx|newton_renderer|rgb64", "isaacsim_physx|newton_renderer|semantic_segmentation128", "isaacsim_physx|newton_renderer|semantic_segmentation256", "isaacsim_physx|newton_renderer|semantic_segmentation64", "isaacsim_physx|newton_renderer|shapes", "isaacsim_physx|newton_renderer|single_camera", "newton_mjwarp||", "newton_mjwarp||albedo128", "newton_mjwarp||albedo256", "newton_mjwarp||albedo64", "newton_mjwarp||cube", "newton_mjwarp||depth128", "newton_mjwarp||depth256", "newton_mjwarp||depth64", "newton_mjwarp||duo_camera", "newton_mjwarp||raycaster_depth128", "newton_mjwarp||raycaster_depth256", "newton_mjwarp||raycaster_depth64", "newton_mjwarp||rgb128", "newton_mjwarp||rgb256", "newton_mjwarp||rgb64", "newton_mjwarp||semantic_segmentation128", "newton_mjwarp||semantic_segmentation256", "newton_mjwarp||semantic_segmentation64", "newton_mjwarp||shapes", "newton_mjwarp||single_camera", "newton_mjwarp|isaacsim_rtx|", "newton_mjwarp|isaacsim_rtx|albedo128", "newton_mjwarp|isaacsim_rtx|albedo256", "newton_mjwarp|isaacsim_rtx|albedo64", "newton_mjwarp|isaacsim_rtx|cube", "newton_mjwarp|isaacsim_rtx|depth128", "newton_mjwarp|isaacsim_rtx|depth256", "newton_mjwarp|isaacsim_rtx|depth64", "newton_mjwarp|isaacsim_rtx|duo_camera", "newton_mjwarp|isaacsim_rtx|rgb128", "newton_mjwarp|isaacsim_rtx|rgb256", "newton_mjwarp|isaacsim_rtx|rgb64", "newton_mjwarp|isaacsim_rtx|semantic_segmentation128", "newton_mjwarp|isaacsim_rtx|semantic_segmentation256", "newton_mjwarp|isaacsim_rtx|semantic_segmentation64", "newton_mjwarp|isaacsim_rtx|shapes", "newton_mjwarp|isaacsim_rtx|simple_shading_constant_diffuse128", "newton_mjwarp|isaacsim_rtx|simple_shading_constant_diffuse256", "newton_mjwarp|isaacsim_rtx|simple_shading_constant_diffuse64", "newton_mjwarp|isaacsim_rtx|simple_shading_diffuse_mdl128", "newton_mjwarp|isaacsim_rtx|simple_shading_diffuse_mdl256", "newton_mjwarp|isaacsim_rtx|simple_shading_diffuse_mdl64", "newton_mjwarp|isaacsim_rtx|simple_shading_full_mdl128", "newton_mjwarp|isaacsim_rtx|simple_shading_full_mdl256", "newton_mjwarp|isaacsim_rtx|simple_shading_full_mdl64", "newton_mjwarp|isaacsim_rtx|single_camera", "newton_mjwarp|newton_renderer|", "newton_mjwarp|newton_renderer|albedo128", "newton_mjwarp|newton_renderer|albedo256", "newton_mjwarp|newton_renderer|albedo64", "newton_mjwarp|newton_renderer|cube", "newton_mjwarp|newton_renderer|depth128", "newton_mjwarp|newton_renderer|depth256", "newton_mjwarp|newton_renderer|depth64", "newton_mjwarp|newton_renderer|duo_camera", "newton_mjwarp|newton_renderer|rgb128", "newton_mjwarp|newton_renderer|rgb256", "newton_mjwarp|newton_renderer|rgb64", "newton_mjwarp|newton_renderer|semantic_segmentation128", "newton_mjwarp|newton_renderer|semantic_segmentation256", "newton_mjwarp|newton_renderer|semantic_segmentation64", "newton_mjwarp|newton_renderer|shapes", "newton_mjwarp|newton_renderer|single_camera", "newton_mjwarp|ovrtx|", "newton_mjwarp|ovrtx|albedo128", "newton_mjwarp|ovrtx|albedo256", "newton_mjwarp|ovrtx|albedo64", "newton_mjwarp|ovrtx|cube", "newton_mjwarp|ovrtx|depth128", "newton_mjwarp|ovrtx|depth256", "newton_mjwarp|ovrtx|depth64", "newton_mjwarp|ovrtx|duo_camera", "newton_mjwarp|ovrtx|rgb128", "newton_mjwarp|ovrtx|rgb256", "newton_mjwarp|ovrtx|rgb64", "newton_mjwarp|ovrtx|semantic_segmentation128", "newton_mjwarp|ovrtx|semantic_segmentation256", "newton_mjwarp|ovrtx|semantic_segmentation64", "newton_mjwarp|ovrtx|shapes", "newton_mjwarp|ovrtx|simple_shading_constant_diffuse128", "newton_mjwarp|ovrtx|simple_shading_constant_diffuse256", "newton_mjwarp|ovrtx|simple_shading_constant_diffuse64", "newton_mjwarp|ovrtx|simple_shading_diffuse_mdl128", "newton_mjwarp|ovrtx|simple_shading_diffuse_mdl256", "newton_mjwarp|ovrtx|simple_shading_diffuse_mdl64", "newton_mjwarp|ovrtx|simple_shading_full_mdl128", "newton_mjwarp|ovrtx|simple_shading_full_mdl256", "newton_mjwarp|ovrtx|simple_shading_full_mdl64", "newton_mjwarp|ovrtx|single_camera", "ovphysx||", "ovphysx||albedo128", "ovphysx||albedo256", "ovphysx||albedo64", "ovphysx||cube", "ovphysx||depth128", "ovphysx||depth256", "ovphysx||depth64", "ovphysx||duo_camera", "ovphysx||raycaster_depth128", "ovphysx||raycaster_depth256", "ovphysx||raycaster_depth64", "ovphysx||rgb128", "ovphysx||rgb256", "ovphysx||rgb64", "ovphysx||semantic_segmentation128", "ovphysx||semantic_segmentation256", "ovphysx||semantic_segmentation64", "ovphysx||shapes", "ovphysx||single_camera", "ovphysx|newton_renderer|", "ovphysx|newton_renderer|albedo128", "ovphysx|newton_renderer|albedo256", "ovphysx|newton_renderer|albedo64", "ovphysx|newton_renderer|cube", "ovphysx|newton_renderer|depth128", "ovphysx|newton_renderer|depth256", "ovphysx|newton_renderer|depth64", "ovphysx|newton_renderer|duo_camera", "ovphysx|newton_renderer|rgb128", "ovphysx|newton_renderer|rgb256", "ovphysx|newton_renderer|rgb64", "ovphysx|newton_renderer|semantic_segmentation128", "ovphysx|newton_renderer|semantic_segmentation256", "ovphysx|newton_renderer|semantic_segmentation64", "ovphysx|newton_renderer|shapes", "ovphysx|newton_renderer|single_camera", "ovphysx|ovrtx|", "ovphysx|ovrtx|albedo128", "ovphysx|ovrtx|albedo256", "ovphysx|ovrtx|albedo64", "ovphysx|ovrtx|cube", "ovphysx|ovrtx|depth128", "ovphysx|ovrtx|depth256", "ovphysx|ovrtx|depth64", "ovphysx|ovrtx|duo_camera", "ovphysx|ovrtx|rgb128", "ovphysx|ovrtx|rgb256", "ovphysx|ovrtx|rgb64", "ovphysx|ovrtx|semantic_segmentation128", "ovphysx|ovrtx|semantic_segmentation256", "ovphysx|ovrtx|semantic_segmentation64", "ovphysx|ovrtx|shapes", "ovphysx|ovrtx|simple_shading_constant_diffuse128", "ovphysx|ovrtx|simple_shading_constant_diffuse256", "ovphysx|ovrtx|simple_shading_constant_diffuse64", "ovphysx|ovrtx|simple_shading_diffuse_mdl128", "ovphysx|ovrtx|simple_shading_diffuse_mdl256", "ovphysx|ovrtx|simple_shading_diffuse_mdl64", "ovphysx|ovrtx|simple_shading_full_mdl128", "ovphysx|ovrtx|simple_shading_full_mdl256", "ovphysx|ovrtx|simple_shading_full_mdl64", "ovphysx|ovrtx|single_camera"]], ["Isaac-Shadow-Handover-Direct", "rl_games,rsl_rl,skrl", "isaacsim_physx,newton_mjwarp,ovphysx", "", "", "environment-previews/shadow_hand_over.jpg", false, {}, {"skrl": "MAPPO"}, "", null, ["||", "isaacsim_physx||", "newton_mjwarp||", "ovphysx||"]], ["Isaac-Shadow-Handover", "rsl_rl", "isaacsim_physx,newton_mjwarp,ovphysx", "", "randomized", "environment-previews/shadow_hand_over.jpg", false, {}, {}, "", null, ["||", "||randomized", "isaacsim_physx||", "isaacsim_physx||randomized", "newton_mjwarp||", "newton_mjwarp||randomized", "ovphysx||", "ovphysx||randomized"]], + ["Isaac-Velocity-Flat-Anymal-C-NeRD", "rsl_rl", "", "", ""], + ["Isaac-Velocity-Flat-Anymal-C-NeRD-Explore", "rsl_rl", "", "", ""], + ["Isaac-Velocity-Flat-Anymal-C-NeRD-Learned", "rsl_rl", "", "", ""], ["Isaac-Velocity-Flat-AnymalD", "rsl_rl,skrl", "isaacsim_physx,newton_kamino,newton_mjwarp,ovphysx", "", "", "environment-previews/anymal_d_flat.jpg", true, {}, {}, "", null, ["||", "isaacsim_physx||", "newton_kamino||", "newton_mjwarp||", "ovphysx||"]], ["Isaac-Velocity-Flat-Cassie", "rsl_rl,skrl", "isaacsim_physx,newton_kamino,newton_mjwarp,ovphysx", "", "", "environment-previews/cassie_flat.jpg", true, {}, {}, "", null, ["||", "isaacsim_physx||", "newton_kamino||", "newton_mjwarp||", "ovphysx||"]], ["Isaac-Velocity-Flat-G1", "rsl_rl,skrl", "isaacsim_physx,newton_kamino,newton_mjwarp,ovphysx", "", "", "environment-previews/g1_flat.jpg", true, {}, {}, "", null, ["||", "isaacsim_physx||", "newton_kamino||", "newton_mjwarp||", "ovphysx||"]], ["Isaac-Velocity-Flat-H1", "rsl_rl,skrl", "isaacsim_physx,newton_kamino,newton_mjwarp,ovphysx", "", "", "environment-previews/h1_flat.jpg", true, {}, {}, "", null, ["||", "isaacsim_physx||", "newton_kamino||", "newton_mjwarp||", "ovphysx||"]], ["Isaac-Velocity-Flat-UnitreeGo2", "rsl_rl,skrl", "isaacsim_physx,newton_kamino,newton_mjwarp,ovphysx", "", "", "environment-previews/go2_flat.jpg", true, {}, {}, "", null, ["||", "isaacsim_physx||", "newton_kamino||", "newton_mjwarp||", "ovphysx||"]], + ["Isaac-Velocity-Rough-Anymal-C-NeRD", "rsl_rl", "", "", ""], + ["Isaac-Velocity-Rough-Anymal-C-NeRD-Explore", "rsl_rl", "", "", ""], + ["Isaac-Velocity-Rough-Anymal-C-NeRD-Learned", "rsl_rl", "", "", ""], ["Isaac-Velocity-Rough-AnymalD", "rsl_rl,skrl", "isaacsim_physx,newton_kamino,newton_mjwarp,ovphysx", "", "", "tasks/locomotion/anymal_d_rough.jpg", false, {}, {}, "", null, ["||", "isaacsim_physx||", "newton_kamino||", "newton_mjwarp||", "ovphysx||"]], ["Isaac-Velocity-Rough-Cassie", "rsl_rl,skrl", "isaacsim_physx,newton_kamino,newton_mjwarp,ovphysx", "", "", "tasks/cassie_rough.jpg", false, {}, {}, "", null, ["||", "isaacsim_physx||", "newton_kamino||", "newton_mjwarp||", "ovphysx||"]], ["Isaac-Velocity-Rough-G1", "rsl_rl,skrl", "isaacsim_physx,newton_kamino,newton_mjwarp,ovphysx", "", "", "tasks/locomotion/g1_rough.jpg", false, {}, {}, "", null, ["||", "isaacsim_physx||", "newton_kamino||", "newton_mjwarp||", "ovphysx||"]], diff --git a/docs/source/experimental-features/nerd.rst b/docs/source/experimental-features/nerd.rst new file mode 100644 index 000000000000..4870992e7cb5 --- /dev/null +++ b/docs/source/experimental-features/nerd.rst @@ -0,0 +1,232 @@ +.. _nerd: + +Offline NeRD: Cartpole and ANYmal C +=================================== + +The registered tasks live in ``isaaclab_tasks.core.cartpole.cartpole_nerd_env_cfg`` and +``isaaclab_tasks.core.velocity.config.anymal_c.nerd_env_cfg``. Each has a numerical teacher, +a task-independent ``-Explore`` configuration, and a ``-Learned`` configuration for policy +training/playback. They use ordinary action, observation, reward and termination managers. +The exploration tasks configure ``NeRDRecorderCfg``; teacher and learned playback leave capture +disabled by default. Algorithm settings are registered under ``nerd_cfg_entry_point`` and are +selected from the recording's task metadata, without launching a simulation during offline fitting. + +The shared Newton representation and solver live in ``isaaclab_newton.physics``. The recorder +term lives in ``isaaclab_newton.envs.mdp.recorders``. ``isaaclab_rl.nerd`` owns offline window +sampling and optimization. The two scripts under ``scripts/learning/nerd`` are composition roots +for collection and fitting; policy learning uses the existing reinforcement-learning scripts. +There is no additional integration package or external task launcher. + +NeRD physics always consumes applied joint efforts from ``Control.joint_f``. The actuator owns +position/velocity commands, delays, internal state and motor-effort clipping. It runs before each +physics step, and capture copies its final effort at that same solver boundary. There is no +target-conditioned physics mode or recorder control selector. Solver-side motor drives, including +damping drives, and MuJoCo-specific actuators are rejected because they bypass this boundary. +External body wrenches remain unsupported; passive physical forces are part of the fixed model. + +Cartpole records at 120 Hz, with its damping and effort limits implemented by a Newton actuator. +ANYmal C records every 5 ms after ANYdrive evaluates and clips its effort. Its state features use +the root heading frame, body-frame gravity and four fixed foot-sphere probes. The learned task +keeps ANYdrive and advances learned physics four times per 20 ms policy action. Each NeRD step +predicts one physics transition, then Newton forward kinematics updates body state. + +Capture requires ``decimation=1`` and ``num_substeps=1``; policy training can use decimation to +repeat actuator evaluation and learned physics. Both numerical and learned physics enable CUDA +graphs. The learned manager retains a private Torch allocation pool for its captured temporaries. +The existing ANYdrive implementation steps outside the graph, with learned physics replayed at +each 5 ms tick. Position and velocity controllers are supported when their efforts are computed +upstream; NeRD does not predict motor effort as an additional output. + +Rough terrain and live contacts +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``Isaac-Velocity-Rough-Anymal-C-NeRD`` and its ``-Explore`` / ``-Learned`` variants use the +same 5 ms applied-effort contract with Newton collision detection enabled. NeRD replaces the +numerical dynamics solve; collision detection, ANYdrive and forward kinematics remain. +The exploration task uses terrain-aware reset patches to avoid spawning inside raised terrain. +The policy observations are proprioceptive; these tasks do not add a terrain-height observation. + +Set ``NeRDRecorderCfg.max_contacts`` to a positive capacity to select PhysicsNeMo Robotics' +``RigidContactSetEncoder``. It captures the collision pipeline's pre-solver contacts after +actuator evaluation, without rerunning collision detection. Each packed contact contains validity, +body/other-body identity, dynamic-body status, point, normal, lever arm, signed gap and relative +velocity. The learner converts these to typed, permutation-invariant contact sets: only valid +continuous geometry is normalized; identities and masks retain their semantics. Contact and state +histories use the same episode boundaries and repeated reset prefixes. Capacity overflow stops +capture or deployment instead of silently dropping constraints. + +Terrain geometry can change between collection and deployment because it is represented through +live contacts. The checkpoint still fixes robot geometry, inertias, gains, limits, collision filtering, +gravity and the uniform external contact material. This contract does not support independently +moving external objects or per-surface material variation. Wrench accuracy is a separate validation +requirement; enabling contact conditioning does not enable learned force sensors. + +.. code-block:: bash + + uv run --no-sync python scripts/learning/nerd/record.py \ + --task Isaac-Velocity-Rough-Anymal-C-NeRD-Explore \ + --output logs/nerd/rough/exploration.hdf5 --num_envs 128 --steps 1600 \ + --action_scale 0.75 --action_hold 4 + uv run --no-sync python scripts/learning/nerd/train.py \ + --dataset logs/nerd/rough/exploration.hdf5 --output logs/nerd/rough/model \ + --wandb_project --wandb_entity + +``--action_hold`` sets the interval between action updates while retaining every physics transition. +For locomotion data, ``--policy /path/to/exported/policy.pt`` accepts a feedforward TorchScript +policy consuming the task's ``policy`` observations. Match its observation layout, joint ordering +and action scale through task configuration. A policy trained in PhysX may need +``env.scene.robot.joint_ordering=physx`` when collecting with Newton. Multiple compatible +datasets can be passed together to ``train.py --dataset``. Use separate terrain seeds for final +evaluation; the learner's episode split alone does not establish terrain generalization. + +Collect and fit +~~~~~~~~~~~~~~~ + +Install the optional PhysicsNeMo Robotics dependency described above. These commands use +``--no-sync`` to preserve that separately installed experimental dependency: + +.. code-block:: bash + + uv run --no-sync python scripts/learning/nerd/record.py \ + --task Isaac-Cartpole-NeRD-Explore --output logs/nerd/cartpole/data.hdf5 \ + --num_envs 1024 --steps 1500 + uv run --no-sync python scripts/learning/nerd/train.py \ + --dataset logs/nerd/cartpole/data.hdf5 --output logs/nerd/cartpole/model \ + --wandb_project --wandb_entity + + uv run --no-sync python scripts/learning/nerd/record.py \ + --task Isaac-Velocity-Flat-Anymal-C-NeRD-Explore --output logs/nerd/anymal/data.hdf5 \ + --num_envs 4096 --steps 2400 --action_scale 1.5 --action_hold 4 + uv run --no-sync python scripts/learning/nerd/train.py \ + --dataset logs/nerd/anymal/data.hdf5 --output logs/nerd/anymal/model \ + --wandb_project --wandb_entity + +The default Cartpole recipe uses 30,000 updates and ten input frames. ANYmal C uses 60,000 +updates and thirteen frames, with its network geometry configured alongside the task. The +training loop samples history from flattened device-resident transitions, so memory does not +multiply by history length. Data must still fit on the selected device. ``--config`` accepts +JSON overrides of ``NeRDTrainCfg``; a supplied ``network`` dictionary replaces the task's +network geometry. For a smoke test, collect 32 steps in four worlds and fit +with ``--max_updates 3 --batch_size 16 --log_interval 1 --save_interval 1``. + +Collection writes native HDF5 episodes, a JSON representation descriptor, and the resolved +task YAML beside the dataset. Keep those files together. The recorder publishes preallocated +``state``, ``next_state``, ``control``, ``contacts``, ``terminated`` and ``truncated`` tensors. +Contact features come from the pre-state; applied effort is copied immediately before the solver; the terminal +successor is read before reset. An online consumer can read ``latest`` with +``record_to_episodes=False`` and feed replay or a prediction-error objective without a writer. + +Fitting splits complete episodes, repeats the first frame for reset prefixes, includes terminal +transitions, and rejects discontinuous episodes. It reports training/held-out normalized MSE +and held-out delta MSE. These measure dynamics prediction, not policy task performance. + +``last.pt`` contains network weights, fixed normalization, physical model identity, frame and +control conventions, optimizer state, sampler state and progress. Saves are atomic, happen at +least every 120 seconds between updates, and are uploaded synchronously when W&B is enabled. +SIGINT/SIGTERM finishes the current update and saves. Resume with: + +.. code-block:: bash + + uv run --no-sync python scripts/learning/nerd/train.py \ + --dataset logs/nerd/anymal/data.hdf5 --output logs/nerd/anymal/model \ + --checkpoint logs/nerd/anymal/model/last.pt --max_updates 80000 \ + --wandb_project --wandb_entity + +The checkpoint owns the normalization statistics. Data, split, network and optimization settings +must match; the update budget can increase without changing the learning-rate decay schedule. + +Model and precision options +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``network_type`` selects the PhysicsNeMo network class. ParallelNeRD is the default. To use +VectorNeRD, supply a JSON configuration such as: + +.. code-block:: json + + { + "network_type": "physicsnemo_robotics.models.nerd:NewtonNeRDVectorTransformer", + "network": { + "num_heads": 8, "hidden_size": 192, "head_hidden": 64, + "temporal_depth": 4, "position_encoding": "relative_lag", + "temporal_attention_spans": [4, 3, 3, 3] + } + } + +``precision`` accepts ``float32`` (default), ``bfloat16`` or ``float16``. Parameters, normalized +targets and losses remain FP32. FP16 uses a saved gradient scaler and retries a skipped update +with the same samples; a nonfinite forward prediction stops training. ParallelNeRD FP16 requires +the dependency's attention matrix multiplications to disable autocast explicitly. The original +integration's runtime monkeypatch is not used. Development validation used PhysicsNeMo Robotics +revision ``a81160ef`` plus the attention-accumulation fix ``ee6d2e0``. That fix is an unpublished +dependency; the earlier revision alone is insufficient for safe FP16 fitting. + +Launch distributed fitting through Torch's standard launcher: + +.. code-block:: bash + + uv run --no-sync python -m torch.distributed.run --standalone --nproc_per_node=2 \ + scripts/learning/nerd/train.py --dataset logs/nerd/anymal/data.hdf5 \ + --output logs/nerd/anymal/distributed --batch_size 4096 + +The batch size is global and must be divisible by the rank count. Each rank holds the dataset, +draws an independent sample stream and participates in gradient averaging. Validation is +partitioned across ranks. Rank zero owns logs, checkpoint publication and W&B uploads. +Checkpoints save every rank's sampler state; resume requires the same rank count. + +Train and evaluate a policy +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Select the learned task and pass its dynamics checkpoint through normal task overrides: + +.. code-block:: bash + + uv run --no-sync python scripts/reinforcement_learning/train.py \ + --rl_library rsl_rl --task Isaac-Cartpole-NeRD-Learned --num_envs 4096 \ + env.sim.physics.solver_cfg.checkpoint=logs/nerd/cartpole/model/last.pt + uv run --no-sync python scripts/reinforcement_learning/train.py \ + --rl_library rsl_rl --task Isaac-Velocity-Flat-Anymal-C-NeRD-Learned --num_envs 4096 \ + env.sim.physics.solver_cfg.checkpoint=logs/nerd/anymal/model/last.pt + + uv run --no-sync python scripts/reinforcement_learning/play.py \ + --rl_library rsl_rl --task Isaac-Velocity-Flat-Anymal-C-NeRD \ + --checkpoint /path/to/policy/model_299.pt --num_envs 16 --video --video_length 600 + +The last command evaluates the learned-physics policy in MuJoCo Warp. To evaluate the same +policy in learned physics, use ``-Learned`` and the same dynamics-checkpoint override. The +policy observation/action layouts and objectives agree between the two configurations. +ANYmal's acceleration penalty uses the control period in both solvers. Contact-force rewards, +torque penalties and physics randomizations absent from the model's inputs are disabled in both. + +Scope and migration +~~~~~~~~~~~~~~~~~~~ + +These are joint-backed rigid scenes with fixed physical parameters. The solver rejects a changed +physical model, unsupported timing, external body wrenches and solver-side motor drives. +Physical identity includes fixed-root orientation/height, body flags, collision filtering and +per-world topology. Horizontal reset-origin translations are ignored only for translation-invariant +flat scenes. Version-2 checkpoints include architecture, gradient-scaler and per-rank sampler +state. Recollect/refit datasets whose older physical fingerprint omitted these properties. +Target-conditioned datasets and checkpoints are rejected. The former flat ANYmal ``-Effort-Explore`` +and ``-Effort-Learned`` tasks are now the standard ``-Explore`` and ``-Learned`` tasks, with no +compatibility aliases. Their applied-effort checkpoints retain the same input and physical contract. +Cartpole datasets and checkpoints collected with the former solver-side damping drive must be +recollected and refit for its actuator-owned damping. +ANYmal's flat-model fixed-plane probes do not represent rough terrain. Use the rough task family +to collect and fit the live-contact representation; flat and rough checkpoints are not interchangeable. + +Native HDF5 and checkpoints replace the experimental ``isaaclab_physicsnemo`` Zarr/artifact +launchers. Existing artifacts are not silently converted or treated as compatible. Historical +training results from that branch are not new validation of this workflow. The native integration +tests cover both tasks, terminal capture and retained values, offline update/resume, and repeated +learned-solver graph replay with asynchronous resets and intervening tensor allocations. +They also cover clipped actuator effort retained before buffer reuse, rejection of legacy target +models and solver-side drives, VectorNeRD, FP16 update/resume and two-rank CPU gradient +averaging against an independent combined-batch update. These are integration checks, not +long-horizon policy benchmarks or a multi-GPU scaling measurement. +The rough case also exercises live contact capture, contact-conditioned fitting and resume, +deployment with a different terrain and world count, and rejection of changed contact friction. + +Joint wrenches and contact forces are not learned outputs. Reconstruction requires the physical +model, contact law and acceleration in addition to position/velocity. Endpoint state alone does +not generally determine a unique contact-force distribution. Analytic reconstruction is being +validated separately and is not exposed as a learned-solver sensor capability. diff --git a/scripts/learning/nerd/record.py b/scripts/learning/nerd/record.py new file mode 100644 index 000000000000..a4ce5a08db47 --- /dev/null +++ b/scripts/learning/nerd/record.py @@ -0,0 +1,89 @@ +# 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 + +"""Collect a registered task's native NeRD transitions into an HDF5 dataset.""" + +import argparse +import json +from pathlib import Path + +import gymnasium as gym +import torch + +from isaaclab.app import add_launcher_args, launch_simulation +from isaaclab.managers import DatasetExportMode +from isaaclab.utils.assets import read_file +from isaaclab.utils.io import dump_yaml + +import isaaclab_tasks # noqa: F401 +from isaaclab_tasks.utils import resolve_task_config, setup_preset_cli + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--task", required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--num_envs", type=int, default=1024) + parser.add_argument("--steps", type=int, default=1500) + parser.add_argument("--action_scale", type=float, default=1.5, help="Uniform normalized action bound.") + parser.add_argument( + "--policy", help="Exported feedforward TorchScript policy path or URL, consuming policy observations." + ) + parser.add_argument("--action_hold", type=int, default=1, help="Environment steps between action updates.") + parser.add_argument("--seed", type=int, default=42) + add_launcher_args(parser) + args, overrides = setup_preset_cli(parser) + if min(args.num_envs, args.steps, args.action_hold) < 1 or args.action_scale <= 0: + parser.error("World/step counts, action hold and action scale must be positive.") + path = args.output.resolve() + if path.suffix != ".hdf5": + parser.error("--output must end in .hdf5.") + if path.exists() or path.with_suffix(".json").exists(): + parser.error("The output dataset already exists; choose another path.") + cfg, _ = resolve_task_config(args.task, "", overrides=overrides) + if cfg.recorders.transition is None: + parser.error("Choose a task with a configured NeRD transition recorder, such as a -Explore task.") + cfg.scene.num_envs = args.num_envs + cfg.sim.device = args.device + cfg.seed = args.seed + cfg.recorders.transition.record_to_episodes = True + cfg.recorders.dataset_export_mode = DatasetExportMode.EXPORT_ALL + cfg.recorders.dataset_export_dir_path = str(path.parent) + cfg.recorders.dataset_filename = path.stem + cfg.recorders.export_in_close = True + path.parent.mkdir(parents=True, exist_ok=True) + dump_yaml(str(path.with_suffix(".yaml")), cfg) + torch.manual_seed(args.seed) + with launch_simulation(cfg, args): + env = gym.make(args.task, cfg=cfg).unwrapped + try: + observations, _ = env.reset() + policy = torch.jit.load(read_file(args.policy), map_location=env.device).eval() if args.policy else None + capture = env.recorder_manager.get_term("transition") + metadata = { + "task": args.task, + "seed": args.seed, + "action_scale": args.action_scale, + "policy": args.policy, + "action_hold": args.action_hold, + "dynamics": capture.dynamics.descriptor, + } + path.with_suffix(".json").write_text(json.dumps(metadata, indent=2) + "\n") + with torch.inference_mode(): + actions = torch.empty(env.num_envs, env.action_manager.total_action_dim, device=env.device) + for step in range(args.steps): + if step % args.action_hold == 0: + if policy is None: + actions.uniform_(-args.action_scale, args.action_scale) + else: + actions.copy_(policy(observations["policy"])) + observations, _, _, _, _ = env.step(actions) + finally: + env.close() + print(f"Recorded native transitions to {path}") + + +if __name__ == "__main__": + main() diff --git a/scripts/learning/nerd/train.py b/scripts/learning/nerd/train.py new file mode 100644 index 000000000000..e0efff01a8e3 --- /dev/null +++ b/scripts/learning/nerd/train.py @@ -0,0 +1,161 @@ +# 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 + +"""Fit temporal NeRD to native HDF5 episodes, with resumable checkpoints and optional W&B uploads.""" + +import argparse +import json +import os +import signal +import time +from contextlib import nullcontext +from pathlib import Path + +import torch +import torch.distributed as dist + +from isaaclab.utils import to_dict + +from isaaclab_rl.nerd import NeRDTrainCfg, OfflineNeRD + +import isaaclab_tasks # noqa: F401 +from isaaclab_tasks.utils import load_cfg_from_registry + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dataset", type=Path, nargs="+", required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--config", type=Path, help="JSON overrides of NeRDTrainCfg, including network geometry.") + parser.add_argument("--max_updates", type=int) + parser.add_argument("--batch_size", type=int) + parser.add_argument("--device", default="cuda:0") + parser.add_argument("--checkpoint", type=Path, help="Resume a native checkpoint.") + parser.add_argument("--save_interval", type=int, default=1000) + parser.add_argument("--save_seconds", type=float, default=120.0) + parser.add_argument("--log_interval", type=int, default=100) + parser.add_argument("--wandb_project") + parser.add_argument("--wandb_entity") + parser.add_argument("--wandb_name") + args = parser.parse_args() + if min(args.save_interval, args.save_seconds, args.log_interval) <= 0: + parser.error("Checkpoint and logging intervals must be positive.") + distributed = int(os.environ.get("WORLD_SIZE", "1")) > 1 + if distributed: + if args.device.startswith("cuda"): + args.device = f"cuda:{os.environ['LOCAL_RANK']}" + torch.cuda.set_device(args.device) + dist.init_process_group(backend="nccl" if args.device.startswith("cuda") else "gloo") + rank = dist.get_rank() if distributed else 0 + task = json.loads(args.dataset[0].with_suffix(".json").read_text())["task"] + task_cfg = load_cfg_from_registry(task, "nerd_cfg_entry_point") + settings = task_cfg if isinstance(task_cfg, dict) else to_dict(task_cfg) + if args.config: + settings.update(json.loads(args.config.read_text())) + cfg = NeRDTrainCfg(**settings) + if args.max_updates is not None: + cfg.max_updates = args.max_updates + if args.batch_size is not None: + cfg.batch_size = args.batch_size + args.output.mkdir(parents=True, exist_ok=True) + target = args.output / "last.pt" + if target.exists() and not args.checkpoint: + parser.error("Output contains last.pt; use --checkpoint to resume or choose another output.") + saved = torch.load(args.checkpoint, map_location="cpu", weights_only=True) if args.checkpoint else None + settings = to_dict(cfg) + if saved: + previous = saved["training_cfg"] + if any(previous[key] != value for key, value in settings.items() if key not in ("max_updates", "compile")): + parser.error("Resume requires unchanged optimization settings, split and model geometry.") + learner = OfflineNeRD(args.dataset, cfg, args.device, checkpoint=saved) + if learner.updates >= cfg.max_updates: + parser.error("The requested update limit has already been reached.") + logging = nullcontext(None) + if args.wandb_project and rank == 0: + import wandb + + identity = saved.get("wandb") if saved else None + resume_identity = ( + identity + and identity["project"] == args.wandb_project + and (args.wandb_entity is None or identity["entity"] == args.wandb_entity) + ) + logging = wandb.init( + project=args.wandb_project, + entity=identity["entity"] if resume_identity else args.wandb_entity, + name=args.wandb_name, + id=identity["id"] if resume_identity else None, + resume="allow", + config=settings, + allow_val_change=True, + dir=str(args.output), + ) + stopped = False + + def stop(signum, frame): + nonlocal stopped + stopped = True + + handlers = {signum: signal.signal(signum, stop) for signum in (signal.SIGINT, signal.SIGTERM)} + try: + metrics_file = (args.output / "metrics.jsonl").open("a") if rank == 0 else nullcontext(None) + with logging as run, metrics_file as metrics: + if run: + run.define_metric("update") + run.define_metric("*", step_metric="update") + last_save = time.monotonic() + while learner.updates < cfg.max_updates: + values = learner.update() + checkpoint_due = ( + learner.updates % args.save_interval == 0 or time.monotonic() - last_save >= args.save_seconds + ) + finished = learner.updates == cfg.max_updates or stopped + if distributed: + decisions = torch.tensor([checkpoint_due, finished], dtype=torch.int32, device=args.device) + dist.all_reduce(decisions, op=dist.ReduceOp.MAX) + checkpoint_due, finished = decisions.tolist() + if learner.updates % args.log_interval == 0 or finished or checkpoint_due: + values |= learner.validate() + values["update"] = learner.updates + if metrics: + metrics.write(json.dumps(values) + "\n") + metrics.flush() + print(json.dumps(values), flush=True) + if run: + run.log(values) + if checkpoint_due or finished: + checkpoint = learner.checkpoint() + if rank == 0: + checkpoint["training_cfg"] = settings + checkpoint["wandb"] = ( + {"id": run.id, "project": run.project, "entity": run.entity} if run else None + ) + temporary = target.with_suffix(".tmp") + with temporary.open("wb") as stream: + torch.save(checkpoint, stream) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, target) + if run: + artifact = wandb.Artifact(f"nerd-{run.id}", type="model", metadata={"update": learner.updates}) + artifact.add_file(str(target), name="last.pt") + logged = run.log_artifact(artifact, aliases=["latest", f"update-{learner.updates}"]) + if run.settings.mode != "offline": + logged.wait(timeout=180) + run.log({"update": learner.updates, "checkpoint/durable_update": learner.updates}) + last_save = time.monotonic() + if finished: + break + finally: + for signum, handler in handlers.items(): + signal.signal(signum, handler) + + +if __name__ == "__main__": + try: + main() + finally: + if dist.is_initialized(): + dist.destroy_process_group() diff --git a/source/isaaclab_contrib/changelog.d/solver-config-replacement.fixed.rst b/source/isaaclab_contrib/changelog.d/solver-config-replacement.fixed.rst new file mode 100644 index 000000000000..e2561e4261e4 --- /dev/null +++ b/source/isaaclab_contrib/changelog.d/solver-config-replacement.fixed.rst @@ -0,0 +1 @@ +* Removed the custom coupling preset workaround for copying the derived Newton manager class. diff --git a/source/isaaclab_contrib/isaaclab_contrib/custom_coupling/franka_soft_env_cfg.py b/source/isaaclab_contrib/isaaclab_contrib/custom_coupling/franka_soft_env_cfg.py index 0a112f2e733f..4059c560837f 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/custom_coupling/franka_soft_env_cfg.py +++ b/source/isaaclab_contrib/isaaclab_contrib/custom_coupling/franka_soft_env_cfg.py @@ -21,8 +21,6 @@ class PhysicsCfg(CorePhysicsCfg): newton_mjwarp_vbd = replace( CorePhysicsCfg().newton_mjwarp_vbd_proxy, - # Required: ``NewtonCfg.__post_init__`` rejects a preset class_type and re-derives it. - class_type=None, solver_cfg=CoupledMJWarpVBDSolverCfg( rigid_solver_cfg=MJWarpSolverCfg( njmax=40, nconmax=20, ls_iterations=20, integrator="implicitfast", ccd_iterations=100 diff --git a/source/isaaclab_newton/changelog.d/native-nerd.added.rst b/source/isaaclab_newton/changelog.d/native-nerd.added.rst new file mode 100644 index 000000000000..5763df59d9a4 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/native-nerd.added.rst @@ -0,0 +1,9 @@ +* Added native NeRD transition recording and a graph-enabled learned Newton solver, shared by + Cartpole and ANYmal C. Physical-model, physics-step and reset-history contracts are checked + against native training checkpoints. PhysicsNeMo Robotics is an optional experimental dependency. +* NeRD consumes only applied joint efforts, captured after actuator evaluation and clipping and before + the solver runs. Capture/deployment guards reject solver-side motor drives and external body wrenches. + Physical identity retains fixed-root geometry, body flags and collision filtering; policy decimation + repeats the actuator and learned physics step. Target-conditioned models are rejected. +* Added shared live Newton contact encoding for rough-terrain recording and learned deployment, + with synchronized reset histories, explicit capacity checks and fixed contact-material identity. diff --git a/source/isaaclab_newton/changelog.d/native-nerd.minor b/source/isaaclab_newton/changelog.d/native-nerd.minor new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_newton/changelog.d/solver-config-replacement.fixed.rst b/source/isaaclab_newton/changelog.d/solver-config-replacement.fixed.rst new file mode 100644 index 000000000000..3657beafee84 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/solver-config-replacement.fixed.rst @@ -0,0 +1,2 @@ +* Made ``NewtonCfg.class_type`` derived constructor state so replacing ``solver_cfg`` selects + the new manager without copying a stale manager class into the configuration constructor. diff --git a/source/isaaclab_newton/isaaclab_newton/envs/mdp/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/envs/mdp/__init__.pyi index a0b4c9f49d3d..4ee2bb50b321 100644 --- a/source/isaaclab_newton/isaaclab_newton/envs/mdp/__init__.pyi +++ b/source/isaaclab_newton/isaaclab_newton/envs/mdp/__init__.pyi @@ -14,6 +14,9 @@ __all__ = [ "NewtonOperationalSpaceControllerAction", "NewtonOperationalSpaceControllerActionCfg", "randomize_visual_shape", + "NeRDRecorder", + "NeRDRecorderCfg", + "NeRDRecorderManagerCfg", ] from .actions import ( @@ -30,3 +33,4 @@ from .events import ( randomize_rigid_body_material, randomize_visual_shape, ) +from .recorders import NeRDRecorder, NeRDRecorderCfg, NeRDRecorderManagerCfg diff --git a/source/isaaclab_newton/isaaclab_newton/envs/mdp/recorders.py b/source/isaaclab_newton/isaaclab_newton/envs/mdp/recorders.py new file mode 100644 index 000000000000..c72ba75b0bad --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/envs/mdp/recorders.py @@ -0,0 +1,114 @@ +# 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 + +"""Native recorder terms for generalized-coordinate learned dynamics.""" + +from contextlib import nullcontext + +import torch +import warp as wp + +from isaaclab.managers import DatasetExportMode, RecorderManagerBaseCfg, RecorderTerm, RecorderTermCfg +from isaaclab.utils import configclass + + +@configclass +class NeRDRecorderCfg(RecorderTermCfg): + """Select the transition representation independently of its consumer or storage.""" + + class_type: str = "{DIR}.recorders:NeRDRecorder" + heading: bool = False + contact_bodies: tuple[str, ...] = () + root_body: str = "" + max_contacts: int = 0 + """Positive capacity selects live Newton contact sets instead of fixed plane probes.""" + record_to_episodes: bool = False + """Retain transitions through RecorderManager; streaming consumers read ``latest`` either way.""" + + +@configclass +class NeRDRecorderManagerCfg(RecorderManagerBaseCfg): + """Optional native dynamics capture; task playback has no capture cost by default.""" + + dataset_export_mode: DatasetExportMode = DatasetExportMode.EXPORT_NONE + transition: NeRDRecorderCfg | None = None + + +class NeRDRecorder(RecorderTerm): + """Capture the terminal successor before autoreset into persistent device tensors. + + ``latest`` is valid until the next step. Replay and curiosity consumers copy the fields they + retain; this term owns neither a dataset writer nor an episode/replay buffer. + """ + + requires_post_step_observations = False + + def __init__(self, cfg, env): + super().__init__(cfg, env) + from isaaclab_newton.physics.nerd import JointDynamics + + self.manager = env.sim.backend_manager + if env.cfg.decimation != 1 or env.cfg.sim.physics.num_substeps != 1: + raise ValueError("Effort recording requires one physics step per environment step.") + model = self.manager.get_model() + self.dynamics = JointDynamics( + model=model, + frame_dt=env.step_dt, + heading=cfg.heading, + contact_bodies=cfg.contact_bodies, + root_body=cfg.root_body, + max_contacts=cfg.max_contacts, + ) + self.control = self.manager.get_control() + self.contacts = self.manager.get_contacts() + self._torch_pool = torch.cuda.MemPool() if cfg.max_contacts and model.device.is_cuda else None + if cfg.max_contacts and not self.manager._needs_collision_pipeline: + raise ValueError("Newton contact capture requires the Newton collision pipeline.") + self.manager.register_state_force_callback(self._capture_solver_inputs) + width = self.dynamics.codec.state_shape[0] + self.latest = { + "state": torch.empty(self.num_envs, width, device=self.device), + "next_state": torch.empty(self.num_envs, width, device=self.device), + "control": torch.empty(self.num_envs, self.dynamics.control_dim, device=self.device), + "contacts": torch.empty(self.num_envs, *self.dynamics.contact_shape, device=self.device), + "terminated": torch.empty(self.num_envs, dtype=torch.bool, device=self.device), + "truncated": torch.empty(self.num_envs, dtype=torch.bool, device=self.device), + } + + self._has_transition = False + + def record_pre_step(self): + state = self.manager.get_scene_data_backend().state + self.dynamics.codec.read_into(state, self.latest["state"]) + if not self.cfg.max_contacts: + self.latest["contacts"].copy_(self.dynamics.contacts(state)) + return None, None + + def _capture_solver_inputs(self, state): + device = state.joint_q.device + stream = torch.cuda.stream(wp.stream_to_torch(wp.get_stream(device))) if device.is_cuda else nullcontext() + pool = ( + torch.cuda.use_mem_pool(self._torch_pool, device=device.alias) + if self._torch_pool is not None + else nullcontext() + ) + with stream, pool: + self.dynamics.check_inputs(state) + self.latest["control"].copy_(wp.to_torch(self.control.joint_f).reshape(self.num_envs, -1)) + if self.cfg.max_contacts: + self.latest["contacts"].copy_(self.dynamics.contacts(state, self.contacts)) + + def record_post_step(self): + self.dynamics.codec.read_into(self.manager.get_state_0(), self.latest["next_state"]) + self.latest["terminated"].copy_(self._env.reset_terminated) + self.latest["truncated"].copy_(self._env.reset_time_outs) + self._has_transition = True + return ("transition", self.latest) if self.cfg.record_to_episodes else (None, None) + + def record_pre_reset(self, env_ids): + if self._has_transition: + ids = slice(None) if env_ids is None else env_ids + self.latest["truncated"][ids] |= ~self.latest["terminated"][ids] + return None, None diff --git a/source/isaaclab_newton/isaaclab_newton/physics/__init__.pyi b/source/isaaclab_newton/isaaclab_newton/physics/__init__.pyi index 4a18a1fcc015..ad6f2c43fbaf 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/__init__.pyi +++ b/source/isaaclab_newton/isaaclab_newton/physics/__init__.pyi @@ -17,6 +17,7 @@ __all__ = [ "KaminoPADMMSolverCfg", "MPMSolverCfg", "MJWarpSolverCfg", + "NeRDSolverCfg", "MjWarpActuatorBridge", "NewtonCfg", "NewtonBackendCfg", @@ -28,6 +29,7 @@ __all__ = [ "NewtonManager", "NewtonQueries", "NewtonMJWarpManager", + "NewtonNeRDManager", "NewtonShapeCfg", "NewtonSoftContactCfg", "NewtonSolverCfg", @@ -57,6 +59,8 @@ from .mjwarp_manager import NewtonMJWarpManager from .mjwarp_manager_cfg import MJWarpSolverCfg from .mpm_manager import NewtonMPMManager from .mpm_manager_cfg import MPMSolverCfg +from .nerd_manager import NewtonNeRDManager +from .nerd_manager_cfg import NeRDSolverCfg from .newton_collision_cfg import HydroelasticSDFCfg, NewtonCollisionPipelineCfg from .newton_manager import NewtonManager, NewtonQueries, create_newton_builder from .newton_manager_cfg import ( diff --git a/source/isaaclab_newton/isaaclab_newton/physics/nerd.py b/source/isaaclab_newton/isaaclab_newton/physics/nerd.py new file mode 100644 index 000000000000..101d90872cdb --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/physics/nerd.py @@ -0,0 +1,474 @@ +# 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 + +"""Shared NeRD representation for native recording, offline fitting and Newton inference. + +PhysicsNeMo Robotics owns the joint codec, contact encoders and network. Isaac Lab owns the +recorded physics-step contract and the heading convention used by its locomotion tasks. +Import this module only when learned dynamics is selected; task discovery needs no PhysicsNeMo. +""" + +from __future__ import annotations + +import copy +import hashlib +import json +from contextlib import nullcontext +from dataclasses import fields + +import newton +import numpy as np +import torch +import warp as wp +from physicsnemo_robotics.dynamics import ( + BodyHeadingFrame, + BodyStateCodec, + FixedContactFeatureAdapter, + FixedRigidContactEncoder, + JointStateCodec, + RigidContactSetEncoder, + expand_rigid_contact_shapes, +) +from physicsnemo_robotics.dynamics.codecs.joint import JointLayout +from physicsnemo_robotics.newton import NewtonStepContext + +from isaaclab.utils.math import normalize, quat_apply, quat_apply_inverse, quat_conjugate, quat_mul + + +class HeadingJointStateCodec(JointStateCodec): + """Store world state, encode inputs and deltas in the current root's swing/twist heading frame.""" + + def __init__(self, model=None, *, layout=None): + super().__init__(model, layout=layout) + if ( + not self.layout.root_is_free + or self.layout.up_axis_index != 2 + or self.layout.free_root_translation_q_starts != (0,) + or self.layout.quaternion_q_starts != (3,) + ): + raise ValueError("Heading dynamics requires one z-up free root at the start of the joint coordinates.") + + def encode_state(self, state): + return super().encode_state(self._to_heading(state, state)[0]) + + def state_to_delta(self, current, next_state): + return super().state_to_delta(self._to_heading(current, current)[0], self._to_heading(next_state, current)[0]) + + def delta_to_state(self, current, delta): + local, heading, origin = self._to_heading(current, current) + local = super().delta_to_state(local, delta) + q, qd = local[..., : self.layout.dof_q], local[..., self.layout.dof_q :] + return self.canonicalize_state( + torch.cat( + ( + quat_apply(heading, q[..., :3]) + origin, + quat_mul(heading, q[..., 3:7]), + q[..., 7:], + quat_apply(heading, qd[..., :3]), + quat_apply(heading, qd[..., 3:6]), + qd[..., 6:], + ), + dim=-1, + ) + ) + + def _to_heading(self, state, anchor): + twist = torch.cat((torch.zeros_like(anchor[..., 3:5]), anchor[..., 5:7]), dim=-1) + identity = torch.zeros_like(twist) + identity[..., 3] = 1.0 + heading = normalize(torch.where(twist.norm(dim=-1, keepdim=True) >= 1.0e-8, twist, identity)) + heading = torch.where(heading[..., 3:] < 0, -heading, heading) + origin = torch.cat((anchor[..., :2], torch.zeros_like(anchor[..., 2:3])), dim=-1) + q, qd = state[..., : self.layout.dof_q], state[..., self.layout.dof_q :] + local = torch.cat( + ( + quat_apply_inverse(heading, q[..., :3] - origin), + quat_mul(quat_conjugate(heading), q[..., 3:7]), + q[..., 7:], + quat_apply_inverse(heading, qd[..., :3]), + quat_apply_inverse(heading, qd[..., 3:6]), + qd[..., 6:], + ), + dim=-1, + ) + return local, heading, origin + + +class JointDynamics: + """One representation used on stored tensors and, optionally, a live Newton model. + + Applied joint efforts are the sole control input; actuators run outside learned physics. + Only uniform, joint-backed rigid worlds are supported. Contact inputs are fixed plane probes + or live Newton rigid contact sets, recomputed from each predicted state during deployment. + The descriptor is JSON data, so offline training does not initialize a simulation. + """ + + def __init__( + self, + descriptor=None, + model=None, + *, + frame_dt=None, + heading=False, + contact_bodies=(), + root_body="", + max_contacts=0, + ): + if descriptor is None: + if max_contacts < 0 or (max_contacts and contact_bodies): + raise ValueError("Select fixed contact_bodies or a positive Newton max_contacts capacity.") + if ( + model.particle_count + or model.body_count != model.joint_count + or model.articulation_count != model.world_count + ): + raise ValueError("NeRD requires one complete joint-backed rigid articulation per world.") + self.codec = HeadingJointStateCodec(model) if heading else JointStateCodec(model) + layout = {} + for field in fields(self.codec.layout): + if field.init and field.name not in ("q_indices", "qd_indices", "semantic_signature"): + value = getattr(self.codec.layout, field.name) + layout[field.name] = value.tolist() if isinstance(value, torch.Tensor) else value + layout["world_count"] = 1 + descriptor = { + "version": 2, + "frame_dt": frame_dt, + "control": "joint_f", + "heading": heading, + "contact_bodies": list(contact_bodies), + "root_body": root_body, + "max_contacts": max_contacts, + "contact_entities": [ + model.body_label[i].rsplit("/", 1)[-1] for i in np.flatnonzero(model.body_world.numpy() == 0) + ] + if max_contacts + else [], + "layout": layout, + "model": model_signature(model, contact_conditioned=bool(max_contacts)), + } + else: + if descriptor["version"] != 2: + raise ValueError("NeRD requires descriptor version 2; recollect data with complete physical identity.") + if descriptor["control"] != "joint_f": + raise ValueError("NeRD requires applied joint efforts; recollect and refit target-conditioned data.") + layout = dict(descriptor["layout"]) + for name in ("continuous_q_mask", "root_translation_mask"): + layout[name] = torch.tensor(layout[name], dtype=torch.bool) + for name in ("quaternion_q_starts", "free_root_translation_q_starts"): + layout[name] = tuple(layout[name]) + layout["world_count"] = 1 if model is None else model.world_count + codec_type = HeadingJointStateCodec if descriptor["heading"] else JointStateCodec + self.codec = codec_type(layout=JointLayout(**layout)) if model is None else codec_type(model) + self.descriptor = descriptor + self.max_contacts = descriptor.get("max_contacts", 0) + if model is not None: + if ( + np.any(model.joint_target_ke.numpy()) + or np.any(model.joint_target_kd.numpy()) + or model.custom_frequency_counts.get("mujoco:actuator", 0) + ): + raise ValueError( + "NeRD requires all motor effort in joint_f; solver-side drives are unsupported. " + "Compute and clip efforts in the actuator before each physics step." + ) + self._input_valid = torch.empty((), dtype=torch.bool, device=model.device.alias) + self.contact_encoder = self.contact_adapter = None + if model is not None and self.max_contacts: + body_worlds = model.body_world.numpy() + indices = np.stack([np.flatnonzero(body_worlds == i) for i in range(model.world_count)]) + names = [model.body_label[i].rsplit("/", 1)[-1] for i in indices[0]] + if names != descriptor["contact_entities"]: + raise ValueError("NeRD contact body order differs from the recorded representation.") + body_codec = BodyStateCodec( + model, + indices=indices, + semantic_ids=tuple(names), + model_frame=BodyHeadingFrame(body=names.index(descriptor["root_body"])) + if descriptor["heading"] + else None, + ) + self.contact_encoder = RigidContactSetEncoder(model, body_codec, max_contacts=self.max_contacts) + elif model is not None and descriptor["contact_bodies"]: + # The probe library compares local geometry across replicas. USD world-transform + # roundoff is removed from a private view; live model arrays are never replaced. + geometry = copy.copy(model) + transforms = model.shape_transform.numpy().astype(np.float64) + transforms[np.abs(transforms) < 1.0e-8] = 0.0 + geometry.shape_transform = wp.array(np.round(transforms, 6), dtype=wp.transform, device=model.device) + body_ids = np.flatnonzero(model.body_world.numpy() == 0) + body_names = [model.body_label[i].rsplit("/", 1)[-1] for i in body_ids] + shape_ids = np.flatnonzero(model.shape_world.numpy() == 0) + shape_body, shape_type = model.shape_body.numpy(), model.shape_type.numpy() + feet = [] + for name in descriptor["contact_bodies"]: + body = body_ids[body_names.index(name)] + slots = [ + i + for i, s in enumerate(shape_ids) + if shape_body[s] == body and shape_type[s] == newton.GeoType.SPHERE + ] + if len(slots) != 1: + raise ValueError(f"NeRD contact body {name!r} needs exactly one sphere.") + feet.append(slots[0]) + planes = [ + i for i, s in enumerate(shape_ids) if shape_body[s] == -1 and shape_type[s] == newton.GeoType.PLANE + ] + if len(planes) != 1: + raise ValueError("NeRD fixed contacts need exactly one plane in each world.") + body_codec = BodyStateCodec( + geometry, model_frame=BodyHeadingFrame(body=body_names.index(descriptor["root_body"])) + ) + self.contact_encoder = FixedRigidContactEncoder( + geometry, + body_codec, + points=expand_rigid_contact_shapes(geometry, body_codec, feet), + environment_shape_slots=planes, + environment_geometry="per_world", + key="contact/probes", + ) + self.contact_adapter = FixedContactFeatureAdapter( + self.contact_encoder, ("point", "normal", "lever_arm", "gap", "relative_velocity", "contact_state") + ) + + @property + def control_dim(self): + return self.codec.layout.dof_qd + + @property + def contact_dim(self): + return 14 * len(self.descriptor["contact_bodies"]) + + @property + def contact_shape(self): + return (self.max_contacts, 17) if self.max_contacts else (self.contact_dim,) + + @property + def contact_model_cfg(self): + if not self.max_contacts: + return {} + return dict( + contact_dim=17, + max_contacts=self.max_contacts, + num_contact_entities=len(self.descriptor["contact_entities"]), + ) + + @property + def input_dim(self): + return ( + self.codec.state_shape[0] + self.control_dim + (3 if self.descriptor["heading"] else 0) + self.contact_dim + ) + + def inputs(self, state, control, contacts): + """Encode one frame or a batch of sequences without discarding autograd.""" + values = [self.codec.encode_state(state), control] + if self.descriptor["heading"]: + gravity = torch.zeros_like(state[..., :3]) + gravity[..., 2] = -1.0 + values.append(quat_apply_inverse(normalize(state[..., 3:7]), gravity)) + if not self.max_contacts: + values.append(contacts) + return torch.cat(values, dim=-1) + + def contacts(self, state, contacts=None): + """Encode starting-state geometry; variable contacts must come from the live collision pipeline.""" + if self.max_contacts: + dt = self.descriptor["frame_dt"] + context = NewtonStepContext(state, state, None, contacts, 0, 0, dt, dt) + values = self.contact_encoder.encode_step(context) + torch._assert_async((self.contact_encoder.last_overflow == 0).all(), "NeRD contact capacity overflowed.") + return values + if self.contact_encoder is None: + return torch.empty(self.codec.batch_size, 0, device=state.joint_q.device.alias) + return self.contact_adapter.encode_tokens(self.contact_encoder.encode_state(state)) + + @staticmethod + def contact_tokens(values, mean, scale): + """Normalize only valid continuous geometry; endpoint indices and masks retain their types.""" + tokens = RigidContactSetEncoder.to_variable_contact_set(values) + tokens.geometry.sub_(mean).div_(scale).mul_(tokens.valid.unsqueeze(-1)) + return tokens + + def check_inputs(self, state): + """Reject unrepresented inputs at the same solver boundary during capture and deployment.""" + # Persistent reduction outputs also work inside the numerical teacher's Warp CUDA graph. + device = state.body_f.device + stream = torch.cuda.stream(wp.stream_to_torch(wp.get_stream(device))) if device.is_cuda else nullcontext() + with stream: + torch.any(wp.to_torch(state.body_f), out=self._input_valid) + torch._assert_async(self._input_valid.logical_not_(), "NeRD has no external body-wrench input.") + + +def model_signature(model: newton.Model, *, contact_conditioned: bool = False) -> str: + """Fingerprint the supported rigid model without mutating it or including solver/viewer attributes. + + Replicated worlds must have the same physical parameters. Local transform roundoff from USD + placement is tolerated at 1e-6; masses, gains, limits and friction remain exact. Horizontal + translation is ignored only in scenes with no static geometry except horizontal planes. + Fixed-root orientation and height remain physical parameters. Mesh geometry is included by content. + Live-contact models instead fix robot geometry and uniform external material, allowing static + terrain geometry and floating-root placement to vary through the encoded state and contacts. + """ + digest = hashlib.sha256() + collision_shapes = (model.shape_flags.numpy() & int(newton.ShapeFlags.COLLIDE_SHAPES)) != 0 + shape_types, shape_bodies = model.shape_type.numpy(), model.shape_body.numpy() + transforms = model.shape_transform.numpy() + static = collision_shapes & (shape_bodies == -1) + horizontal = (shape_types == newton.GeoType.PLANE) & np.all(np.abs(transforms[:, 3:5]) < 1.0e-8, axis=1) + translation_invariant = np.all(horizontal[static]) + if contact_conditioned: + # Geometry is a live input. Its contact law is not: every external surface must share it. + for name in ( + "margin", + "gap", + "material_ke", + "material_kd", + "material_mu", + "material_kf", + "material_ka", + "material_restitution", + "material_mu_torsional", + "material_mu_rolling", + ): + values = getattr(model, "shape_" + name).numpy()[static] + if not len(values) or not np.all(values == values[0]): + raise ValueError("Contact-conditioned NeRD requires one uniform external contact material.") + digest.update(name.encode()) + digest.update(np.asarray(values[0]).tobytes()) + collision_shapes &= ~static + groups = { + "body": ("mass", "inertia", "com", "flags"), + "joint": ("type", "dof_dim", "enabled", "X_p", "X_c"), + "shape": ( + "type", + "scale", + "transform", + "margin", + "gap", + "material_ke", + "material_kd", + "material_mu", + "material_kf", + "material_ka", + "material_restitution", + "material_mu_torsional", + "material_mu_rolling", + ), + } + for entity, names in groups.items(): + worlds = getattr(model, entity + "_world").numpy().copy() + if entity == "shape": + # Rendering can add world-positioned visual meshes. They do not change rigid dynamics. + worlds[~collision_shapes] = -2 + counts = np.bincount(worlds[worlds >= 0], minlength=model.world_count) + if np.any(counts != counts[0]): + raise ValueError(f"NeRD needs identical {entity} counts in each world.") + local = np.flatnonzero(worlds >= 0) + ids = local[np.argsort(worlds[local], kind="stable")].reshape(model.world_count, -1) + labels = getattr(model, entity + "_label") + digest.update(json.dumps([labels[i].split("/env_0/")[-1] for i in ids[0]]).encode()) + for suffix in names: + name = entity + "_" + suffix + array = getattr(model, name).numpy().copy() + if name == "joint_X_p" and translation_invariant: + array[model.joint_parent.numpy() == -1, :2] = 0.0 + if name == "joint_X_p" and contact_conditioned: + roots = (model.joint_parent.numpy() == -1) & (model.joint_type.numpy() == newton.JointType.FREE) + array[roots, :3] = 0.0 + if name == "shape_transform": + # A horizontal plane is unchanged by translation in its own plane. + array[horizontal, :2] = 0.0 + if suffix in ("X_p", "X_c", "transform"): + array = np.round(array.astype(np.float64), 6) + array[np.abs(array) < 1.0e-8] = 0.0 + first = array[ids[0]] + if not np.all(array[ids] == first): + raise ValueError(f"NeRD cannot represent per-world variation in {name}.") + digest.update(name.encode()) + digest.update(first.tobytes()) + digest.update(array[worlds == -1].tobytes()) + for name in ( + "joint_axis", + "joint_limit_lower", + "joint_limit_upper", + "joint_armature", + "joint_friction", + "joint_target_ke", + "joint_target_kd", + "joint_damping", + "joint_effort_limit", + "joint_velocity_limit", + "joint_limit_ke", + "joint_limit_kd", + ): + values = getattr(model, name).numpy().reshape(model.world_count, -1) + if not np.all(values == values[0]): + raise ValueError(f"NeRD cannot represent per-world variation in {name}.") + digest.update(name.encode()) + digest.update(values[0].tobytes()) + body_worlds = model.body_world.numpy() + body_ids = np.argsort(body_worlds, kind="stable").reshape(model.world_count, -1) + body_slots = np.empty(model.body_count, dtype=np.int64) + body_slots[body_ids] = np.arange(body_ids.shape[1]) + for name, values in ( + ("joint_parent", model.joint_parent.numpy()), + ("joint_child", model.joint_child.numpy()), + ("shape_body", model.shape_body.numpy()), + ): + entity = "shape" if name == "shape_body" else "joint" + worlds = getattr(model, entity + "_world").numpy() + selected = worlds >= 0 + if entity == "shape": + selected &= collision_shapes + indices = np.flatnonzero(selected) + indices = indices[np.argsort(worlds[indices], kind="stable")].reshape(model.world_count, -1) + values = np.where(values >= 0, body_slots[values.clip(min=0)], -1) + local = values[indices] + if not np.all(local == local[0]): + raise ValueError(f"NeRD cannot represent per-world variation in {name}.") + digest.update(name.encode()) + digest.update(local[0].tobytes()) + for shape in np.flatnonzero((model.shape_world.numpy() == 0) & collision_shapes): + source = model.shape_source[shape] + if source is not None: + if not isinstance(source, newton.Mesh): + raise ValueError("NeRD supports primitive and mesh collision geometry only.") + digest.update(np.asarray(source.vertices).tobytes()) + digest.update(np.asarray(source.indices).tobytes()) + # Pair indices include visual shapes and all replicas. Normalize them to collision-only + # slots so headless collection and playback with a different world count have the same identity. + shape_world = model.shape_world.numpy() + local_shapes = np.flatnonzero(collision_shapes & (shape_world >= 0)) + local_shapes = local_shapes[np.argsort(shape_world[local_shapes], kind="stable")].reshape(model.world_count, -1) + slots = np.full(model.shape_count, -1, dtype=np.int64) + slots[local_shapes] = np.arange(local_shapes.shape[1]) + global_shapes = np.flatnonzero(collision_shapes & (shape_world == -1)) + slots[global_shapes] = -1 - np.arange(len(global_shapes)) + for name, pairs in ( + ("excluded_pairs", model.shape_collision_filter_pairs_array()), + ("contact_pairs", model.shape_contact_pairs.numpy()), + ): + pairs = np.asarray(pairs).reshape(-1, 2) + pairs = pairs[collision_shapes[pairs].all(axis=1)] + pair_worlds = shape_world[pairs] + # Different local worlds cannot collide; a global shape can participate in each world. + pairs = pairs[(pair_worlds[:, 0] == pair_worlds[:, 1]) | (pair_worlds == -1).any(axis=1)] + worlds = shape_world[pairs].max(axis=1) + canonical = np.sort(slots[pairs], axis=1) + order = np.lexsort((canonical[:, 1], canonical[:, 0], worlds)) + canonical, worlds = canonical[order], worlds[order] + counts = np.bincount(worlds[worlds >= 0], minlength=model.world_count) + if np.any(counts != counts[0]): + raise ValueError(f"NeRD cannot represent per-world variation in {name}.") + local = canonical[worlds >= 0].reshape(model.world_count, -1, 2) + if not np.all(local == local[0]): + raise ValueError(f"NeRD cannot represent per-world variation in {name}.") + digest.update(name.encode()) + digest.update(local[0].tobytes()) + digest.update(canonical[worlds == -1].tobytes()) + gravity = model.gravity.numpy() + if not np.all(gravity == gravity[0]): + raise ValueError("NeRD cannot represent per-world gravity variation.") + digest.update(np.asarray(gravity[0]).tobytes()) + return digest.hexdigest() diff --git a/source/isaaclab_newton/isaaclab_newton/physics/nerd_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/nerd_manager.py new file mode 100644 index 000000000000..c9f2a371f41b --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/physics/nerd_manager.py @@ -0,0 +1,170 @@ +# 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 + +"""Native Newton solver boundary for a temporal NeRD model.""" + +import math + +import newton +import torch +import warp as wp + +from isaaclab.utils import string_to_callable + +from .nerd import JointDynamics, model_signature +from .newton_manager import NewtonManager + + +@wp.kernel +def _write_joint_state( + values: wp.array2d(dtype=float), q: wp.array(dtype=float), qd: wp.array(dtype=float), nq: int, nv: int +): + world, column = wp.tid() + if column < nq: + q[world * nq + column] = values[world, column] + else: + qd[world * nv + column - nq] = values[world, column] + + +class SolverNeRD(newton.solvers.SolverBase): + """Fixed-storage history, resets and inference; all Python decisions are construction-time decisions.""" + + def __init__(self, model, checkpoint, *, compile=True): + super().__init__(model) + if not model.device.is_cuda: + raise ValueError("The native NeRD solver currently requires CUDA.") + saved = torch.load(checkpoint, map_location="cpu", weights_only=True) + if saved["format"] != "isaaclab-nerd-v2": + raise ValueError("Expected a native Isaac Lab NeRD checkpoint.") + self.dynamics = JointDynamics(saved["dynamics"], model) + if model_signature(model, contact_conditioned=bool(self.dynamics.max_contacts)) != saved["dynamics"]["model"]: + raise ValueError("NeRD checkpoint physical model differs from the live scene.") + self.frame_dt = saved["dynamics"]["frame_dt"] + device = model.device.alias + self.network = ( + string_to_callable(saved["network_type"])( + self.dynamics.input_dim, + self.dynamics.codec.prediction_shape[0], + **saved["network_cfg"], + **self.dynamics.contact_model_cfg, + ) + .to(device) + .eval() + ) + self.network.load_state_dict(saved["model"]) + self.network.requires_grad_(False) + self.mean = saved["input_mean"].to(device) + self.scale = saved["input_scale"].to(device) + self.output_mean = saved["target_mean"].to(device) + self.output_scale = saved["target_scale"].to(device) + history = 1 + sum(span - 1 for span in saved["network_cfg"]["temporal_attention_spans"]) + self.history = torch.zeros(model.world_count, history, self.dynamics.input_dim, device=device) + self.contact_history = None + self.contact_mean = self.contact_scale = None + if self.dynamics.max_contacts: + self.contact_history = torch.zeros(model.world_count, history, *self.dynamics.contact_shape, device=device) + self.contact_mean, self.contact_scale = saved["contact_mean"].to(device), saved["contact_scale"].to(device) + self.pending_reset = torch.ones(model.world_count, dtype=torch.bool, device=device) + self.predict = torch.compile(self.network.predict_last) if compile else self.network.predict_last + # Warm model kernels and compilation outside the host's graph capture. No physical state advances. + with torch.no_grad(), torch.cuda.stream(wp.stream_to_torch(wp.get_stream(model.device))): + if self.contact_history is None: + self.predict(self.history) + else: + self.predict( + self.history, + self.dynamics.contact_tokens(self.contact_history, self.contact_mean, self.contact_scale), + ) + + @torch.no_grad() + def reset(self, state, *, world_mask=None, flags=0): + with torch.cuda.stream(wp.stream_to_torch(wp.get_stream(self.model.device))): + if world_mask is None: + self.pending_reset.fill_(True) + else: + self.pending_reset.logical_or_(wp.to_torch(world_mask)[: self.model.world_count]) + + @torch.no_grad() + def step(self, state_in, state_out, control, contacts, dt): + if not math.isclose(dt, self.frame_dt, rel_tol=1e-9): + raise ValueError(f"NeRD frame is {self.frame_dt}s, received {dt}s.") + with torch.cuda.stream(wp.stream_to_torch(wp.get_stream(self.model.device))): + self.dynamics.check_inputs(state_in) + action = wp.to_torch(control.joint_f) + state = self.dynamics.codec.read(state_in) + contact_values = self.dynamics.contacts(state_in, contacts) + features = self.dynamics.inputs(state, action.reshape(self.model.world_count, -1), contact_values) + token = (features - self.mean) / self.scale + shifted = torch.cat((self.history[:, 1:], token[:, None]), dim=1) + self.history.copy_(torch.where(self.pending_reset[:, None, None], token[:, None], shifted)) + if self.contact_history is None: + prediction = self.predict(self.history) + else: + shifted = torch.cat((self.contact_history[:, 1:], contact_values[:, None]), dim=1) + self.contact_history.copy_( + torch.where(self.pending_reset[:, None, None, None], contact_values[:, None], shifted) + ) + prediction = self.predict( + self.history, + self.dynamics.contact_tokens(self.contact_history, self.contact_mean, self.contact_scale), + ) + self.pending_reset.zero_() + delta = prediction * self.output_scale + self.output_mean + prediction = self.dynamics.codec.delta_to_state(state, delta) + torch._assert_async(torch.isfinite(prediction).all(), "NeRD produced a nonfinite state.") + layout = self.dynamics.codec.layout + wp.launch( + _write_joint_state, + dim=prediction.shape, + inputs=[ + wp.from_torch(prediction.contiguous()), + state_out.joint_q, + state_out.joint_qd, + layout.dof_q, + layout.dof_qd, + ], + device=self.model.device, + ) + newton.eval_fk(self.model, state_out.joint_q, state_out.joint_qd, state_out) + + def notify_model_changed(self, flags): + # Fixed-base root-pose resets can notify unchanged joint properties. Verify the same + # physical contract instead of suppressing every notification or mutating a library method. + if ( + int(flags) + and model_signature(self.model, contact_conditioned=bool(self.dynamics.max_contacts)) + != self.dynamics.descriptor["model"] + ): + raise ValueError("Physical model changes invalidate the trained NeRD checkpoint.") + + +class NewtonNeRDManager(NewtonManager): + """Construct learned physics through the same instance lifecycle as other Newton solvers.""" + + def _build_solver(self, model, solver_cfg): + if self._num_substeps != 1 or not solver_cfg.checkpoint: + raise ValueError("NeRD requires num_substeps=1 and a checkpoint path.") + if ( + set(model.get_requested_contact_attributes()) & newton.Contacts.EXTENDED_ATTRIBUTES + or set(model.get_requested_state_attributes()) & newton.State.EXTENDED_ATTRIBUTES + ): + raise ValueError("NeRD does not produce extended sensor state or solved contact forces.") + self._solver = SolverNeRD(model, solver_cfg.checkpoint, compile=solver_cfg.compile) + if not math.isclose(self._solver_dt, self._solver.frame_dt, rel_tol=1e-9): + raise ValueError("Set sim.dt to the NeRD checkpoint's physics-step duration.") + self._use_single_state = True + self._needs_collision_pipeline = bool(self._solver.dynamics.max_contacts) + self._supports_rigid_body_force_input = False + self._supports_contact_sensors = False + + def _capture_graph(self, capture_target): + # Warp owns graph capture/replay. Isolate its Torch temporaries from the caller's caching + # allocator and keep this pool alive as long as the captured physics graph can use them. + self._torch_pool = torch.cuda.MemPool() + with torch.cuda.use_mem_pool(self._torch_pool, device=self._device): + return super()._capture_graph(capture_target) + + def _solver_specific_clear(self): + self._torch_pool = None diff --git a/source/isaaclab_newton/isaaclab_newton/physics/nerd_manager_cfg.py b/source/isaaclab_newton/isaaclab_newton/physics/nerd_manager_cfg.py new file mode 100644 index 000000000000..e7c511d5202d --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/physics/nerd_manager_cfg.py @@ -0,0 +1,29 @@ +# 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 + +"""Configuration of learned generalized-coordinate Newton dynamics.""" + +from isaaclab.utils import configclass + +from .newton_manager_cfg import NewtonSolverCfg + + +@configclass +class NeRDSolverCfg(NewtonSolverCfg): + """Advance one physics step from applied joint efforts with a native NeRD checkpoint. + + Use one physics substep. Policy decimation repeats actuator evaluation and learned physics; + collection records each physics step separately. The checkpoint declares the frame duration, + modeled joints and contact features. Actuator commands, state and effort limits remain owned + by the actuator. Solver-side motor drives are unsupported. + """ + + class_type: str = "{DIR}.nerd_manager:NewtonNeRDManager" + solver_type: str = "nerd" + checkpoint: str = "" + """Native checkpoint written by ``scripts/learning/nerd/train.py``.""" + + compile: bool = True + """Compile model inference before the native manager captures its physics CUDA graph.""" diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager_cfg.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager_cfg.py index 18687ef9a063..88efcae3024c 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager_cfg.py @@ -161,11 +161,11 @@ class NewtonCfg(PhysicsCfg): :attr:`class_type` explicitly. """ - class_type: type[NewtonManager] | str | None = None + class_type: type[NewtonManager] | str | None = field(default=None, init=False) """The class type of the :class:`NewtonManager`. Auto-set in :meth:`__post_init__` from :attr:`solver_cfg.class_type`. - Users normally do not set this directly. + It is not a constructor argument, so replacing a solver derives the matching manager again. """ num_substeps: int = 1 @@ -294,10 +294,6 @@ class NewtonCfg(PhysicsCfg): def __post_init__(self): # NewtonCfg.class_type is auto-derived from solver_cfg.class_type. - # Refuse a user-set value: setting both is ambiguous and was - # previously silently overwritten. - if self.class_type is not None: - raise TypeError("Cannot manually set NewtonCfg.class_type; it is auto-derived from solver_cfg.class_type.") if self.deterministic_mode not in ("not_guaranteed", "run_to_run", "gpu_to_gpu"): raise ValueError( "NewtonCfg.deterministic_mode must be 'not_guaranteed', 'run_to_run', or 'gpu_to_gpu', " diff --git a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py index 51c354e87c47..de0f0a019eea 100644 --- a/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py +++ b/source/isaaclab_newton/test/physics/test_newton_manager_abstraction.py @@ -167,6 +167,8 @@ def test_newton_cfg_post_init_propagates_class_type( """``NewtonCfg.__post_init__`` lifts ``solver_cfg.class_type`` onto ``NewtonCfg.class_type``.""" cfg = NewtonCfg(solver_cfg=solver_cfg_factory()) assert cfg.class_type.__name__ == expected_manager.__name__ + replaced = NewtonCfg().replace(solver_cfg=solver_cfg_factory()) + assert replaced.class_type.__name__ == expected_manager.__name__ @pytest.mark.parametrize( diff --git a/source/isaaclab_newton/test/test_nerd.py b/source/isaaclab_newton/test/test_nerd.py new file mode 100644 index 000000000000..f26a22aef667 --- /dev/null +++ b/source/isaaclab_newton/test/test_nerd.py @@ -0,0 +1,438 @@ +# 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 + +"""NeRD capture, fitting, checkpoint and graph/reset contracts across articulated tasks and contact models.""" + +import ast +import json +import subprocess +import sys +from pathlib import Path + +import gymnasium as gym +import h5py +import newton +import pytest +import torch +import warp as wp + +from isaaclab.app import launch_simulation +from isaaclab.managers import DatasetExportMode +from isaaclab.paths import ISAACLAB_ROOT +from isaaclab.test.utils import DeviceScope, test_devices +from isaaclab.utils import to_dict + +from isaaclab_tasks.utils import resolve_task_config + + +def test_native_nerd_ownership(): + """Keep capture, solver state and learning dependencies at their owning boundaries.""" + source = Path(ISAACLAB_ROOT) / "source" + assert not (source / "isaaclab_physicsnemo").exists() + newton = source / "isaaclab_newton/isaaclab_newton" + task_core = source / "isaaclab_tasks/isaaclab_tasks/core" + paths = ( + *newton.glob("physics/nerd*.py"), + newton / "envs/mdp/recorders.py", + task_core / "cartpole/cartpole_nerd_env_cfg.py", + *task_core.glob("velocity/config/anymal_c/*.py"), + source / "isaaclab_rl/isaaclab_rl/nerd.py", + ) + for path in paths: + tree = ast.parse(path.read_text()) + for node in ast.walk(tree): + if isinstance(node, (ast.ClassDef, ast.FunctionDef)): + assert node.name not in { + "TransitionCapture", + "NewtonBinding", + "TrajectoryDatasetRecorder", + "_fp32_parallel_nerd_attention", + "AnymalCNeRDEffortExplorationEnvCfg", + "AnymalCNeRDEffortLearnedEnvCfg", + }, path + if isinstance(node, ast.ClassDef) and node.name == "NeRDRecorderCfg": + fields = {item.target.id for item in node.body if isinstance(item, ast.AnnAssign)} + assert fields.isdisjoint({"control", "asset_name"}), "Capture has one applied-effort contract." + if isinstance(node, ast.ClassDef) and node.name == "JointDynamics": + init = next(item for item in node.body if isinstance(item, ast.FunctionDef) and item.name == "__init__") + assert "control" not in {arg.arg for arg in (*init.args.args, *init.args.kwonlyargs)} + imports = [] + if isinstance(node, ast.Import): + imports = [item.name for item in node.names] + elif isinstance(node, ast.ImportFrom) and node.level == 0: + imports = [node.module or ""] + forbidden = ("isaaclab_physicsnemo", "supermimic", "argparse", "gymnasium", "wandb") + if path.is_relative_to(newton): + forbidden += ("isaaclab_rl", "isaaclab_tasks") + if path.is_relative_to(task_core) and path.name != "__init__.py": + forbidden += ("isaaclab_rl", "physicsnemo") + # Registration files legitimately import Gym, but never learner implementations. + if path.name != "__init__.py": + assert not any(name.startswith(forbidden) for name in imports), path + if isinstance(node, ast.Assign): + for target in node.targets: + assert not ( + isinstance(target, ast.Attribute) + and isinstance(target.value, ast.Name) + and target.value.id == "NewtonManager" + ), "Solver state belongs to the simulation's manager instance." + + +def never(env): + return torch.zeros(env.num_envs, dtype=torch.bool, device=env.device) + + +@pytest.mark.parametrize( + "task,network", + ( + ("Isaac-Cartpole-NeRD", "parallel"), + ("Isaac-Velocity-Flat-Anymal-C-NeRD", "parallel"), + ("Isaac-Velocity-Flat-Anymal-C-NeRD", "vector"), + ("Isaac-Velocity-Rough-Anymal-C-NeRD", "parallel"), + ), +) +@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) +def test_record_fit_resume_and_graph_resets(task, network, device, tmp_path): + pytest.importorskip("physicsnemo_robotics") + from isaaclab_newton.physics.nerd import JointDynamics + + from isaaclab_rl.nerd import NeRDTrainCfg, OfflineNeRD + + torch.backends.cuda.matmul.allow_tf32 = False + cfg, _ = resolve_task_config(task + "-Explore", None, overrides=[]) + cfg.scene.num_envs, cfg.sim.device, cfg.seed = 4, device, 7 + if "Rough" in task: + from isaaclab.envs.mdp import reset_root_state_uniform + from isaaclab.terrains import HfRandomUniformTerrainCfg + + cfg.scene.terrain.terrain_generator.num_rows = cfg.scene.terrain.terrain_generator.num_cols = 1 + cfg.scene.terrain.terrain_generator.sub_terrains = { + "rough": HfRandomUniformTerrainCfg(noise_range=(-0.02, 0.02), noise_step=0.005) + } + cfg.events.reset_base.func = reset_root_state_uniform + cfg.events.reset_base.params["pose_range"].update(x=(0.0, 0.0), y=(0.0, 0.0), z=(-0.35, -0.35)) + cfg.recorders.transition.record_to_episodes = True + cfg.recorders.dataset_export_mode = DatasetExportMode.EXPORT_ALL + cfg.recorders.dataset_export_dir_path = str(tmp_path) + cfg.recorders.export_in_close = True + cfg.recorders.buffer_initial_capacity = 2 + cfg.episode_length_s = 6 * cfg.sim.dt * cfg.decimation + with launch_simulation(cfg): + env = gym.make(task + "-Explore", cfg=cfg).unwrapped + try: + env.reset() + manager = env.sim.backend_manager + capture = env.recorder_manager.get_term("transition") + descriptor = capture.dynamics.descriptor + if "Cartpole" in task: + with pytest.raises(ValueError, match="version 2"): + JointDynamics(dict(descriptor, version=1)) + with pytest.raises(ValueError, match="applied joint efforts"): + JointDynamics(dict(descriptor, control="joint_target_q")) + for gains in (manager.get_model().joint_target_ke, manager.get_model().joint_target_kd): + saved_gains = wp.clone(gains) + gains.fill_(100.0) + with pytest.raises(ValueError, match="solver-side drives"): + JointDynamics(model=manager.get_model(), frame_dt=env.step_dt) + wp.copy(gains, saved_gains) + + def clear_consumed_effort(): + manager.get_control().joint_f.zero_() + + # Capture must retain the solver's input even when a later hook reuses its buffer. + manager.register_post_step_callback(clear_consumed_effort) + metadata = {"dynamics": descriptor, "task": task + "-Explore"} + (tmp_path / "dataset.json").write_text(json.dumps(metadata)) + # Stagger autoresets so prefixes and retained fields exercise different world lengths. + env.episode_length_buf[:] = torch.arange(4, device=device) + seen_terminal = False + seen_contacts = False + with torch.inference_mode(): + for step in range(16): + raw = manager.get_state_0() + before = torch.cat( + (wp.to_torch(raw.joint_q).reshape(4, -1), wp.to_torch(raw.joint_qd).reshape(4, -1)), -1 + ).clone() + actions = torch.full((4, env.action_manager.total_action_dim), 0.1 * (step % 3 - 1), device=device) + if "Cartpole" in task: + # Velocity commands belong to the actuator, and capture must include damping and clipping. + env.scene["robot"].actuators.target_command.set_velocity_index( + value=torch.full((4, 2), 0.25, device=device) + ) + if step == 5: + actions.fill_(10.0) + env.step(actions) + if "Rough" in task: + contacts = capture.latest["contacts"] + valid = contacts[..., 0] == 1 + seen_contacts |= bool(valid.any()) + assert ( + (contacts[..., 1][valid] >= 0) + & (contacts[..., 1][valid] < len(descriptor["contact_entities"])) + ).all() + torch.testing.assert_close( + contacts[..., 7:10][valid].norm(dim=-1), torch.ones_like(contacts[..., 0][valid]) + ) + torch.testing.assert_close(capture.latest["state"], before, rtol=0, atol=0) + if "Cartpole" in task: + expected = (actions[:, 0] * 100 + 10 * (0.25 - before[:, 2])).clamp(-400, 400) + torch.testing.assert_close(capture.latest["control"][:, 0], expected, rtol=1e-6, atol=1e-6) + else: + robot = env.scene["robot"] + names = [ + name.rsplit("/", 1)[-1] + for name in manager.get_model().joint_label[: manager.get_model().joint_count // 4] + ] + names = [name for name in names if name in robot.joint_names] + expected = robot.actuators.applied_effort.torch[ + :, [robot.joint_names.index(name) for name in names] + ] + live = ~env.reset_buf + torch.testing.assert_close(capture.latest["control"][live, 6:], expected[live], rtol=0, atol=0) + if env.reset_buf.any(): + after = torch.cat( + (wp.to_torch(raw.joint_q).reshape(4, -1), wp.to_torch(raw.joint_qd).reshape(4, -1)), -1 + ) + assert not torch.equal(capture.latest["next_state"][env.reset_buf], after[env.reset_buf]) + seen_terminal = True + assert seen_terminal and manager._graph is not None + if "Rough" in task: + assert seen_contacts + # Last continuing sample agrees with cold episode materialization, byte for byte. + for world in range(4): + episode = env.recorder_manager.get_episode(world) + if not episode.is_empty(): + for name, tensor in capture.latest.items(): + assert torch.equal(episode.data["transition"][name][-1], tensor[world]) + env.reset() + assert (capture.latest["terminated"] | capture.latest["truncated"]).all() + env.recorder_manager.export_episodes() + finally: + env.close() + dataset = tmp_path / "dataset.hdf5" + with h5py.File(dataset) as data: + assert len(data["data"]) >= 4 + assert sum(len(episode["transition/state"]) for episode in data["data"].values()) == 64 + + train_cfg = NeRDTrainCfg( + max_updates=3, + batch_size=8, + compile=False, + network={ + "num_heads": 1, + "head_dim": 16, + "intermediate_size": 32, + "head_hidden": 16, + "temporal_attention_spans": (2, 2), + "linear_probe": True, + }, + ) + if "Rough" in task: + train_cfg.network.update(contact_slot_count=2, contact_slot_dim=8) + train_cfg.precision = "bfloat16" + elif network == "vector": + train_cfg.network_type = "physicsnemo_robotics.models.nerd:NewtonNeRDVectorTransformer" + train_cfg.network = { + "num_heads": 2, + "hidden_size": 32, + "head_hidden": 16, + "temporal_depth": 2, + "temporal_attention_spans": (2, 2), + "position_encoding": "relative_lag", + } + elif "Anymal" in task: + train_cfg.precision = "float16" + learner = OfflineNeRD([dataset], train_cfg, device) + assert all(torch.isfinite(torch.tensor(v)) for v in learner.update().values()) + checkpoint = tmp_path / "model.pt" + torch.save(learner.checkpoint(), checkpoint) + resumed = OfflineNeRD( + [dataset], train_cfg, device, checkpoint=torch.load(checkpoint, map_location="cpu", weights_only=True) + ) + assert torch.equal(learner.generator.get_state(), resumed.generator.get_state()) + assert torch.equal(learner.input_mean, resumed.input_mean) + assert torch.equal(learner.target_scale, resumed.target_scale) + assert learner.scaler.state_dict() == resumed.scaler.state_dict() + learner.update() + resumed.update() + for name, tensor in learner.model.state_dict().items(): + # GPU/JIT reduction ordering can change final mantissa bits; resume preserves the update numerically. + torch.testing.assert_close(tensor, resumed.model.state_dict()[name], rtol=1.0e-6, atol=1.0e-8) + assert all(torch.isfinite(torch.tensor(v)) for v in learner.validate().values()) + torch.save(learner.checkpoint(), checkpoint) + + if "Cartpole" in task: + # A two-rank CPU run must match one optimizer step over the combined samples. + # This checks gradient averaging independently of DDP and exercises the actual CLI/save root. + options = tmp_path / "training.json" + options.write_text(json.dumps(to_dict(train_cfg))) + output = tmp_path / "distributed" + script = Path(ISAACLAB_ROOT) / "scripts/learning/nerd/train.py" + command = [sys.executable, "-m", "torch.distributed.run", "--standalone", "--nproc_per_node=2"] + command += [str(script), "--dataset", str(dataset), "--output", str(output)] + command += ["--config", str(options), "--device", "cpu", "--log_interval", "1"] + with (tmp_path / "distributed.log").open("w") as log: + subprocess.run(command + ["--max_updates", "1"], stdout=log, stderr=log, check=True, timeout=120) + distributed = torch.load(output / "last.pt", map_location="cpu", weights_only=True) + oracle = OfflineNeRD([dataset], train_cfg, "cpu") + samples = [] + for rank in range(2): + oracle.generator.manual_seed(train_cfg.seed + rank) + samples.append(oracle.sample(oracle.train_ids, train_cfg.batch_size // 2)) + inputs, targets = (torch.cat([sample[i] for sample in samples]) for i in range(2)) + (oracle.model.predict_last(inputs) - targets).square().mean().backward() + torch.nn.utils.clip_grad_norm_(oracle.model.parameters(), train_cfg.gradient_clip_norm) + oracle.optimizer.step() + for name, tensor in oracle.model.state_dict().items(): + torch.testing.assert_close(tensor, distributed["model"][name], rtol=1e-5, atol=1e-7) + assert len(distributed["generators"]) == 2 + assert not torch.equal(*distributed["generators"]) + with (tmp_path / "distributed.log").open("a") as log: + subprocess.run( + command + ["--max_updates", "2", "--checkpoint", str(output / "last.pt")], + stdout=log, + stderr=log, + check=True, + timeout=120, + ) + continued = torch.load(output / "last.pt", map_location="cpu", weights_only=True) + assert continued["updates"] == 2 + assert all(not torch.equal(a, b) for a, b in zip(distributed["generators"], continued["generators"])) + assert len((output / "metrics.jsonl").read_text().splitlines()) == 2 + + _check_graph_resets(task, device, checkpoint) + + +def _check_graph_resets(task, device, checkpoint): + from isaaclab_newton.physics.nerd_manager import SolverNeRD + + cfg, _ = resolve_task_config(task + "-Learned", None, overrides=[]) + cfg.play_mode() + cfg.scene.num_envs, cfg.sim.device, cfg.seed = 8, device, 7 + if "Rough" in task: + # Unseen terrain and different world count must preserve the robot/material contract. + cfg.scene.terrain.terrain_generator.seed = 99 + cfg.scene.terrain.terrain_generator.num_rows = cfg.scene.terrain.terrain_generator.num_cols = 1 + # Playback imports visual-only geometry absent from headless collection; it is not physics. + cfg.sim.physics.load_visual_shapes = True + cfg.sim.physics.solver_cfg.checkpoint = str(checkpoint) + cfg.sim.physics.solver_cfg.compile = "Anymal" in task + for name, term in vars(cfg.terminations).items(): + if term is not None: + term.func, term.params = never, {} + with launch_simulation(cfg): + env = gym.make(task + "-Learned", cfg=cfg).unwrapped + try: + env.reset() + manager = env.sim.backend_manager + reference = SolverNeRD(manager.get_model(), checkpoint, compile=cfg.sim.physics.solver_cfg.compile) + collision = newton.CollisionPipeline(manager.get_model()) if "Rough" in task else None + contacts = collision.contacts() if collision else None + with torch.no_grad(): + probe = torch.randn_like(reference.history) + if "Rough" in task: + tokens = reference.dynamics.contact_tokens( + reference.contact_history, reference.contact_mean, reference.contact_scale + ) + torch.testing.assert_close( + reference.predict(probe, tokens), reference.network.predict_last(probe, tokens) + ) + else: + torch.testing.assert_close(reference.predict(probe), reference.network.predict_last(probe)) + state = manager.get_model().state() + state.assign(manager.get_state_0()) + action = torch.zeros(8, env.action_manager.total_action_dim, device=device) + if cfg.decimation > 1: + # Save every applied effort, then independently replay four + # single solver steps. ANYdrive's recurrent state stays owned by the real actuator. + efforts = torch.empty(cfg.decimation, manager.get_model().joint_dof_count, device=device) + cursor = torch.zeros(1, dtype=torch.long, device=device) + replay_control = manager.get_model().control() + + def retain_effort(): + with torch.cuda.stream(wp.stream_to_torch(wp.get_stream(device))): + efforts.index_copy_( + 0, cursor.remainder(cfg.decimation), wp.to_torch(manager.get_control().joint_f)[None] + ) + cursor.add_(1) + + manager.register_post_actuator_callback(retain_effort) + addresses = (manager._solver.history.data_ptr(), manager._solver.pending_reset.data_ptr()) + with torch.inference_mode(): + for step in range(21): + if step in (4, 9, 13): + ids = torch.tensor([step % 4], device=device) + env.reset(ids) + state.assign(manager.get_state_0()) + mask = wp.zeros(9, dtype=wp.bool, device=device) + wp.to_torch(mask)[ids] = True + reference.reset(state, world_mask=mask) + if step < 2 or step % 2 or not manager.handles_decimation(): + env.step(action) + else: + # Direct graph replay must advance history without executing Python cursor updates. + wp.capture_launch(manager._graph) + if cfg.decimation > 1: + for substep in range(cfg.decimation): + wp.copy(replay_control.joint_f, wp.from_torch(efforts[substep])) + if collision: + collision.collide(state, contacts) + reference.step(state, state, replay_control, contacts, cfg.sim.dt) + else: + reference.step(state, state, manager.get_control(), None, env.step_dt) + wp.synchronize_device(device) + torch.testing.assert_close( + wp.to_torch(manager.get_state_0().joint_q), + wp.to_torch(state.joint_q), + rtol=2e-5, + atol=2e-6, + ) + torch.testing.assert_close( + wp.to_torch(manager.get_state_0().joint_qd), + wp.to_torch(state.joint_qd), + rtol=2e-5, + atol=2e-6, + ) + assert manager._graph is not None + assert addresses == (manager._solver.history.data_ptr(), manager._solver.pending_reset.data_ptr()) + with pytest.raises(ValueError, match="frame"): + reference.step(state, state, manager.get_control(), None, env.step_dt * 2) + # A checkpoint must reject altered physics even when tensor shapes still agree. + masses = wp.to_torch(manager.get_model().body_mass) + saved_masses = masses.clone() + masses.mul_(1.1) + with pytest.raises(ValueError, match="invalidate"): + reference.notify_model_changed(1) + masses.copy_(saved_masses) + if "Rough" in task: + materials = wp.to_torch(manager.get_model().shape_material_mu) + saved_materials = materials.clone() + materials.mul_(0.8) + with pytest.raises(ValueError, match="invalidate"): + reference.notify_model_changed(1) + materials.copy_(saved_materials) + if "Cartpole" in task: + anchors = wp.to_torch(manager.get_model().joint_X_p) + saved_anchors = anchors.clone() + roots = wp.to_torch(manager.get_model().joint_parent) == -1 + anchors[roots, 3:] = anchors.new_tensor([0.0, 2**-0.5, 0.0, 2**-0.5]) + with pytest.raises(ValueError, match="invalidate"): + reference.notify_model_changed(1) + anchors.copy_(saved_anchors) + flags = wp.to_torch(manager.get_model().body_flags) + saved_flags = flags.clone() + flags.zero_() + with pytest.raises(ValueError, match="invalidate"): + reference.notify_model_changed(1) + flags.copy_(saved_flags) + model = manager.get_model() + pairs = model.shape_collision_filter_pairs_array() + assert len(pairs) + model._set_shape_collision_filter_pairs(()) + with pytest.raises(ValueError, match="invalidate"): + reference.notify_model_changed(1) + model._set_shape_collision_filter_pairs(pairs) + finally: + env.close() diff --git a/source/isaaclab_rl/changelog.d/native-nerd.added.rst b/source/isaaclab_rl/changelog.d/native-nerd.added.rst new file mode 100644 index 000000000000..af42cb1830c8 --- /dev/null +++ b/source/isaaclab_rl/changelog.d/native-nerd.added.rst @@ -0,0 +1,5 @@ +* Added offline NeRD fitting from native HDF5 episodes, with bounded history sampling, held-out + episode validation, atomic resumable checkpoints and optional synchronous W&B model uploads. +* Supported ParallelNeRD/VectorNeRD, mixed precision with resumable gradient scaling and distributed + fitting with global batches, partitioned validation and per-rank sampler checkpoints. +* Added offline NeRD contact-set fitting with valid-geometry normalization and preserved categorical identities. diff --git a/source/isaaclab_rl/changelog.d/native-nerd.minor b/source/isaaclab_rl/changelog.d/native-nerd.minor new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_rl/isaaclab_rl/nerd.py b/source/isaaclab_rl/isaaclab_rl/nerd.py new file mode 100644 index 000000000000..214326f66edb --- /dev/null +++ b/source/isaaclab_rl/isaaclab_rl/nerd.py @@ -0,0 +1,301 @@ +# 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 + +"""Offline NeRD fitting from native RecorderManager episodes.""" + +from __future__ import annotations + +import hashlib +import json +import math +from pathlib import Path + +import h5py +import torch +import torch.distributed as dist +from isaaclab_newton.physics.nerd import JointDynamics + +from isaaclab.utils import configclass, string_to_callable + + +@configclass +class NeRDTrainCfg: + """Offline model fitting settings; the dataset owns state, control and frame conventions.""" + + max_updates: int = 30000 + batch_size: int = 1024 + learning_rate: float = 1.0e-3 + final_learning_rate: float = 1.0e-4 + learning_rate_decay_updates: int = 30000 + gradient_clip_norm: float = 1.0 + validation_fraction: float = 0.1 + seed: int = 7 + compile: bool = True + network_type: str = "physicsnemo_robotics.models.parallel_nerd:ParallelNeRD" + """Network class with a temporal_attention_spans configuration and a predict_last method.""" + precision: str = "float32" + """Projection precision: float32, float16 or bfloat16; parameters and losses remain FP32.""" + max_gradient_retries: int = 8 + network: dict = { + "num_heads": 4, + "head_dim": 64, + "intermediate_size": 640, + "head_hidden": 256, + "temporal_attention_spans": (4, 3, 3, 3), + "rms_norm_eps": 1.0e-6, + "linear_probe": True, + } + + +class _PredictLast(torch.nn.Module): + """Expose the newest prediction through forward so DDP owns gradient synchronization.""" + + def __init__(self, network): + super().__init__() + self.network = network + + def forward(self, inputs, contacts=None): + return ( + self.network.predict_last(inputs, contacts) if contacts is not None else self.network.predict_last(inputs) + ) + + +class OfflineNeRD: + """Own flattened transition tensors, boundary-aware history sampling and model optimization. + + Episodes, rather than overlapping windows, are split into train and validation sets. The + terminal transition is retained. Every frame after an episode boundary starts a repeated + first-frame prefix, matching deployed solver resets. Storage grows with transitions, not + with the product of transitions and history length. + """ + + def __init__(self, datasets: list[Path], cfg: NeRDTrainCfg, device: str, *, checkpoint=None): + if cfg.max_updates < 1 or cfg.batch_size < 1 or not 0 < cfg.validation_fraction < 1: + raise ValueError("Positive update/batch counts and a validation fraction in (0, 1) are required.") + if cfg.learning_rate_decay_updates < 1 or any( + not math.isfinite(value) or value <= 0 + for value in (cfg.learning_rate, cfg.final_learning_rate, cfg.gradient_clip_norm) + ): + raise ValueError("Learning rates, decay duration and gradient clip norm must be positive and finite.") + self.cfg, self.device = cfg, device + self.rank = dist.get_rank() if dist.is_initialized() else 0 + self.world_size = dist.get_world_size() if dist.is_initialized() else 1 + if cfg.batch_size % self.world_size: + raise ValueError("The global batch size must be divisible by the distributed world size.") + if cfg.precision not in ("float32", "float16", "bfloat16") or cfg.max_gradient_retries < 1: + raise ValueError("Select float32, float16 or bfloat16 and a positive gradient retry limit.") + self.dtype = getattr(torch, cfg.precision) + self.device_type = torch.device(device).type + if self.dtype == torch.float16 and self.device_type != "cuda": + raise ValueError("FP16 fitting requires CUDA.") + self.scaler = torch.amp.GradScaler("cuda", enabled=self.dtype == torch.float16) + self.generator = torch.Generator(device=device).manual_seed(cfg.seed + self.rank) + self.updates = 0 + self.identity = [] + self.dynamics = None + episodes = [] + for path in datasets: + path = Path(path) + metadata = json.loads(path.with_suffix(".json").read_text()) + descriptor = metadata["dynamics"] + if self.dynamics is None: + self.dynamics = JointDynamics(descriptor) + elif self.dynamics.descriptor != descriptor: + raise ValueError("Datasets must use the same dynamics representation and physical model.") + with path.open("rb") as stream: + fingerprint = hashlib.file_digest(stream, "sha256").hexdigest() + if fingerprint in self.identity: + raise ValueError("Duplicate datasets would leak episodes between training and validation.") + self.identity.append(fingerprint) + with h5py.File(path, "r") as file: + for name in file["data"]: + data = file["data"][name]["transition"] + state, successor, control, contacts = ( + torch.from_numpy(data[key][:]).to(device) + for key in ("state", "next_state", "control", "contacts") + ) + if not len(state): + continue + if not all(torch.isfinite(value).all() for value in (state, successor, control, contacts)): + raise ValueError(f"Nonfinite transition in {path}:{name}.") + if not torch.equal(successor[:-1], state[1:]): + raise ValueError( + f"Discontinuous episode in {path}:{name}; a reset cannot enter a training window." + ) + if data["terminated"][:-1].any() or data["truncated"][:-1].any(): + raise ValueError(f"Episode boundary inside {path}:{name}.") + inputs = self.dynamics.inputs(state, control, contacts) + targets = self.dynamics.codec.state_to_delta(state, successor) + episodes.append((inputs, targets, contacts)) + if len(episodes) < 2: + raise ValueError("Record at least two nonempty episodes to split training and validation.") + order = torch.randperm(len(episodes), generator=torch.Generator().manual_seed(cfg.seed)).tolist() + validation_count = max(1, min(len(episodes) - 1, round(cfg.validation_fraction * len(episodes)))) + self.validation_episodes = order[:validation_count] + self.train_episodes = order[validation_count:] + self.inputs = torch.cat([item[0] for item in episodes]) + self.targets = torch.cat([item[1] for item in episodes]) + self.contacts = None + self.contact_mean = self.contact_scale = None + lengths = torch.tensor([len(item[0]) for item in episodes], device=device) + starts = lengths.cumsum(0) - lengths + self.starts = torch.repeat_interleave(starts, lengths) + ids = torch.repeat_interleave(torch.arange(len(episodes), device=device), lengths) + held_out = torch.isin(ids, ids.new_tensor(self.validation_episodes)) + self.train_ids, self.validation_ids = (~held_out).nonzero().flatten(), held_out.nonzero().flatten() + if checkpoint is None: + train_x, train_y = self.inputs[self.train_ids], self.targets[self.train_ids] + self.input_mean, self.target_mean = train_x.mean(0), train_y.mean(0) + self.input_scale = train_x.std(0, correction=0) + 1.0e-6 + self.target_scale = train_y.std(0, correction=0) + 1.0e-6 + else: + if ( + checkpoint["format"] != "isaaclab-nerd-v2" + or checkpoint["datasets"] != self.identity + or checkpoint["dynamics"] != self.dynamics.descriptor + or checkpoint["train_episodes"] != self.train_episodes + or checkpoint["network_cfg"] != cfg.network + or checkpoint["network_type"] != cfg.network_type + or len(checkpoint["generators"]) != self.world_size + ): + raise ValueError("Resume requires the same datasets, representation, split, network and rank count.") + # The checkpoint owns fixed normalization, including across GPU/runtime changes. + for name in ("input_mean", "input_scale", "target_mean", "target_scale"): + setattr(self, name, checkpoint[name].to(device)) + self.inputs.sub_(self.input_mean).div_(self.input_scale) + self.targets.sub_(self.target_mean).div_(self.target_scale) + if self.dynamics.max_contacts: + contacts = torch.cat([item[2] for item in episodes]) + if checkpoint is None: + training = contacts[self.train_ids] + geometry = training[..., 4:][training[..., 0] == 1] + if not len(geometry): + raise ValueError("Contact-conditioned training requires contacts in its training split.") + self.contact_mean = geometry.mean(0) + self.contact_scale = geometry.std(0, correction=0) + 1.0e-6 + else: + self.contact_mean, self.contact_scale = ( + checkpoint["contact_mean"].to(device), + checkpoint["contact_scale"].to(device), + ) + self.contacts = self.dynamics.contact_tokens(contacts, self.contact_mean, self.contact_scale) + history = 1 + sum(span - 1 for span in cfg.network["temporal_attention_spans"]) + self.offsets = torch.arange(1 - history, 1, device=device) + torch.manual_seed(cfg.seed) + self.model = string_to_callable(cfg.network_type)( + self.dynamics.input_dim, + self.dynamics.codec.prediction_shape[0], + **cfg.network, + **self.dynamics.contact_model_cfg, + ).to(device) + self.predict = _PredictLast(self.model) + if self.world_size > 1: + device_ids = [torch.device(device).index] if self.device_type == "cuda" else None + self.predict = torch.nn.parallel.DistributedDataParallel(self.predict, device_ids=device_ids) + if cfg.compile: + self.predict = torch.compile(self.predict) + self.optimizer = torch.optim.AdamW(self.model.parameters(), lr=cfg.learning_rate, weight_decay=0.0) + if checkpoint is not None: + self.model.load_state_dict(checkpoint["model"]) + self.optimizer.load_state_dict(checkpoint["optimizer"]) + self.generator.set_state(checkpoint["generators"][self.rank].cpu()) + self.scaler.load_state_dict(checkpoint["scaler"]) + self.updates = checkpoint["updates"] + + def sample(self, ids, count): + """Draw transitions uniformly, with a reset prefix that never crosses episode boundaries.""" + selected = ids[torch.randint(len(ids), (count,), device=self.device, generator=self.generator)] + history = (selected[:, None] + self.offsets).clamp_min(self.starts[selected, None]) + return ( + self.inputs[history], + self.targets[selected], + self.contacts[history] if self.contacts is not None else None, + ) + + def update(self): + self.model.train() + self.optimizer.param_groups[0]["lr"] = self.cfg.learning_rate + ( + self.cfg.final_learning_rate - self.cfg.learning_rate + ) * min(self.updates / self.cfg.learning_rate_decay_updates, 1.0) + x, y, contacts = self.sample(self.train_ids, self.cfg.batch_size // self.world_size) + for _ in range(self.cfg.max_gradient_retries): + self.optimizer.zero_grad(set_to_none=True) + with torch.autocast(self.device_type, dtype=self.dtype, enabled=self.dtype != torch.float32): + prediction = self.predict(x, contacts) + loss = (prediction.float() - y).square().mean() + finite = torch.isfinite(loss).to(torch.int32) + if self.world_size > 1: + dist.all_reduce(finite, op=dist.ReduceOp.MIN) + if not finite: + raise FloatingPointError(f"Nonfinite NeRD training loss at update {self.updates}.") + self.scaler.scale(loss).backward() + self.scaler.unscale_(self.optimizer) + norm = torch.nn.utils.clip_grad_norm_( + self.model.parameters(), self.cfg.gradient_clip_norm, error_if_nonfinite=not self.scaler.is_enabled() + ) + scale = self.scaler.get_scale() + self.scaler.step(self.optimizer) + self.scaler.update() + if self.scaler.get_scale() >= scale: + break + else: + raise FloatingPointError(f"NeRD gradients overflowed repeatedly at update {self.updates}.") + if self.world_size > 1: + dist.all_reduce(loss.detach()) + loss.detach().div_(self.world_size) + self.updates += 1 + return {"train/normalized_mse": float(loss.detach()), "train/gradient_norm": float(norm)} + + @torch.no_grad() + def validate(self): + self.model.eval() + totals = torch.zeros(3, dtype=torch.float64, device=self.device) + for ids in self.validation_ids[self.rank :: self.world_size].split(self.cfg.batch_size): + if not len(ids): + continue + history = (ids[:, None] + self.offsets).clamp_min(self.starts[ids, None]) + with torch.autocast(self.device_type, dtype=self.dtype, enabled=self.dtype != torch.float32): + contacts = self.contacts[history] if self.contacts is not None else None + prediction = ( + self.model.predict_last(self.inputs[history], contacts) + if contacts is not None + else self.model.predict_last(self.inputs[history]) + ) + error = prediction.float() - self.targets[ids] + totals[0] += error.square().sum() + totals[1] += (error * self.target_scale).square().sum() + totals[2] += error.numel() + if self.world_size > 1: + dist.all_reduce(totals) + return { + "validation/normalized_mse": float(totals[0] / totals[2]), + "validation/delta_mse": float(totals[1] / totals[2]), + } + + def checkpoint(self): + """Return a deployable and resumable snapshot; the composition root publishes it atomically.""" + generators = [self.generator.get_state().cpu()] + if self.world_size > 1: + generators = [None] * self.world_size + dist.all_gather_object(generators, self.generator.get_state().cpu()) + return { + "format": "isaaclab-nerd-v2", + "dynamics": self.dynamics.descriptor, + "datasets": self.identity, + "train_episodes": self.train_episodes, + "network_cfg": self.cfg.network, + "network_type": str(self.cfg.network_type), + "model": self.model.state_dict(), + "optimizer": self.optimizer.state_dict(), + "input_mean": self.input_mean, + "input_scale": self.input_scale, + "target_mean": self.target_mean, + "target_scale": self.target_scale, + "contact_mean": self.contact_mean, + "contact_scale": self.contact_scale, + "updates": self.updates, + "generators": generators, + "scaler": self.scaler.state_dict(), + } diff --git a/source/isaaclab_tasks/changelog.d/native-nerd.added.rst b/source/isaaclab_tasks/changelog.d/native-nerd.added.rst new file mode 100644 index 000000000000..b37935b2f6f3 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/native-nerd.added.rst @@ -0,0 +1,7 @@ +* Added native Cartpole and ANYmal C NeRD exploration, numerical-teacher and learned-physics + task configurations. Policies use the standard reinforcement-learning training and playback scripts. + ANYmal keeps ANYdrive outside NeRD and models each 5 ms physics step from applied effort. + Cartpole's actuator owns damping and effort limits; neither task uses solver-side motor drives. +* Allowed velocity task playback with an explicit ground asset instead of a terrain importer. +* Added rough-terrain ANYmal C numerical, exploration and learned tasks using live Newton contacts, + terrain-aware reset sampling and a contact-conditioned NeRD fitting recipe. diff --git a/source/isaaclab_tasks/changelog.d/native-nerd.minor b/source/isaaclab_tasks/changelog.d/native-nerd.minor new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_tasks/changelog.d/solver-config-replacement.fixed.rst b/source/isaaclab_tasks/changelog.d/solver-config-replacement.fixed.rst new file mode 100644 index 000000000000..26cade37fe0e --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/solver-config-replacement.fixed.rst @@ -0,0 +1 @@ +* Removed the Digit preset workaround for copying the derived Newton manager class. 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 58b2ff1cad2f..cbd1e3865774 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 @@ -40,12 +40,8 @@ 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 = replace( _ROUGH_NEWTON_MJWARP, - class_type=None, solver_cfg=replace(_ROUGH_NEWTON_MJWARP.solver_cfg, njmax=5000, nconmax=2000), ) default = isaacsim_physx diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/__init__.py index 99b7549a6463..b70eb314c951 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/__init__.py +++ b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/__init__.py @@ -14,6 +14,23 @@ from . import agents +for suffix, config in ( + ("", "CartpoleNeRDEnvCfg"), + ("-Explore", "CartpoleNeRDExplorationEnvCfg"), + ("-Learned", "CartpoleNeRDLearnedEnvCfg"), +): + gym.register( + id=f"Isaac-Cartpole-NeRD{suffix}", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + disable_env_checker=True, + kwargs={ + "env_cfg_entry_point": f"{__name__}.cartpole_nerd_env_cfg:{config}", + "nerd_cfg_entry_point": "isaaclab_rl.nerd:NeRDTrainCfg", + "rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_ppo_cfg:CartpolePPORunnerCfg", + "default_agent": "rsl_rl", + }, + ) + ## # Register Gym environments -- direct workflow. ## diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_nerd_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_nerd_env_cfg.py new file mode 100644 index 000000000000..5572cb0d9820 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_nerd_env_cfg.py @@ -0,0 +1,65 @@ +# 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 + +"""Native Cartpole tasks for offline dynamics collection and learned-physics policy training.""" + +import math + +from isaaclab_newton.envs.mdp.recorders import NeRDRecorderCfg, NeRDRecorderManagerCfg +from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg +from isaaclab_newton.physics.nerd_manager_cfg import NeRDSolverCfg + +from isaaclab.actuators import IdealPDActuatorCfg +from isaaclab.utils import configclass + +from .cartpole_manager_env_cfg import CartpoleEnvCfg + + +@configclass +class CartpoleNeRDEnvCfg(CartpoleEnvCfg): + """MuJoCo Warp teacher at 120 Hz, with optional native transition capture.""" + + recorders: NeRDRecorderManagerCfg = NeRDRecorderManagerCfg() + + def __post_init__(self): + super().__post_init__() + self.scene.robot.actuators = { + "joints": IdealPDActuatorCfg( + joint_names_expr=[".*"], + stiffness=0.0, + damping={"slider_to_cart": 10.0, "cart_to_pole": 0.0}, + actuator_effort_limit=400.0, + ) + } + self.decimation = self.sim.render_interval = 1 + self.sim.dt = 1.0 / 120.0 + self.sim.physics = NewtonCfg( + solver_cfg=MJWarpSolverCfg(njmax=5, nconmax=3, cone="pyramidal", impratio=1.0, integrator="implicitfast"), + num_substeps=1, + use_cuda_graph=True, + ) + + +@configclass +class CartpoleNeRDExplorationEnvCfg(CartpoleNeRDEnvCfg): + """Task-independent initial states for a dynamics dataset, including full pole rotations.""" + + def __post_init__(self): + super().__post_init__() + self.episode_length_s = 2.0 + self.events.reset_cart_position.params.update(position_range=(-2.0, 2.0), velocity_range=(-2.0, 2.0)) + self.events.reset_pole_position.params.update( + position_range=(-math.pi, math.pi), velocity_range=(-2 * math.pi, 2 * math.pi) + ) + self.recorders.transition = NeRDRecorderCfg() + + +@configclass +class CartpoleNeRDLearnedEnvCfg(CartpoleNeRDEnvCfg): + """The same control task advanced by a NeRD checkpoint.""" + + def __post_init__(self): + super().__post_init__() + self.sim.physics = NewtonCfg(solver_cfg=NeRDSolverCfg(), num_substeps=1, use_cuda_graph=True) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/anymal_c/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/anymal_c/__init__.py new file mode 100644 index 000000000000..aa2b0b08095d --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/anymal_c/__init__.py @@ -0,0 +1,41 @@ +# 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 + +"""Native ANYmal C NeRD collection and velocity-tracking tasks.""" + +import gymnasium as gym + +for suffix, config in ( + ("", "AnymalCNeRDEnvCfg"), + ("-Explore", "AnymalCNeRDExplorationEnvCfg"), + ("-Learned", "AnymalCNeRDLearnedEnvCfg"), +): + gym.register( + id=f"Isaac-Velocity-Flat-Anymal-C-NeRD{suffix}", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + disable_env_checker=True, + kwargs={ + "env_cfg_entry_point": f"{__name__}.nerd_env_cfg:{config}", + "nerd_cfg_entry_point": f"{__name__}:nerd_cfg.yaml", + "rsl_rl_cfg_entry_point": "isaaclab_tasks.core.velocity.config.anymal_d.agents.rsl_rl_ppo_cfg:AnymalDFlatPPORunnerCfg", + "default_agent": "rsl_rl", + }, + ) + +for suffix, config in ( + ("", "AnymalCNeRDRoughEnvCfg"), + ("-Explore", "AnymalCNeRDRoughExplorationEnvCfg"), + ("-Learned", "AnymalCNeRDRoughLearnedEnvCfg"), +): + gym.register( + id=f"Isaac-Velocity-Rough-Anymal-C-NeRD{suffix}", + entry_point="isaaclab.envs:ManagerBasedRLEnv", disable_env_checker=True, + kwargs={ + "env_cfg_entry_point": f"{__name__}.nerd_env_cfg:{config}", + "nerd_cfg_entry_point": f"{__name__}.nerd_env_cfg:AnymalCRoughNeRDTrainCfg", + "rsl_rl_cfg_entry_point": "isaaclab_tasks.core.velocity.config.anymal_d.agents.rsl_rl_ppo_cfg:AnymalDFlatPPORunnerCfg", + "default_agent": "rsl_rl", + }, + ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/anymal_c/nerd_cfg.yaml b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/anymal_c/nerd_cfg.yaml new file mode 100644 index 000000000000..dfcd176516ba --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/anymal_c/nerd_cfg.yaml @@ -0,0 +1,17 @@ +# 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 + +max_updates: 60000 +learning_rate_decay_updates: 60000 +batch_size: 4096 +validation_fraction: 0.05 +network: + num_heads: 10 + head_dim: 64 + intermediate_size: 896 + head_hidden: 192 + temporal_attention_spans: [4, 4, 4, 4] + rms_norm_eps: 1.0e-6 + linear_probe: true diff --git a/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/anymal_c/nerd_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/anymal_c/nerd_env_cfg.py new file mode 100644 index 000000000000..f95f1cecd3fe --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/core/velocity/config/anymal_c/nerd_env_cfg.py @@ -0,0 +1,212 @@ +# 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 + +"""ANYmal C applied-effort dynamics and locomotion tasks.""" + +import math + +import torch +from isaaclab_newton.envs.mdp.recorders import NeRDRecorderCfg, NeRDRecorderManagerCfg +from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg, NewtonShapeCfg +from isaaclab_newton.physics.nerd_manager_cfg import NeRDSolverCfg + +import isaaclab.envs.mdp as mdp +import isaaclab.sim as sim_utils +from isaaclab.assets import AssetBaseCfg +from isaaclab.managers import ManagerTermBase, RewardTermCfg, TerminationTermCfg +from isaaclab.terrains import FlatPatchSamplingCfg, TerrainImporterCfg +from isaaclab.terrains.config.rough import ROUGH_TERRAINS_CFG +from isaaclab.utils import configclass + +from isaaclab_assets.robots.anymal import ANYMAL_C_CFG + +from ..anymal_d.flat_env_cfg import AnymalDFlatEnvCfg + + +class control_period_joint_acc_l2(ManagerTermBase): + """Difference velocities over the control period in both numerical and learned physics.""" + + def __init__(self, cfg, env): + super().__init__(cfg, env) + self.robot = env.scene["robot"] + self.previous = self.robot.data.joint_vel.torch.clone() + self.fresh = torch.ones(env.num_envs, dtype=torch.bool, device=env.device) + + def reset(self, env_ids=None): + self.fresh[slice(None) if env_ids is None else env_ids] = True + + def __call__(self, env): + velocity = self.robot.data.joint_vel.torch + penalty = torch.where(self.fresh, 0.0, ((velocity - self.previous) / env.step_dt).square().sum(dim=1)) + self.previous.copy_(velocity) + self.fresh.zero_() + return penalty + + +@configclass +class AnymalCNeRDEnvCfg(AnymalDFlatEnvCfg): + """Flat-ground teacher with 5 ms ANYdrive updates and a 20 ms policy control period. + + The policy objective uses state-derived quantities that the learned solver also produces. + Fixed probe geometry requires one plane per world. Rough terrain needs another feature + contract and is not represented by these flat-ground probes. + """ + + recorders: NeRDRecorderManagerCfg = NeRDRecorderManagerCfg() + + def __post_init__(self): + super().__post_init__() + self.scene.robot = ANYMAL_C_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot") + self.sim.physics = NewtonCfg( + solver_cfg=MJWarpSolverCfg( + njmax=60, nconmax=25, cone="elliptic", impratio=100.0, integrator="implicitfast" + ), + num_substeps=1, + use_cuda_graph=True, + default_shape_cfg=NewtonShapeCfg(margin=0.0, ke=160000.0, kd=1100.0), + ) + material = self.scene.terrain.physics_material + self.scene.terrain = None + self.scene.ground = AssetBaseCfg( + prim_path="{ENV_REGEX_NS}/Ground", + spawn=sim_utils.GroundPlaneCfg( + size=(self.scene.env_spacing, self.scene.env_spacing), physics_material=material + ), + ) + self.scene.contact_forces = None + self.rewards.feet_air_time = self.rewards.undesired_contacts = self.rewards.dof_torques_l2 = None + self.rewards.dof_acc_l2 = RewardTermCfg(func=control_period_joint_acc_l2, weight=-1.2e-6) + self.terminations.base_contact = None + self.terminations.bad_orientation = TerminationTermCfg( + func=mdp.bad_orientation, params={"limit_angle": math.pi / 2} + ) + self.events.physics_material = self.events.add_base_mass = self.events.base_com = None + self.events.base_external_force_torque = self.events.push_robot = None + + +@configclass +class AnymalCNeRDExplorationEnvCfg(AnymalCNeRDEnvCfg): + """Random poses and targets, retaining falls for task-independent dynamics collection.""" + + def __post_init__(self): + super().__post_init__() + self.decimation = 1 + self.episode_length_s = 2.0 + self.terminations.bad_orientation = None + self.events.reset_base.params = { + "pose_range": { + "x": (-0.5, 0.5), + "y": (-0.5, 0.5), + "z": (-0.1, 0.1), + "roll": (-0.3, 0.3), + "pitch": (-0.3, 0.3), + "yaw": (-math.pi, math.pi), + }, + "velocity_range": { + "x": (-1.0, 1.0), + "y": (-1.0, 1.0), + "z": (-0.5, 0.5), + "roll": (-1.0, 1.0), + "pitch": (-1.0, 1.0), + "yaw": (-1.0, 1.0), + }, + } + self.events.reset_robot_joints.func = mdp.reset_joints_by_offset + self.events.reset_robot_joints.params = {"position_range": (-0.5, 0.5), "velocity_range": (-1.0, 1.0)} + self.recorders.transition = NeRDRecorderCfg( + heading=True, + contact_bodies=("LF_FOOT", "LH_FOOT", "RF_FOOT", "RH_FOOT"), + root_body="base", + ) + + +@configclass +class AnymalCNeRDLearnedEnvCfg(AnymalCNeRDEnvCfg): + """Keep ANYdrive and run four 5 ms learned physics steps per 20 ms policy action.""" + + def __post_init__(self): + super().__post_init__() + self.sim.physics = self.sim.physics.replace(solver_cfg=NeRDSolverCfg()) + + +@configclass +class AnymalCNeRDRoughExplorationEnvCfg(AnymalCNeRDExplorationEnvCfg): + """Physics-step collection across rough surfaces, conditioned on all Newton rigid contacts.""" + + def __post_init__(self): + super().__post_init__() + material = self.scene.ground.spawn.physics_material + self.scene.ground = None + self.scene.terrain = TerrainImporterCfg( + prim_path="/World/ground", + terrain_type="generator", + max_init_terrain_level=None, + terrain_generator=ROUGH_TERRAINS_CFG.replace( + seed=42, num_rows=4, num_cols=6, border_width=2.0, curriculum=False + ), + physics_material=material, + ) + self.sim.physics.solver_cfg.use_mujoco_contacts = False + self.sim.physics.solver_cfg.nconmax = 64 + self.sim.physics.solver_cfg.njmax = 256 + for terrain in self.scene.terrain.terrain_generator.sub_terrains.values(): + terrain.flat_patch_sampling = { + "init_pos": FlatPatchSamplingCfg( + num_patches=64, patch_radius=0.5, max_height_diff=0.12, x_range=(-3.0, 3.0), y_range=(-3.0, 3.0) + ) + } + self.scene.robot.init_state.pos = (0.0, 0.0, 0.8) + self.events.reset_base.func = mdp.reset_root_state_from_terrain + self.events.reset_base.params["pose_range"] = { + "roll": (-0.3, 0.3), + "pitch": (-0.3, 0.3), + "yaw": (-math.pi, math.pi), + } + self.recorders.transition.contact_bodies = () + self.recorders.transition.max_contacts = 64 + + +@configclass +class AnymalCNeRDRoughEnvCfg(AnymalCNeRDRoughExplorationEnvCfg): + """Rough-terrain locomotion with native ANYdrive and proprioceptive policy observations.""" + + def __post_init__(self): + super().__post_init__() + self.recorders.transition = None + self.decimation = self.sim.render_interval = 4 + self.episode_length_s = 20.0 + self.terminations.bad_orientation = TerminationTermCfg( + func=mdp.bad_orientation, params={"limit_angle": math.pi / 2} + ) + + +@configclass +class AnymalCNeRDRoughLearnedEnvCfg(AnymalCNeRDRoughEnvCfg): + """Replace the numerical rough-terrain solver while retaining collisions and ANYdrive.""" + + def __post_init__(self): + super().__post_init__() + self.sim.physics = self.sim.physics.replace(solver_cfg=NeRDSolverCfg()) + + +@configclass +class AnymalCRoughNeRDTrainCfg: + """Contact-conditioned offline fitting; optimizer defaults belong to the shared learner.""" + + max_updates = 60000 + learning_rate_decay_updates = 60000 + batch_size = 4096 + precision = "bfloat16" + network = { + "num_heads": 10, + "head_dim": 64, + "intermediate_size": 896, + "head_hidden": 192, + "temporal_attention_spans": (4, 4, 4, 4), + "rms_norm_eps": 1.0e-6, + "linear_probe": True, + "contact_slot_count": 8, + "contact_slot_dim": 64, + } 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 0e92889fc9fd..573d6cdc8c12 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 @@ -377,13 +377,14 @@ def __post_init__(self): def play_mode(self): """Play-mode overrides shared by the velocity-tracking environments.""" super().play_mode() - # spawn the robot randomly in the grid (instead of their terrain levels) - self.scene.terrain.max_init_terrain_level = None - # reduce the number of terrains to save memory - if self.scene.terrain.terrain_generator is not None: - self.scene.terrain.terrain_generator.num_rows = 5 - self.scene.terrain.terrain_generator.num_cols = 5 - self.scene.terrain.terrain_generator.curriculum = False + if self.scene.terrain is not None: + # spawn the robot randomly in the grid (instead of their terrain levels) + self.scene.terrain.max_init_terrain_level = None + # reduce the number of terrains to save memory + if self.scene.terrain.terrain_generator is not None: + self.scene.terrain.terrain_generator.num_rows = 5 + self.scene.terrain.terrain_generator.num_cols = 5 + self.scene.terrain.terrain_generator.curriculum = False # remove random pushing events self.events.base_external_force_torque = None self.events.push_robot = None From fcd290d2b3e50b566a55a07e5e5d6cb90cbdd084 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Mon, 26 Oct 2026 11:07:19 -0700 Subject: [PATCH 3/9] Add opt-in force and wrench reconstruction for NeRD --- docs/source/experimental-features/nerd.rst | 67 ++- .../changelog.d/nerd-forces.added.rst | 3 + .../changelog.d/nerd-forces.minor | 0 .../isaaclab_newton/physics/nerd_forces.py | 546 ++++++++++++++++++ .../isaaclab_newton/physics/nerd_manager.py | 39 +- .../physics/nerd_manager_cfg.py | 9 + .../test/physics/test_nerd_forces.py | 165 ++++++ source/isaaclab_newton/test/test_nerd.py | 32 +- 8 files changed, 844 insertions(+), 17 deletions(-) create mode 100644 source/isaaclab_newton/changelog.d/nerd-forces.added.rst create mode 100644 source/isaaclab_newton/changelog.d/nerd-forces.minor create mode 100644 source/isaaclab_newton/isaaclab_newton/physics/nerd_forces.py create mode 100644 source/isaaclab_newton/test/physics/test_nerd_forces.py diff --git a/docs/source/experimental-features/nerd.rst b/docs/source/experimental-features/nerd.rst index 4870992e7cb5..5ab63746e515 100644 --- a/docs/source/experimental-features/nerd.rst +++ b/docs/source/experimental-features/nerd.rst @@ -195,7 +195,65 @@ The last command evaluates the learned-physics policy in MuJoCo Warp. To evaluat policy in learned physics, use ``-Learned`` and the same dynamics-checkpoint override. The policy observation/action layouts and objectives agree between the two configurations. ANYmal's acceleration penalty uses the control period in both solvers. Contact-force rewards, -torque penalties and physics randomizations absent from the model's inputs are disabled in both. +torque penalties and physics randomizations absent from the model's inputs are disabled by default in both. + +Reconstructed forces for rewards +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +An existing applied-effort checkpoint can optionally report estimated contact forces and incoming +joint wrenches. Enable reconstruction in the learned task configuration and declare ordinary sensors; +no additional network or force labels are required: + +.. code-block:: python + + from isaaclab.envs import mdp + from isaaclab.managers import RewardTermCfg, SceneEntityCfg + from isaaclab.sensors import ContactSensorCfg, JointWrenchSensorCfg + + cfg.sim.physics.solver_cfg.reconstruct_forces = True + cfg.scene.robot.enable_joint_wrench = True + cfg.scene.contact_forces = ContactSensorCfg( + prim_path="{ENV_REGEX_NS}/Robot/.*", history_length=3, track_friction_forces=True + ) + cfg.scene.joint_wrenches = JointWrenchSensorCfg(prim_path="{ENV_REGEX_NS}/Robot") + cfg.rewards.contact_load = RewardTermCfg( + func=mdp.contact_forces, + weight=-1e-4, + params={"threshold": 100.0, "sensor_cfg": SceneEntityCfg("contact_forces")}, + ) + +Custom reward functions can read ``env.scene["contact_forces"].data.net_forces_w.torch`` for +net contact forces on each sensed body in world coordinates (N). They can read +``env.scene["robot"].data.body_joint_wrench.torch`` for incoming joint wrenches, ordered as +``[force_x, force_y, force_z, torque_x, torque_y, torque_z]`` in N and N m, expressed in the +child-side joint frame with torque taken about its anchor. ``JointWrenchSensor`` exposes the +same kind of measurement through its existing ``data.force`` and ``data.torque`` interfaces. +These are the latest physics-step measurements, not control-period averages. Contact force +and incoming joint force are different quantities; gravity, inertia and motor effort also +contribute to a joint reaction. The external body-force input buffer remains an input and is +not used to publish estimates. External body-wrench inputs and IMU acceleration remain unsupported. + +Reconstruction runs Newton collision detection even for a flat checkpoint without contact inputs. +It uses the physical mass matrix and contact Jacobian to project the predicted acceleration while +holding applied motor effort fixed, enforcing unilateral contact and elliptic friction cones. +Newton-Euler evaluation then supplies incoming joint wrenches when requested. Only the diagnostic +acceleration is projected: the learned positions and velocities are unchanged. CUDA graph capture +remains enabled, and persistent projection buffers belong to the solver. + +This is an experimental estimate, not equivalence to MuJoCo's compliant contact solver. Endpoint +motion does not uniquely identify every contact-force distribution, and small velocity errors can +produce large force errors after division by the physics timestep. Validate the force-dependent +reward against held-out teacher trajectories before relying on it for policy training; successful +state prediction alone does not establish wrench accuracy. The bounded projection uses FP64 setup +and a fused FP32 solve with at most 1,000 iterations and a stopping tolerance of ``1e-4`` on the +largest primal residual or force-iterate change in scaled cone coordinates. This is not a bound on +sensor error or a guarantee that every solve reaches that tolerance. + +The initial implementation supports CUDA, 1--60 velocity DOFs per world, at most 60 constraint +rows per world, joint limits, and frictionless or three-dimensional elliptic contacts. Equalities, +tendons, joint friction loss, adhesive contacts and higher-dimensional contact cones are rejected. +Capacity overflow and nonfinite reconstructed values fail explicitly. Each frictional contact +uses three constraint rows, so 60 rows do not imply support for 60 simultaneous frictional contacts. Scope and migration ~~~~~~~~~~~~~~~~~~~ @@ -226,7 +284,6 @@ long-horizon policy benchmarks or a multi-GPU scaling measurement. The rough case also exercises live contact capture, contact-conditioned fitting and resume, deployment with a different terrain and world count, and rejection of changed contact friction. -Joint wrenches and contact forces are not learned outputs. Reconstruction requires the physical -model, contact law and acceleration in addition to position/velocity. Endpoint state alone does -not generally determine a unique contact-force distribution. Analytic reconstruction is being -validated separately and is not exposed as a learned-solver sensor capability. +Force-reconstruction tests additionally cover fixed-effort support and flight, friction saturation, +contact capacity boundaries, masked resets, and existing sensor/reward access during graph replay. +Enabling reconstruction preserves the learned trajectory in the flat ANYmal integration test. diff --git a/source/isaaclab_newton/changelog.d/nerd-forces.added.rst b/source/isaaclab_newton/changelog.d/nerd-forces.added.rst new file mode 100644 index 000000000000..00a1af72173f --- /dev/null +++ b/source/isaaclab_newton/changelog.d/nerd-forces.added.rst @@ -0,0 +1,3 @@ +* Added optional fixed-effort NeRD contact-force and incoming joint-wrench reconstruction through + existing sensor and articulation interfaces. Enable ``NeRDSolverCfg.reconstruct_forces`` and + validate force accuracy before using estimates in rewards. diff --git a/source/isaaclab_newton/changelog.d/nerd-forces.minor b/source/isaaclab_newton/changelog.d/nerd-forces.minor new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_newton/isaaclab_newton/physics/nerd_forces.py b/source/isaaclab_newton/isaaclab_newton/physics/nerd_forces.py new file mode 100644 index 000000000000..b51591755ae2 --- /dev/null +++ b/source/isaaclab_newton/isaaclab_newton/physics/nerd_forces.py @@ -0,0 +1,546 @@ +# 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 + +"""Optional fixed-effort inverse dynamics for NeRD sensor estimates. + +The constrained projection preserves applied motor effort and unilateral friction cones. +It adjusts diagnostic acceleration, not the learned motion, and does not impose MuJoCo's +soft-contact law. Newton owns coordinate/contact conversion and Newton-Euler evaluation. +""" + +import mujoco_warp as mjw +import newton +import numpy as np +import torch +import warp as wp +from mujoco_warp._src.inverse import discrete_acc +from newton._src.solvers.mujoco.kernels import convert_rigid_forces_from_mj_kernel + +_Vec64 = wp.types.vector(length=64, dtype=wp.float32) + + +def _make_projection_kernel(block_size): + @wp.kernel(enable_backward=False, module="unique") + def solve_projection_kernel( + inverse: wp.array3d[wp.float32], + rhs: wp.array2d[wp.float32], + rho: wp.array[wp.float32], + active: wp.array2d[wp.bool], + contact: wp.array2d[wp.bool], + normal: wp.array2d[wp.int64], + tangent1: wp.array2d[wp.int64], + tangent2: wp.array2d[wp.int64], + iterations: int, + tolerance: wp.float32, + force: wp.array2d[wp.float32], + primal: wp.array2d[wp.float32], + dual_residual: wp.array2d[wp.float32], + iteration_counts: wp.array[int], + ): + world, lane = wp.tid() + count = rhs.shape[1] + row = _Vec64() + b = wp.float32(0.0) + enabled = False + friction = False + ni, ti, tj = int(0), int(0), int(0) + if lane < count: + enabled = active[world, lane] + b = wp.where(enabled, rhs[world, lane], wp.float32(0.0)) + friction = contact[world, lane] + ni = int(normal[world, lane]) + ti = int(tangent1[world, lane]) + tj = int(tangent2[world, lane]) + extra_enabled = int(0) + if wp.static(block_size == 32): + if lane + 32 < count: + extra_enabled = int(active[world, lane + 32]) + active_count = wp.tile_sum(wp.tile(int(enabled) + extra_enabled))[0] + if wp.static(block_size == 32): + if active_count > 32: + return + else: + if active_count <= 32: + return + if enabled: + for j in range(64): + if j < count and j < block_size: + row[j] = inverse[world, lane, j] + penalty = rho[world] + z, dual, x, previous = wp.float32(0.0), wp.float32(0.0), wp.float32(0.0), wp.float32(0.0) + performed = int(0) + for iteration in range(iterations): + if active_count == 0: + break + vector = wp.tile(b + penalty * (z - dual)) + x = wp.float32(0.0) + if active_count <= 16: + for j in range(16): + x += row[j] * vector[j] + elif active_count <= 32: + for j in range(32): + x += row[j] * vector[j] + else: + if wp.static(block_size == 64): + for j in range(64): + x += row[j] * vector[j] + values = wp.tile(x + dual) + n, a, c = values[ni], values[ti], values[tj] + radius = wp.float32(0.0) + if friction: + radius = wp.sqrt(a * a + c * c) + magnitude = wp.max((n + radius) * wp.float32(0.5), wp.float32(0.0)) + factor = wp.float32(1.0) + if n < radius: + factor = magnitude / wp.max(radius, wp.float32(1.0e-30)) + previous = z + z = (x + dual) * factor + if lane == ni: + z = n + if n < radius: + z = magnitude + if not enabled: + z = wp.float32(0.0) + dual += x - z + performed = iteration + 1 + if tolerance > wp.float32(0.0) and performed % 8 == 0: + residual = wp.tile_max(wp.tile(wp.max(wp.abs(x - z), wp.abs(z - previous))))[0] + if residual <= tolerance: + break + if lane < count: + force[world, lane] = z + primal[world, lane] = x - z + dual_residual[world, lane] = penalty * (z - previous) + if lane == 0: + iteration_counts[world] = performed + if wp.static(block_size == 32): + if lane + 32 < count: + force[world, lane + 32] = wp.float32(0.0) + primal[world, lane + 32] = wp.float32(0.0) + dual_residual[world, lane + 32] = wp.float32(0.0) + + wp.set_module_options({"max_unroll": 64}, module=solve_projection_kernel.module) + return solve_projection_kernel + + +_PROJECTION_KERNELS = (_make_projection_kernel(32), _make_projection_kernel(64)) + + +@wp.kernel(enable_backward=False) +def _prepare_projection( + counts: wp.array[int], + types: wp.array2d[int], + ids: wp.array2d[int], + addresses: wp.array2d[int], + friction: wp.array2d[float], + source_jacobian: wp.array3d[float], + source_mass: wp.array2d[float], + mass_indices: wp.array2d[int], + source_smooth: wp.array2d[float], + source_acceleration: wp.array2d[float], + active: wp.array2d[wp.bool], + contact: wp.array2d[wp.bool], + normal: wp.array2d[wp.int64], + tangent1: wp.array2d[wp.int64], + tangent2: wp.array2d[wp.int64], + scale: wp.array2d[wp.float64], + jacobian: wp.array3d[wp.float64], + mass: wp.array3d[wp.float64], + smooth: wp.array2d[wp.float64], + acceleration: wp.array2d[wp.float64], +): + world, row = wp.tid() + enabled = row < counts[world] + is_contact = enabled and types[world, row] == 7 + ni, t1, t2 = row, row, row + coefficient = wp.float64(1.0) + if is_contact: + contact_id = wp.max(ids[world, row], 0) + ni = wp.max(addresses[contact_id, 0], 0) + t1 = wp.max(addresses[contact_id, 1], 0) + t2 = wp.max(addresses[contact_id, 2], 0) + if row == t1: + coefficient = wp.float64(friction[contact_id, 0]) + if row == t2: + coefficient = wp.float64(friction[contact_id, 1]) + active[world, row] = enabled + contact[world, row] = is_contact + normal[world, row] = wp.int64(ni) + tangent1[world, row] = wp.int64(t1) + tangent2[world, row] = wp.int64(t2) + scale[world, row] = coefficient + for column in range(mass.shape[1]): + value = wp.float64(0.0) + if enabled: + value = wp.float64(source_jacobian[world, row, column]) + jacobian[world, row, column] = value + if row < mass.shape[1]: + value = wp.float64(0.0) + index = mass_indices[row, column] + if index >= 0: + value = wp.float64(source_mass[world, index]) + mass[world, row, column] = value + if row < mass.shape[1]: + smooth[world, row] = wp.float64(source_smooth[world, row]) + acceleration[world, row] = wp.float64(source_acceleration[world, row]) + + +@wp.func +def _to_float32(value: wp.float64) -> wp.float32: + return wp.float32(value) + + +def _make_setup_kernels(num_dofs, rows, lower_bound): + @wp.kernel(enable_backward=False, module="unique") + def form_projection( + mass: wp.array3d[wp.float64], + jacobian: wp.array3d[wp.float64], + scale: wp.array2d[wp.float64], + smooth: wp.array2d[wp.float64], + acceleration: wp.array2d[wp.float64], + counts: wp.array[int], + mass_identity: wp.array2d[wp.float64], + mass_inverse: wp.array3d[wp.float64], + hessian: wp.array3d[wp.float64], + rhs: wp.array2d[wp.float32], + rho: wp.array[wp.float64], + ): + world, lane = wp.tid() + if counts[world] <= lower_bound or counts[world] > rows: + return + m = wp.tile_load(mass[world], shape=(num_dofs, num_dofs)) + eye_m = wp.tile_load(mass_identity, shape=(num_dofs, num_dofs)) + mi = wp.tile_cholesky_solve(wp.tile_cholesky(m), eye_m) + j = wp.tile_load(jacobian[world], shape=(rows, num_dofs)) + s = wp.tile_reshape(wp.tile_load(scale[world], shape=rows), shape=(rows, 1)) + scaled_j = wp.tile_map(wp.mul, j, wp.tile_broadcast(s, shape=(rows, num_dofs))) + jm = wp.tile_matmul(scaled_j, mi) + h = wp.tile_matmul(jm, wp.tile_transpose(scaled_j)) + a = wp.tile_reshape(wp.tile_load(acceleration[world], shape=num_dofs), shape=(num_dofs, 1)) + f = wp.tile_reshape(wp.tile_load(smooth[world], shape=num_dofs), shape=(num_dofs, 1)) + b = wp.tile_matmul(jm, wp.tile_matmul(m, a) - f) + trace = wp.float64(0.0) + for i in range(rows): + trace += h[i, i] + penalty = wp.max(trace / wp.float64(wp.max(counts[world], 1)), wp.float64(1.0e-8)) + wp.tile_store(mass_inverse[world], mi) + wp.tile_store(hessian[world], h) + wp.tile_store(rhs[world], wp.tile_map(_to_float32, wp.tile_reshape(b, shape=(rows,)))) + rho[world] = penalty + + @wp.kernel(enable_backward=False, module="unique") + def factor_projection( + hessian: wp.array3d[wp.float64], + rho: wp.array[wp.float64], + counts: wp.array[int], + identity: wp.array2d[wp.float64], + step_inverse: wp.array3d[wp.float32], + ): + world, lane = wp.tid() + if counts[world] <= lower_bound or counts[world] > rows: + return + h = wp.tile_load(hessian[world], shape=(rows, rows), storage="shared") + diagonal = wp.tile_ones(shape=rows, dtype=wp.float64) * rho[world] + factor = wp.tile_diag_add(h, diagonal) + wp.tile_cholesky_inplace(factor) + inverse = wp.tile_load(identity, shape=(rows, rows), storage="shared") + wp.tile_cholesky_solve_inplace(factor, inverse) + wp.tile_store(step_inverse[world], wp.tile_map(_to_float32, inverse)) + + return form_projection, factor_projection + + +@wp.kernel +def _prediction_acceleration( + previous: wp.array2d[float], predicted: wp.array2d[float], dt: float, acceleration: wp.array2d[float] +): + world, dof = wp.tid() + acceleration[world, dof] = (predicted[world, dof] - previous[world, dof]) / dt + + +@wp.kernel +def _write_reaction( + force: wp.array2d[float], + scale: wp.array2d[wp.float64], + jacobian: wp.array3d[wp.float64], + smooth: wp.array2d[wp.float64], + reaction: wp.array2d[float], + generalized_force: wp.array2d[wp.float64], +): + world, row = wp.tid() + reaction[world, row] = float(wp.float64(force[world, row]) * scale[world, row]) + if row < generalized_force.shape[1]: + total = smooth[world, row] + for constraint in range(force.shape[1]): + total += jacobian[world, constraint, row] * wp.float64(force[world, constraint]) * scale[world, constraint] + generalized_force[world, row] = total + + +@wp.kernel +def _write_acceleration( + inverse_mass: wp.array3d[wp.float64], generalized_force: wp.array2d[wp.float64], acceleration: wp.array2d[float] +): + world, row = wp.tid() + value = wp.float64(0.0) + for dof in range(acceleration.shape[1]): + value += inverse_mass[world, row, dof] * generalized_force[world, dof] + acceleration[world, row] = float(value) + + +@wp.kernel +def _cast_penalty(source: wp.array[wp.float64], target: wp.array[float]): + world = wp.tid() + target[world] = float(source[world]) + + +@wp.kernel +def _reset_wrenches(worlds: wp.array[int], mask: wp.array[wp.bool], values: wp.array[wp.spatial_vector]): + body = wp.tid() + world = worlds[body] + if world < 0: + world = mask.shape[0] - 1 + if mask[world]: + values[body] = wp.spatial_vectorf(0.0) + + +class _ForceReconstruction: + """Own inverse-dynamics conversion and fixed-capacity buffers for one NeRD solver. + + Minimize ``(a-a_predicted).T M (a-a_predicted) / 2`` subject to + ``M a = applied + passive - bias + J.T f`` and unilateral elliptic cones. + Only the diagnostic acceleration is projected; the caller retains predicted joint state. + """ + + def __init__(self, model, dt): + self.model, self.dt = model, dt + self.solver = newton.solvers.SolverMuJoCo( + model, + separate_worlds=True, + njmax=60, + nconmax=60, + cone="elliptic", + jacobian="dense", + integrator="implicitfast", + use_mujoco_contacts=False, + enable_sleeping=False, + ) + m, d = self.solver.mjw_model, self.solver.mjw_data + if not 1 <= m.nv <= 60 or d.njmax != 60: + raise ValueError("NeRD force reconstruction supports 1-60 DOFs and at most 60 constraint rows per world.") + if m.neq or m.ntendon or np.any(self.solver.mj_model.dof_frictionloss): + raise ValueError("NeRD force reconstruction does not support equalities, tendons or joint friction loss.") + if np.any(~np.isin(self.solver.mj_model.geom_condim, (1, 3))): + raise ValueError("NeRD force reconstruction supports frictionless or 3D elliptic contacts only.") + if np.any(model.shape_material_ka.numpy()): + raise ValueError("NeRD force reconstruction does not support adhesive contacts.") + m.opt.timestep.fill_(dt) + worlds, nv, rows, device = d.nworld, m.nv, d.njmax, model.device + self._previous_q = wp.empty_like(d.qpos) + self._previous_qd = wp.empty_like(d.qvel) + self._active = wp.zeros((worlds, rows), dtype=wp.bool, device=device) + self._contact = wp.zeros_like(self._active) + self._normal = wp.zeros((worlds, rows), dtype=wp.int64, device=device) + self._tangent1, self._tangent2 = wp.zeros_like(self._normal), wp.zeros_like(self._normal) + self._scale = wp.zeros((worlds, rows), dtype=wp.float64, device=device) + self._jacobian = wp.zeros((worlds, rows, nv), dtype=wp.float64, device=device) + self._mass = wp.zeros((worlds, nv, nv), dtype=wp.float64, device=device) + self._mass_inverse = wp.zeros_like(self._mass) + self._smooth = wp.zeros((worlds, nv), dtype=wp.float64, device=device) + self._acceleration = wp.zeros_like(self._smooth) + self._generalized_force = wp.zeros_like(self._smooth) + self._hessian = wp.zeros((worlds, rows, rows), dtype=wp.float64, device=device) + self._rhs = wp.zeros((worlds, rows), dtype=float, device=device) + self._rho = wp.zeros(worlds, dtype=wp.float64, device=device) + self._rho_float = wp.zeros(worlds, dtype=float, device=device) + self._inverse = wp.zeros((worlds, rows, rows), dtype=float, device=device) + self._force, self._primal, self._dual = [wp.zeros_like(self._rhs) for _ in range(3)] + self._iterations = wp.zeros(worlds, dtype=int, device=device) + self._mass_identity = wp.array(np.eye(nv), dtype=wp.float64, device=device) + self._identity = wp.array(np.eye(rows), dtype=wp.float64, device=device) + indices = np.full((nv, nv), -1, dtype=np.int32) + indices[np.repeat(np.arange(nv), np.diff(m.M_mulm_rowadr.numpy())), m.M_mulm_col.numpy()] = ( + m.M_mulm_madr.numpy() + ) + self._mass_indices = wp.array(indices, dtype=int, device=device) + self._setup_kernels = tuple( + (*_make_setup_kernels(nv, capacity, lower), 32 if capacity <= 32 else 128) + for capacity, lower in ((16, -1), (32, 16), (rows, 32)) + ) + self._row_ids = torch.arange(rows, device=device.alias)[None] + self._contact_ids = torch.arange(d.naconmax, device=device.alias) + + def prepare(self, state, control, contacts): + """Read starting state, applied effort and collision geometry before in-place prediction writes.""" + m, d = self.solver.mjw_model, self.solver.mjw_data + self.solver._update_mjc_data(d, self.model, state) + self.solver._apply_mjc_control(self.model, state, control, d) + torch._assert_async( + (wp.to_torch(contacts.rigid_contact_count) <= min(contacts.rigid_contact_max, d.naconmax)).all(), + "NeRD reconstruction collision capacity overflowed.", + ) + self.solver._convert_contacts_to_mjwarp(self.model, state, contacts) + wp.copy(self._previous_q, d.qpos) + wp.copy(self._previous_qd, d.qvel) + mjw.fwd_position(m, d) + mjw.fwd_velocity(m, d) + mjw.fwd_acceleration(m, d) + + def update(self, state): + """Project the prediction and fill Newton's existing incoming-wrench storage.""" + m, d = self.solver.mjw_model, self.solver.mjw_data + self.solver._update_mjc_data(d, self.model, state) + wp.launch( + _prediction_acceleration, + d.qvel.shape, + inputs=[self._previous_qd, d.qvel, self.dt], + outputs=[d.qacc], + device=self.model.device, + ) + wp.copy(d.qpos, self._previous_q) + wp.copy(d.qvel, self._previous_qd) + discrete_acc(m, d, d.qacc) + counts, types = wp.to_torch(d.nefc), wp.to_torch(d.efc.type) + active = self._row_ids < counts[:, None] + torch._assert_async((counts <= d.njmax).all(), "NeRD reconstruction constraint capacity overflowed.") + torch._assert_async( + (wp.to_torch(d.nacon) <= d.naconmax).all(), "NeRD reconstruction contact capacity overflowed." + ) + torch._assert_async( + (~active | (types == 3) | (types == 4) | (types == 5) | (types == 7)).all(), + "NeRD reconstruction encountered an unsupported constraint type.", + ) + dimensions = wp.to_torch(d.contact.dim) + torch._assert_async( + ((self._contact_ids >= wp.to_torch(d.nacon)[0]) | (dimensions == 1) | (dimensions == 3)).all(), + "NeRD reconstruction supports frictionless or 3D contacts only.", + ) + wp.launch( + _prepare_projection, + (d.nworld, d.njmax), + inputs=[ + d.nefc, + d.efc.type, + d.efc.id, + d.contact.efc_address, + wp.from_torch(wp.to_torch(d.contact.friction)), + d.efc.J, + d.M, + self._mass_indices, + d.qfrc_smooth, + d.qacc, + ], + outputs=[ + self._active, + self._contact, + self._normal, + self._tangent1, + self._tangent2, + self._scale, + self._jacobian, + self._mass, + self._smooth, + self._acceleration, + ], + device=self.model.device, + ) + for form, factor, block in self._setup_kernels: + wp.launch_tiled( + form, + dim=d.nworld, + block_dim=block, + inputs=[ + self._mass, + self._jacobian, + self._scale, + self._smooth, + self._acceleration, + d.nefc, + self._mass_identity, + ], + outputs=[self._mass_inverse, self._hessian, self._rhs, self._rho], + device=self.model.device, + ) + wp.launch_tiled( + factor, + dim=d.nworld, + block_dim=block, + inputs=[self._hessian, self._rho, d.nefc, self._identity], + outputs=[self._inverse], + device=self.model.device, + ) + wp.launch(_cast_penalty, d.nworld, inputs=[self._rho], outputs=[self._rho_float], device=self.model.device) + for block, kernel in zip((32, 64), _PROJECTION_KERNELS): + wp.launch( + kernel, + dim=(d.nworld, block), + block_dim=block, + inputs=[ + self._inverse, + self._rhs, + self._rho_float, + self._active, + self._contact, + self._normal, + self._tangent1, + self._tangent2, + 1000, + 1e-4, + ], + outputs=[self._force, self._primal, self._dual, self._iterations], + device=self.model.device, + ) + wp.launch( + _write_reaction, + (d.nworld, d.njmax), + inputs=[self._force, self._scale, self._jacobian, self._smooth], + outputs=[d.efc.force, self._generalized_force], + device=self.model.device, + ) + wp.launch( + _write_acceleration, + (d.nworld, m.nv), + inputs=[self._mass_inverse, self._generalized_force], + outputs=[d.qacc], + device=self.model.device, + ) + torch._assert_async( + torch.isfinite(wp.to_torch(d.qacc)).all() & torch.isfinite(wp.to_torch(d.efc.force)).all(), + "NeRD force reconstruction produced nonfinite values.", + ) + if state.body_parent_f is not None: + mjw.rne_postconstraint(m, d) + wp.launch( + convert_rigid_forces_from_mj_kernel, + dim=self.solver.mjc_body_to_newton.shape, + inputs=[ + self.solver.mjc_body_to_newton, + m.body_rootid, + m.opt.gravity, + d.xipos, + d.subtree_com, + d.cacc, + d.cvel, + d.cfrc_int, + ], + outputs=[None, state.body_parent_f], + device=self.model.device, + ) + torch._assert_async( + torch.isfinite(wp.to_torch(state.body_parent_f)).all(), + "NeRD wrench reconstruction produced nonfinite values.", + ) + + def reset(self, state, world_mask): + """Discard incoming wrenches for reset worlds; each reconstruction starts from zero dual state.""" + if state.body_parent_f is not None: + if world_mask is None: + state.body_parent_f.zero_() + else: + wp.launch( + _reset_wrenches, + self.model.body_count, + inputs=[self.model.body_world, world_mask, state.body_parent_f], + device=self.model.device, + ) diff --git a/source/isaaclab_newton/isaaclab_newton/physics/nerd_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/nerd_manager.py index c9f2a371f41b..7fa0084b288e 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/nerd_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/nerd_manager.py @@ -14,6 +14,7 @@ from isaaclab.utils import string_to_callable from .nerd import JointDynamics, model_signature +from .nerd_forces import _ForceReconstruction from .newton_manager import NewtonManager @@ -31,7 +32,7 @@ def _write_joint_state( class SolverNeRD(newton.solvers.SolverBase): """Fixed-storage history, resets and inference; all Python decisions are construction-time decisions.""" - def __init__(self, model, checkpoint, *, compile=True): + def __init__(self, model, checkpoint, *, compile=True, reconstruct_forces=False): super().__init__(model) if not model.device.is_cuda: raise ValueError("The native NeRD solver currently requires CUDA.") @@ -42,6 +43,7 @@ def __init__(self, model, checkpoint, *, compile=True): if model_signature(model, contact_conditioned=bool(self.dynamics.max_contacts)) != saved["dynamics"]["model"]: raise ValueError("NeRD checkpoint physical model differs from the live scene.") self.frame_dt = saved["dynamics"]["frame_dt"] + self.reconstruction = _ForceReconstruction(model, self.frame_dt) if reconstruct_forces else None device = model.device.alias self.network = ( string_to_callable(saved["network_type"])( @@ -85,6 +87,8 @@ def reset(self, state, *, world_mask=None, flags=0): self.pending_reset.fill_(True) else: self.pending_reset.logical_or_(wp.to_torch(world_mask)[: self.model.world_count]) + if self.reconstruction is not None: + self.reconstruction.reset(state, world_mask) @torch.no_grad() def step(self, state_in, state_out, control, contacts, dt): @@ -115,6 +119,8 @@ def step(self, state_in, state_out, control, contacts, dt): prediction = self.dynamics.codec.delta_to_state(state, delta) torch._assert_async(torch.isfinite(prediction).all(), "NeRD produced a nonfinite state.") layout = self.dynamics.codec.layout + if self.reconstruction is not None: + self.reconstruction.prepare(state_in, control, contacts) wp.launch( _write_joint_state, dim=prediction.shape, @@ -128,6 +134,18 @@ def step(self, state_in, state_out, control, contacts, dt): device=self.model.device, ) newton.eval_fk(self.model, state_out.joint_q, state_out.joint_qd, state_out) + if self.reconstruction is not None: + self.reconstruction.update(state_out) + + def get_max_contact_count(self): + """Size the shared Newton contact buffer for force reporting.""" + return 0 if self.reconstruction is None else self.reconstruction.solver.get_max_contact_count() + + def update_contacts(self, contacts, state=None): + """Publish reconstructed forces on the same Newton collision rows used by the prediction.""" + if self.reconstruction is None: + raise ValueError("Enable NeRDSolverCfg.reconstruct_forces to use contact sensors.") + self.reconstruction.solver._populate_contact_observables(contacts, contacts.force, preserve_geometry=True) def notify_model_changed(self, flags): # Fixed-base root-pose resets can notify unchanged joint properties. Verify the same @@ -146,18 +164,21 @@ class NewtonNeRDManager(NewtonManager): def _build_solver(self, model, solver_cfg): if self._num_substeps != 1 or not solver_cfg.checkpoint: raise ValueError("NeRD requires num_substeps=1 and a checkpoint path.") - if ( - set(model.get_requested_contact_attributes()) & newton.Contacts.EXTENDED_ATTRIBUTES - or set(model.get_requested_state_attributes()) & newton.State.EXTENDED_ATTRIBUTES - ): - raise ValueError("NeRD does not produce extended sensor state or solved contact forces.") - self._solver = SolverNeRD(model, solver_cfg.checkpoint, compile=solver_cfg.compile) + requested_contacts = set(model.get_requested_contact_attributes()) & newton.Contacts.EXTENDED_ATTRIBUTES + requested_state = set(model.get_requested_state_attributes()) & newton.State.EXTENDED_ATTRIBUTES + if requested_contacts - {"force"} or requested_state - {"body_parent_f"}: + raise ValueError("NeRD force reconstruction supports contact forces and incoming joint wrenches only.") + if (requested_contacts or requested_state) and not solver_cfg.reconstruct_forces: + raise ValueError("Enable NeRDSolverCfg.reconstruct_forces to use contact or joint-wrench sensors.") + self._solver = SolverNeRD( + model, solver_cfg.checkpoint, compile=solver_cfg.compile, reconstruct_forces=solver_cfg.reconstruct_forces + ) if not math.isclose(self._solver_dt, self._solver.frame_dt, rel_tol=1e-9): raise ValueError("Set sim.dt to the NeRD checkpoint's physics-step duration.") self._use_single_state = True - self._needs_collision_pipeline = bool(self._solver.dynamics.max_contacts) + self._needs_collision_pipeline = bool(self._solver.dynamics.max_contacts) or solver_cfg.reconstruct_forces self._supports_rigid_body_force_input = False - self._supports_contact_sensors = False + self._supports_contact_sensors = solver_cfg.reconstruct_forces def _capture_graph(self, capture_target): # Warp owns graph capture/replay. Isolate its Torch temporaries from the caller's caching diff --git a/source/isaaclab_newton/isaaclab_newton/physics/nerd_manager_cfg.py b/source/isaaclab_newton/isaaclab_newton/physics/nerd_manager_cfg.py index e7c511d5202d..e884a1a56826 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/nerd_manager_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/nerd_manager_cfg.py @@ -27,3 +27,12 @@ class NeRDSolverCfg(NewtonSolverCfg): compile: bool = True """Compile model inference before the native manager captures its physics CUDA graph.""" + + reconstruct_forces: bool = False + """Estimate contact forces and incoming joint wrenches from predicted motion and applied effort. + + Enables ordinary contact sensors, joint-wrench sensors and ``ArticulationData.body_joint_wrench``. + Adds collision and inverse-dynamics work to every physics step. Estimates use a constrained + acceleration projection; they neither change learned motion nor reproduce MuJoCo's contact law. + Validate force-based rewards on held-out motion before using them for policy training. + """ diff --git a/source/isaaclab_newton/test/physics/test_nerd_forces.py b/source/isaaclab_newton/test/physics/test_nerd_forces.py new file mode 100644 index 000000000000..3a34b98860fd --- /dev/null +++ b/source/isaaclab_newton/test/physics/test_nerd_forces.py @@ -0,0 +1,165 @@ +# 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 + +"""Independent force-balance contracts for NeRD's diagnostic inverse dynamics.""" + +import newton +import numpy as np +import pytest +import torch +import warp as wp +from isaaclab_newton.physics.nerd_forces import _ForceReconstruction + +from isaaclab.test.utils import DeviceScope, test_devices + + +@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) +def test_fixed_effort_support_flight_and_reset(device): + """Contact supports weight minus actual effort; flight cannot invent support or motor effort.""" + builder = newton.ModelBuilder() + builder.request_state_attributes("body_parent_f") + builder.request_contact_attributes("force") + builder.add_ground_plane() + for world in range(3): + builder.begin_world() + base = builder.add_link(mass=2.0, inertia=wp.mat33(0.02, 0.0, 0.0, 0.0, 0.02, 0.0, 0.0, 0.0, 0.02)) + child = builder.add_link(mass=1.0, inertia=wp.mat33(0.01, 0.0, 0.0, 0.0, 0.01, 0.0, 0.0, 0.0, 0.01)) + slider = builder.add_joint_prismatic( + -1, base, axis=(0.0, 0.0, 1.0), parent_xform=wp.transform((world * 2.0, 0.0, 0.199), wp.quat_identity()) + ) + mount = builder.add_joint_fixed(base, child, parent_xform=wp.transform((0.0, 0.0, 0.4), wp.quat_identity())) + builder.add_articulation([slider, mount]) + builder.add_shape_sphere(base, radius=0.2, cfg=newton.ModelBuilder.ShapeConfig(density=0.0, mu=0.5)) + builder.end_world() + model = builder.finalize(device=device) + state, control = model.state(), model.control() + wp.to_torch(state.joint_q)[1] = 1.0 + newton.eval_fk(model, state.joint_q, state.joint_qd, state) + control.joint_f.assign(np.array([5.0, -3.0, 40.0], dtype=np.float32)) + reconstruction = _ForceReconstruction(model, 0.005) + collision = newton.CollisionPipeline(model, rigid_contact_max=reconstruction.solver.get_max_contact_count()) + contacts = collision.contacts() + collision.collide(state, contacts) + q, qd, effort = [wp.to_torch(x).clone() for x in (state.joint_q, state.joint_qd, control.joint_f)] + + def reconstruct(): + reconstruction.prepare(state, control, contacts) + reconstruction.update(state) + reconstruction.solver._populate_contact_observables(contacts, contacts.force, preserve_geometry=True) + + pool = torch.cuda.MemPool() + with torch.cuda.stream(wp.stream_to_torch(wp.get_stream(device))), torch.cuda.use_mem_pool(pool): + reconstruct() + with wp.ScopedCapture(device=device) as capture: + reconstruct() + wp.capture_launch(capture.graph) + wp.synchronize_device(device) + for original, current in zip((q, qd, effort), (state.joint_q, state.joint_qd, control.joint_f)): + torch.testing.assert_close(wp.to_torch(current), original, rtol=0, atol=0) + acceleration = wp.to_torch(reconstruction.solver.mjw_data.qacc)[:, 0] + expected_acceleration = torch.tensor([0.0, -10.81, 40.0 / 3.0 - 9.81], device=device) + torch.testing.assert_close(acceleration, expected_acceleration, atol=1e-3, rtol=1e-4) + # The fixed 1 kg child requires F = m(a + g), independently of contact distribution. + parent_forces = wp.to_torch(state.body_parent_f).reshape(3, 2, 6) + torch.testing.assert_close(parent_forces[:, 1, 2], expected_acceleration + 9.81, atol=1e-3, rtol=1e-4) + torch.testing.assert_close(parent_forces[:, 0, 2], effort, atol=1e-3, rtol=1e-4) + force = wp.to_torch(contacts.force) + assert force.abs().max() > 20.0 + assert torch.isfinite(force).all() + # Masked reset clears incoming wrenches only in the selected world. + before = parent_forces.clone() + mask = wp.array([False, True, False, False], dtype=wp.bool, device=device) + reconstruction.reset(state, mask) + assert not parent_forces[1].any() + torch.testing.assert_close(parent_forces[[0, 2]], before[[0, 2]], rtol=0, atol=0) + + +@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) +def test_reconstruction_contact_capacity_boundaries(device): + """Independent sliders have known support loads across both GPU solve sizes and no-contact worlds.""" + counts = (0, 1, 6, 12, 20) + builder = newton.ModelBuilder() + builder.add_ground_plane() + for world in range(len(counts)): + builder.begin_world() + root = builder.add_link() + joints = [builder.add_joint_fixed(-1, root)] + for slot in range(20): + body = builder.add_link(mass=2.0, inertia=wp.mat33(0.02, 0.0, 0.0, 0.0, 0.02, 0.0, 0.0, 0.0, 0.02)) + joints.append( + builder.add_joint_prismatic( + root, + body, + axis=(0.0, 0.0, 1.0), + parent_xform=wp.transform((slot * 0.5, world * 2.0, 0.199), wp.quat_identity()), + ) + ) + builder.add_shape_sphere(body, radius=0.2, cfg=newton.ModelBuilder.ShapeConfig(density=0.0, mu=0.5)) + builder.add_articulation(joints) + builder.end_world() + model = builder.finalize(device=device) + state = model.state() + positions = wp.to_torch(state.joint_q).reshape(len(counts), 20) + for world, count in enumerate(counts): + positions[world, count:] = 1.0 + newton.eval_fk(model, state.joint_q, state.joint_qd, state) + reconstruction = _ForceReconstruction(model, 0.005) + collision = newton.CollisionPipeline(model, rigid_contact_max=reconstruction.solver.get_max_contact_count()) + contacts = collision.contacts() + collision.collide(state, contacts) + with torch.cuda.stream(wp.stream_to_torch(wp.get_stream(device))): + reconstruction.prepare(state, model.control(), contacts) + reconstruction.update(state) + wp.synchronize_device(device) + data = reconstruction.solver.mjw_data + np.testing.assert_array_equal(data.nefc.numpy(), np.array(counts) * 3) + for world, count in enumerate(counts): + expected = torch.full((20,), -9.81, device=device) + expected[:count] = 0.0 + torch.testing.assert_close(wp.to_torch(data.qacc)[world], expected, atol=2e-3, rtol=1e-4) + normal = wp.to_torch(data.efc.force)[world, : count * 3 : 3] + torch.testing.assert_close(normal, torch.full_like(normal, 19.62), atol=2e-3, rtol=1e-4) + + +@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) +def test_reconstruction_respects_friction_and_rejects_adhesion(device): + """A sliding sphere saturates the friction cone while root reaction remains zero.""" + builder = newton.ModelBuilder() + builder.request_state_attributes("body_parent_f") + builder.add_ground_plane(cfg=newton.ModelBuilder.ShapeConfig(mu=0.5)) + builder.begin_world() + body = builder.add_link( + xform=wp.transform((0.0, 0.0, 0.199), wp.quat_identity()), + mass=1.0, + inertia=wp.mat33(0.016, 0.0, 0.0, 0.0, 0.016, 0.0, 0.0, 0.0, 0.016), + ) + builder.add_articulation([builder.add_joint_free(body)]) + builder.add_shape_sphere(body, radius=0.2, cfg=newton.ModelBuilder.ShapeConfig(density=0.0, mu=0.5)) + builder.end_world() + model = builder.finalize(device=device) + state = model.state() + newton.eval_fk(model, state.joint_q, state.joint_qd, state) + reconstruction = _ForceReconstruction(model, 0.005) + collision = newton.CollisionPipeline(model, rigid_contact_max=reconstruction.solver.get_max_contact_count()) + contacts = collision.contacts() + collision.collide(state, contacts) + with torch.cuda.stream(wp.stream_to_torch(wp.get_stream(device))): + reconstruction.prepare(state, model.control(), contacts) + wp.to_torch(state.joint_qd)[0] = 0.1 # Ask for 20 m/s^2 horizontally with zero applied effort. + newton.eval_fk(model, state.joint_q, state.joint_qd, state) + reconstruction.update(state) + wp.synchronize_device(device) + data = reconstruction.solver.mjw_data + assert data.nefc.numpy()[0] == 3 + force = wp.to_torch(data.efc.force)[0, :3] + assert force[0] > 0 + torch.testing.assert_close(force[1:].norm(), force[0] * 0.5, atol=2e-3, rtol=1e-4) + # No world-root motor exists: independent Newton-Euler recursion must recover zero reaction. + torch.testing.assert_close( + wp.to_torch(state.body_parent_f), torch.zeros_like(wp.to_torch(state.body_parent_f)), atol=2e-3, rtol=0 + ) + model.shape_material_ka.fill_(1.0) + with pytest.raises(ValueError, match="adhesive"): + _ForceReconstruction(model, 0.005) diff --git a/source/isaaclab_newton/test/test_nerd.py b/source/isaaclab_newton/test/test_nerd.py index f26a22aef667..3bafa0a2f5c7 100644 --- a/source/isaaclab_newton/test/test_nerd.py +++ b/source/isaaclab_newton/test/test_nerd.py @@ -19,8 +19,10 @@ import warp as wp from isaaclab.app import launch_simulation -from isaaclab.managers import DatasetExportMode +from isaaclab.envs.mdp.rewards import contact_forces +from isaaclab.managers import DatasetExportMode, RewardTermCfg, SceneEntityCfg from isaaclab.paths import ISAACLAB_ROOT +from isaaclab.sensors import ContactSensorCfg, JointWrenchSensorCfg from isaaclab.test.utils import DeviceScope, test_devices from isaaclab.utils import to_dict @@ -66,6 +68,8 @@ def test_native_nerd_ownership(): forbidden = ("isaaclab_physicsnemo", "supermimic", "argparse", "gymnasium", "wandb") if path.is_relative_to(newton): forbidden += ("isaaclab_rl", "isaaclab_tasks") + if path.parent.name == "physics": + forbidden += ("isaaclab.sensors", "isaaclab.assets", "isaaclab_newton.sensors") if path.is_relative_to(task_core) and path.name != "__init__.py": forbidden += ("isaaclab_rl", "physicsnemo") # Registration files legitimately import Gym, but never learner implementations. @@ -302,10 +306,10 @@ def clear_consumed_effort(): assert all(not torch.equal(a, b) for a, b in zip(distributed["generators"], continued["generators"])) assert len((output / "metrics.jsonl").read_text().splitlines()) == 2 - _check_graph_resets(task, device, checkpoint) + _check_graph_resets(task, device, checkpoint, forces="Flat-Anymal" in task and network == "parallel") -def _check_graph_resets(task, device, checkpoint): +def _check_graph_resets(task, device, checkpoint, forces=False): from isaaclab_newton.physics.nerd_manager import SolverNeRD cfg, _ = resolve_task_config(task + "-Learned", None, overrides=[]) @@ -319,6 +323,16 @@ def _check_graph_resets(task, device, checkpoint): cfg.sim.physics.load_visual_shapes = True cfg.sim.physics.solver_cfg.checkpoint = str(checkpoint) cfg.sim.physics.solver_cfg.compile = "Anymal" in task + if forces: + cfg.sim.physics.solver_cfg.reconstruct_forces = True + cfg.scene.robot.enable_joint_wrench = True + cfg.scene.contact_forces = ContactSensorCfg(prim_path="{ENV_REGEX_NS}/Robot/.*", history_length=3) + cfg.scene.joint_wrenches = JointWrenchSensorCfg(prim_path="{ENV_REGEX_NS}/Robot") + cfg.rewards.estimated_contact = RewardTermCfg( + func=contact_forces, + weight=-1e-4, + params={"threshold": 100.0, "sensor_cfg": SceneEntityCfg("contact_forces")}, + ) for name, term in vars(cfg.terminations).items(): if term is not None: term.func, term.params = never, {} @@ -360,6 +374,7 @@ def retain_effort(): manager.register_post_actuator_callback(retain_effort) addresses = (manager._solver.history.data_ptr(), manager._solver.pending_reset.data_ptr()) + contact_peak = wrench_peak = 0.0 with torch.inference_mode(): for step in range(21): if step in (4, 9, 13): @@ -395,6 +410,17 @@ def retain_effort(): rtol=2e-5, atol=2e-6, ) + if forces: + contact_force = env.scene["contact_forces"].data.net_forces_w.torch + joint_wrench = env.scene["robot"].data.body_joint_wrench.torch + assert torch.isfinite(contact_force).all() + assert torch.isfinite(env.scene["joint_wrenches"].data.force.torch).all() + assert torch.isfinite(joint_wrench).all() + assert torch.isfinite(env.reward_manager.compute(env.step_dt)).all() + contact_peak = max(contact_peak, contact_force.abs().max().item()) + wrench_peak = max(wrench_peak, joint_wrench.abs().max().item()) + if forces: + assert contact_peak > 0 and wrench_peak > 0 assert manager._graph is not None assert addresses == (manager._solver.history.data_ptr(), manager._solver.pending_reset.data_ptr()) with pytest.raises(ValueError, match="frame"): From 7ba043045d76932a0aa56fa09aeba6161a45e8a9 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Mon, 26 Oct 2026 13:23:05 -0700 Subject: [PATCH 4/9] Document learner-independent recorder integration boundaries --- docs/source/api/lab/isaaclab.managers.rst | 11 +++++++++++ .../isaaclab/changelog.d/recorder-capture.changed.rst | 3 +++ .../isaaclab/test/managers/test_recorder_manager.py | 2 +- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/source/api/lab/isaaclab.managers.rst b/docs/source/api/lab/isaaclab.managers.rst index f83a4bd85b8e..90636c193a36 100644 --- a/docs/source/api/lab/isaaclab.managers.rst +++ b/docs/source/api/lab/isaaclab.managers.rst @@ -237,6 +237,17 @@ For an online consumer, a term can keep its latest batch in preallocated tensors ``env.recorder_manager.get_term(name)`` and copies the batch into its own replay storage. The term owns the lifetime of its published tensors; consumers must copy them before the next capture. +The task's recorder-term configuration owns the captured schema: select the state, applied controls, +context and terminal flags that the consumer needs. Core capture/storage does not import a world-model +library or choose its inputs. A PhysicsNeMo learner constructs its codec and normalization at its own +composition root; an online learner can consume the same captured batch for replay or prediction-error +rewards. Network evaluation, optimization and checkpoint publication remain outside recorder callbacks. + +An export mode of ``EXPORT_NONE`` disables file export, not sample retention. To avoid episode buffers, +a term must return ``(None, None)``. A task can offer its own ``record_to_episodes`` option that changes +this return value while keeping capture identical. Disabling the term in the task configuration removes +capture itself. These are three separate choices: capture, retain and export. + In an RL environment, post-step callbacks run after reward and termination computation and before automatic reset. A term reading physical tensors directly should set ``requires_post_step_observations = False``. This avoids an extra observation computation and its diff --git a/source/isaaclab/changelog.d/recorder-capture.changed.rst b/source/isaaclab/changelog.d/recorder-capture.changed.rst index d0eebccaa3cd..6f6e21d4ab43 100644 --- a/source/isaaclab/changelog.d/recorder-capture.changed.rst +++ b/source/isaaclab/changelog.d/recorder-capture.changed.rst @@ -7,3 +7,6 @@ snapshot with tensor leaves. To update live recording, use ``add_to_episodes`` and ``set_success_to_episodes``; do not mutate the snapshot. Set ``buffer_initial_capacity`` to control the initial allocation per field. + +* Documented independent capture, retention and export choices, with task-selected schemas and learner-owned + PhysicsNeMo integration. Core recorder storage remains independent of task and learning packages. diff --git a/source/isaaclab/test/managers/test_recorder_manager.py b/source/isaaclab/test/managers/test_recorder_manager.py index 43e05f8aa320..f58d6eeb61bb 100644 --- a/source/isaaclab/test/managers/test_recorder_manager.py +++ b/source/isaaclab/test/managers/test_recorder_manager.py @@ -307,7 +307,7 @@ def test_recorder_storage_boundary(): source = ast.parse(inspect.getsource(module)) assert not any(isinstance(node, ast.Attribute) and node.attr == "_episodes" for node in ast.walk(source)) - forbidden = ("physicsnemo", "supermimic", "isaaclab_physicsnemo") + forbidden = ("physicsnemo", "supermimic", "isaaclab_physicsnemo", "isaaclab_rl", "isaaclab_tasks") for node in ast.walk(source): if isinstance(node, ast.Import): assert not any(alias.name.startswith(forbidden) for alias in node.names) From d768ba1be9569c1d8acc2206dec488d0dd1d1bc6 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Mon, 26 Oct 2026 14:10:15 -0700 Subject: [PATCH 5/9] Keep recorder documentation and dependency checks generic --- docs/source/api/lab/isaaclab.managers.rst | 7 +++---- .../isaaclab/changelog.d/recorder-capture.changed.rst | 4 ++-- .../isaaclab/test/managers/test_recorder_manager.py | 11 ++++++----- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/source/api/lab/isaaclab.managers.rst b/docs/source/api/lab/isaaclab.managers.rst index 90636c193a36..648635bf7163 100644 --- a/docs/source/api/lab/isaaclab.managers.rst +++ b/docs/source/api/lab/isaaclab.managers.rst @@ -238,10 +238,9 @@ For an online consumer, a term can keep its latest batch in preallocated tensors The term owns the lifetime of its published tensors; consumers must copy them before the next capture. The task's recorder-term configuration owns the captured schema: select the state, applied controls, -context and terminal flags that the consumer needs. Core capture/storage does not import a world-model -library or choose its inputs. A PhysicsNeMo learner constructs its codec and normalization at its own -composition root; an online learner can consume the same captured batch for replay or prediction-error -rewards. Network evaluation, optimization and checkpoint publication remain outside recorder callbacks. +context and terminal flags that the consumer needs. Core capture/storage remains independent of +downstream applications. Consumers own data processing, replay and learning; recorder callbacks only +capture and publish the configured data. An export mode of ``EXPORT_NONE`` disables file export, not sample retention. To avoid episode buffers, a term must return ``(None, None)``. A task can offer its own ``record_to_episodes`` option that changes diff --git a/source/isaaclab/changelog.d/recorder-capture.changed.rst b/source/isaaclab/changelog.d/recorder-capture.changed.rst index 6f6e21d4ab43..1007476bcb5f 100644 --- a/source/isaaclab/changelog.d/recorder-capture.changed.rst +++ b/source/isaaclab/changelog.d/recorder-capture.changed.rst @@ -8,5 +8,5 @@ ``set_success_to_episodes``; do not mutate the snapshot. Set ``buffer_initial_capacity`` to control the initial allocation per field. -* Documented independent capture, retention and export choices, with task-selected schemas and learner-owned - PhysicsNeMo integration. Core recorder storage remains independent of task and learning packages. +* Documented independent capture, retention and export choices, with task-selected schemas and + consumer-owned processing. Core recorder storage remains independent of task and learning packages. diff --git a/source/isaaclab/test/managers/test_recorder_manager.py b/source/isaaclab/test/managers/test_recorder_manager.py index f58d6eeb61bb..48f1c3647db7 100644 --- a/source/isaaclab/test/managers/test_recorder_manager.py +++ b/source/isaaclab/test/managers/test_recorder_manager.py @@ -8,6 +8,7 @@ import inspect import os import shutil +import sys import tempfile import uuid from collections.abc import Sequence @@ -302,17 +303,17 @@ def test_export_batched_episodes(dataset_dir, mode): def test_recorder_storage_boundary(): - """Do not reintroduce per-world episode ownership or Python world loops in the append path.""" + """Keep storage independent of consumers, with batched ownership and no Python world loops.""" import isaaclab.managers.recorder_manager as module source = ast.parse(inspect.getsource(module)) assert not any(isinstance(node, ast.Attribute) and node.attr == "_episodes" for node in ast.walk(source)) - forbidden = ("physicsnemo", "supermimic", "isaaclab_physicsnemo", "isaaclab_rl", "isaaclab_tasks") + allowed = sys.stdlib_module_names | {"isaaclab", "torch", "warp", "prettytable"} for node in ast.walk(source): if isinstance(node, ast.Import): - assert not any(alias.name.startswith(forbidden) for alias in node.names) - elif isinstance(node, ast.ImportFrom): - assert not (node.module or "").startswith(forbidden) + assert all(alias.name.split(".")[0] in allowed for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.level == 0: + assert (node.module or "").split(".")[0] in allowed field = next(node for node in source.body if isinstance(node, ast.ClassDef) and node.name == "_RecorderField") append = next(node for node in field.body if isinstance(node, ast.FunctionDef) and node.name == "append") assert not any(isinstance(node, (ast.For, ast.While, ast.comprehension)) for node in ast.walk(append)) From 9131ba81d89b1089691e759016bdce81728a3463 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Mon, 26 Oct 2026 14:21:47 -0700 Subject: [PATCH 6/9] Make recorder observation capture explicit --- docs/source/api/lab/isaaclab.managers.rst | 17 ++++++-- .../changelog.d/recorder-capture.changed.rst | 9 ++-- .../isaaclab/envs/manager_based_rl_env.py | 5 +-- .../isaaclab/envs/mdp/recorders/recorders.py | 10 ----- .../isaaclab/managers/recorder_manager.py | 26 ++++++----- .../envs/test_manager_based_rl_env_unit.py | 43 ++++++++++++------- .../test/managers/test_recorder_manager.py | 4 ++ .../changelog.d/recorder-capture.changed.rst | 5 ++- .../changelog.d/recorder-capture.major | 0 .../envs/manager_based_rl_env_warp.py | 9 +--- 10 files changed, 68 insertions(+), 60 deletions(-) create mode 100644 source/isaaclab_experimental/changelog.d/recorder-capture.major diff --git a/docs/source/api/lab/isaaclab.managers.rst b/docs/source/api/lab/isaaclab.managers.rst index 648635bf7163..c7b3bbf2ae79 100644 --- a/docs/source/api/lab/isaaclab.managers.rst +++ b/docs/source/api/lab/isaaclab.managers.rst @@ -247,10 +247,19 @@ a term must return ``(None, None)``. A task can offer its own ``record_to_episod this return value while keeping capture identical. Disabling the term in the task configuration removes capture itself. These are three separate choices: capture, retain and export. -In an RL environment, post-step callbacks run after reward and termination computation and before -automatic reset. A term reading physical tensors directly should set -``requires_post_step_observations = False``. This avoids an extra observation computation and its -noise draws. Existing terms default to ``True``. Callback dispatch is independent of this setting. +In an RL environment, post-step callbacks capture the completed physical transition after rewards +and terminations, before automatic reset. Recorder dispatch does not compute observations. +Physical-state terms read the scene directly; pre-step observation terms read the existing +``env.obs_buf`` used by the policy. + +**Migration:** Custom post-step terms must no longer assume that ``env.obs_buf`` has been refreshed; +it still contains the preceding policy input. To capture observations of the completed transition, +call ``env.observation_manager.compute_group("recording", update_history=False)`` explicitly +inside the term. Configure that observation group without history or delay when current measurements +are needed. This does not advance observation history, but noise and modifiers still run: +use a separate noiseless group to avoid changing subsequent policy noise draws. No extra +observation computation occurs just because a recorder is enabled. Non-RL manager-based environments +already update their normal observations before post-step capture. Returning a field path and a batched tensor (or nested dictionary of tensors) retains samples in the manager. Each field has a time-major allocation and independent per-world insertion indices, diff --git a/source/isaaclab/changelog.d/recorder-capture.changed.rst b/source/isaaclab/changelog.d/recorder-capture.changed.rst index 1007476bcb5f..77d151e0620f 100644 --- a/source/isaaclab/changelog.d/recorder-capture.changed.rst +++ b/source/isaaclab/changelog.d/recorder-capture.changed.rst @@ -1,8 +1,11 @@ * Replaced per-world recorder episode containers with batched field allocations and independent insertion indices, reusing storage across resets and growing without dropping samples. -* Added :meth:`~isaaclab.managers.RecorderManager.get_term` and the term capability - :attr:`~isaaclab.managers.RecorderTerm.requires_post_step_observations` so physical-state - capture can avoid extra observation computation and noise draws. +* Added :meth:`~isaaclab.managers.RecorderManager.get_term` for direct access to captured batches. +* **Breaking:** Removed the implicit observation computation before RL post-step recorder callbacks. + Physical capture no longer consumes extra observation noise. Custom terms needing observations + of the completed transition must call ``env.observation_manager.compute_group(name, update_history=False)`` + explicitly; ``env.obs_buf`` still contains the preceding policy input. Use a noiseless group without + history or delay for current measurements that do not consume policy noise draws. * **Breaking:** Changed :meth:`~isaaclab.managers.RecorderManager.get_episode` to return an owned snapshot with tensor leaves. To update live recording, use ``add_to_episodes`` and ``set_success_to_episodes``; do not mutate the snapshot. Set ``buffer_initial_capacity`` diff --git a/source/isaaclab/isaaclab/envs/manager_based_rl_env.py b/source/isaaclab/isaaclab/envs/manager_based_rl_env.py index 122fb5087a6d..a7bbe2de7e1e 100644 --- a/source/isaaclab/isaaclab/envs/manager_based_rl_env.py +++ b/source/isaaclab/isaaclab/envs/manager_based_rl_env.py @@ -236,10 +236,7 @@ def step(self, action: torch.Tensor) -> VecEnvStepReturn: self.reset_time_outs = self.termination_manager.time_outs self.reward_buf = self.reward_manager.compute(dt=self.step_dt) - if len(self.recorder_manager.active_terms) > 0: - if self.recorder_manager.requires_post_step_observations: - self.obs_buf = self.observation_manager.compute() - self.recorder_manager.record_post_step() + self.recorder_manager.record_post_step() # -- reset envs that terminated/timed-out and log the episode information reset_env_ids = self.reset_buf.nonzero(as_tuple=False).squeeze(-1).int() diff --git a/source/isaaclab/isaaclab/envs/mdp/recorders/recorders.py b/source/isaaclab/isaaclab/envs/mdp/recorders/recorders.py index 44252e55a030..a9315bbca63d 100644 --- a/source/isaaclab/isaaclab/envs/mdp/recorders/recorders.py +++ b/source/isaaclab/isaaclab/envs/mdp/recorders/recorders.py @@ -14,8 +14,6 @@ class InitialStateRecorder(RecorderTerm): """Recorder term that records the initial state of the environment after reset.""" - requires_post_step_observations = False - def record_post_reset(self, env_ids: Sequence[int] | None): def extract_env_ids_values(value): nonlocal env_ids @@ -29,8 +27,6 @@ def extract_env_ids_values(value): class PostStepStatesRecorder(RecorderTerm): """Recorder term that records the state of the environment at the end of each step.""" - requires_post_step_observations = False - def record_post_step(self): return "states", self._env.scene.get_state(is_relative=True) @@ -38,8 +34,6 @@ def record_post_step(self): class PreStepActionsRecorder(RecorderTerm): """Recorder term that records the actions in the beginning of each step.""" - requires_post_step_observations = False - def record_pre_step(self): return "actions", self._env.action_manager.action @@ -47,8 +41,6 @@ def record_pre_step(self): class PreStepFlatPolicyObservationsRecorder(RecorderTerm): """Recorder term that records the policy group observations in each step.""" - requires_post_step_observations = False - def record_pre_step(self): return "obs", self._env.obs_buf["policy"] @@ -56,8 +48,6 @@ def record_pre_step(self): class PostStepProcessedActionsRecorder(RecorderTerm): """Recorder term that records processed actions at the end of each step.""" - requires_post_step_observations = False - def record_post_step(self): processed_actions = None diff --git a/source/isaaclab/isaaclab/managers/recorder_manager.py b/source/isaaclab/isaaclab/managers/recorder_manager.py index 3ac51533f963..d85bf26c98b4 100644 --- a/source/isaaclab/isaaclab/managers/recorder_manager.py +++ b/source/isaaclab/isaaclab/managers/recorder_manager.py @@ -76,14 +76,8 @@ class RecorderTerm(ManagerTermBase): * Post-reset recording: This callback is invoked at the end of `env.reset()`. * Pre-step recording: This callback is invoked at the beginning of `env.step()`, after the step action is processed and before the action is applied by the action manager. - * Post-step recording: This callback is invoked at the end of `env.step()` when all the managers are processed. - """ - - requires_post_step_observations: bool = True - """Whether the RL environment must compute observations before this term's post-step callback. - - Terms that only read physical tensors should set this to False. The default preserves the - observation access of existing terms; disabling it avoids extra noise draws and computation. + * Post-step recording: This callback captures the completed physical transition. In RL environments, + it runs after rewards and terminations, before automatic reset and the policy observation update. """ def __init__(self, cfg: RecorderTermCfg, env: ManagerBasedEnv): @@ -137,7 +131,16 @@ def record_pre_step(self) -> tuple[str | None, torch.Tensor | dict | None]: return None, None def record_post_step(self) -> tuple[str | None, torch.Tensor | dict | None]: - """Record data at the end of env.step() when all the managers are processed. + """Record the completed physical transition. + + In RL environments, this runs after rewards and terminations, before automatic reset. + The environment does not compute observations for recorders: `env.obs_buf` still holds + the preceding policy input. Terms needing observations of the completed transition must + explicitly call `env.observation_manager.compute_group(name, update_history=False)`. + For current measurements, configure that group without history or delay. Noise and + modifiers still run, so a noisy group can affect subsequent policy noise draws. + + In non-RL manager-based environments, the normal observation update precedes this callback. Returns: A tuple of key and value to be recorded. @@ -246,11 +249,6 @@ def active_terms(self) -> list[str]: """Name of active recorder terms.""" return self._term_names - @property - def requires_post_step_observations(self) -> bool: - """Whether any active recorder term requires post-step observations.""" - return any(term.requires_post_step_observations for term in self._terms.values()) - @property def exported_successful_episode_count(self) -> int: """Number of successful episodes. diff --git a/source/isaaclab/test/envs/test_manager_based_rl_env_unit.py b/source/isaaclab/test/envs/test_manager_based_rl_env_unit.py index df28cf38669c..0a45428d7daf 100644 --- a/source/isaaclab/test/envs/test_manager_based_rl_env_unit.py +++ b/source/isaaclab/test/envs/test_manager_based_rl_env_unit.py @@ -121,8 +121,6 @@ def _state_observation(env): class _PhysicalRecorder(RecorderTerm): - requires_post_step_observations = False - def record_post_step(self): self.latest = self._env.scene.state.clone() return None, None @@ -130,7 +128,7 @@ def record_post_step(self): class _ObservationRecorder(RecorderTerm): def record_post_step(self): - self.latest = self._env.obs_buf["policy"].clone() + self.latest = self._env.observation_manager.compute_group("state", update_history=False) return None, None @@ -157,23 +155,39 @@ def _make_stepping_env(recorder_type=None): env.extras = {} env.action_manager = SimpleNamespace(process_action=lambda action: None, apply_action=lambda: None) mask = torch.zeros(3, dtype=torch.bool) - env.termination_manager = SimpleNamespace(compute=lambda: mask, terminated=mask, time_outs=mask) + + def terminate(): + mask[0] = env.common_step_counter == 4 + return mask + + env.termination_manager = SimpleNamespace( + active_terms=[], compute=terminate, terminated=mask, time_outs=torch.zeros_like(mask) + ) env.reward_manager = SimpleNamespace(compute=lambda dt: torch.zeros(3)) env.command_manager = SimpleNamespace(compute=lambda dt: None) env.event_manager = SimpleNamespace(active_terms=[]) group = ObservationGroupCfg(history_length=3, flatten_history_dim=False, enable_corruption=True) group.state = ObservationTermCfg(func=_state_observation, noise=UniformNoiseCfg(n_min=-0.1, n_max=0.1)) - env.observation_manager = ObservationManager({"policy": group}, env) + state = ObservationGroupCfg(enable_corruption=False) + state.state = ObservationTermCfg(func=_state_observation) + env.observation_manager = ObservationManager({"policy": group, "state": state}, env) cfg = RecorderManagerBaseCfg(dataset_export_mode=DatasetExportMode.EXPORT_NONE) if recorder_type is not None: cfg.capture = RecorderTermCfg(class_type=recorder_type) env.recorder_manager = RecorderManager(cfg, env) + + def reset(env_ids): + env.scene.state[env_ids] = -100.0 + env.observation_manager.reset(env_ids) + env.recorder_manager.reset(env_ids) + + env._reset_idx = reset env.obs_buf = env.observation_manager.compute(update_history=True) return env def test_raw_capture_preserves_observation_noise_history_and_rng(): - """Raw capture dispatch must not consume extra noise or advance the real policy history.""" + """Capture sees terminal state without implicit observation work or changes to policy history/RNG.""" outputs, states = [], [] for recorder_type in (None, _PhysicalRecorder, _ObservationRecorder): torch.manual_seed(17) @@ -184,16 +198,15 @@ def test_raw_capture_preserves_observation_noise_history_and_rng(): if recorder_type is not None: term = env.recorder_manager.get_term("capture") assert not env.recorder_manager._fields - if recorder_type is _PhysicalRecorder: - assert torch.equal(term.latest, torch.full((3, 2), 4.0)) - else: - # The default-compatible path really supplies observations to its callback. - assert term.latest.shape == (3, 3, 2) + # Both direct capture and explicit noiseless observations see the state before reset. + assert torch.equal(term.latest, torch.full((3, 2), 4.0)) + assert torch.equal(env.scene.state[0], torch.full((2,), -100.0)) env.recorder_manager.close() assert torch.equal(outputs[0], outputs[1]) assert torch.equal(states[0], states[1]) - assert not torch.equal(outputs[0], outputs[2]) - assert not torch.equal(states[0], states[2]) - # After four steps the three history entries contain steps 2, 3 and 4, each noised once. + assert torch.equal(outputs[0], outputs[2]) + assert torch.equal(states[0], states[2]) + # Continuing worlds retain steps 2, 3 and 4; the reset world's history starts afresh. expected = torch.tensor([2.0, 3.0, 4.0])[None, :, None] - assert torch.all((outputs[1][-1] - expected).abs() <= 0.1) + assert torch.all((outputs[1][-1, 1:] - expected).abs() <= 0.1) + assert torch.all((outputs[1][-1, 0] + 100.0).abs() <= 0.1) diff --git a/source/isaaclab/test/managers/test_recorder_manager.py b/source/isaaclab/test/managers/test_recorder_manager.py index 48f1c3647db7..d463fbcdeb17 100644 --- a/source/isaaclab/test/managers/test_recorder_manager.py +++ b/source/isaaclab/test/managers/test_recorder_manager.py @@ -307,6 +307,10 @@ def test_recorder_storage_boundary(): import isaaclab.managers.recorder_manager as module source = ast.parse(inspect.getsource(module)) + # Observation evaluation belongs to explicit terms, never generic capture dispatch. + assert not hasattr(module.RecorderTerm, "requires_post_step_observations") + assert not hasattr(module.RecorderManager, "requires_post_step_observations") + assert not any(isinstance(node, ast.Attribute) and node.attr == "observation_manager" for node in ast.walk(source)) assert not any(isinstance(node, ast.Attribute) and node.attr == "_episodes" for node in ast.walk(source)) allowed = sys.stdlib_module_names | {"isaaclab", "torch", "warp", "prettytable"} for node in ast.walk(source): diff --git a/source/isaaclab_experimental/changelog.d/recorder-capture.changed.rst b/source/isaaclab_experimental/changelog.d/recorder-capture.changed.rst index 99e5426a4d83..29d7578a43f8 100644 --- a/source/isaaclab_experimental/changelog.d/recorder-capture.changed.rst +++ b/source/isaaclab_experimental/changelog.d/recorder-capture.changed.rst @@ -1,2 +1,3 @@ -* Honored recorder terms' post-step observation requirements in the Warp RL environment, - allowing physical-state capture without extra observation computation. +* **Breaking:** Removed the implicit observation computation before Warp RL post-step recorder callbacks. + Capture runs before automatic reset. Custom terms needing observations must request them explicitly + from the observation manager; ``env.obs_buf`` is no longer refreshed for recorder dispatch. diff --git a/source/isaaclab_experimental/changelog.d/recorder-capture.major b/source/isaaclab_experimental/changelog.d/recorder-capture.major new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_experimental/isaaclab_experimental/envs/manager_based_rl_env_warp.py b/source/isaaclab_experimental/isaaclab_experimental/envs/manager_based_rl_env_warp.py index b35fb410a88c..5ac88958d450 100644 --- a/source/isaaclab_experimental/isaaclab_experimental/envs/manager_based_rl_env_warp.py +++ b/source/isaaclab_experimental/isaaclab_experimental/envs/manager_based_rl_env_warp.py @@ -320,14 +320,7 @@ def step(self, action: torch.Tensor) -> VecEnvStepReturn: timer=DEBUG_TIMER_STEP, ) - if len(self.recorder_manager.active_terms) > 0: - if self.recorder_manager.requires_post_step_observations: - self._manager_call_switch.call_stage( - stage="ObservationManager_compute_no_history", - warp_call={"fn": self.observation_manager.compute, "kwargs": {"return_cloned_output": False}}, - timer=DEBUG_TIMER_STEP, - ) - self.recorder_manager.record_post_step() + self.recorder_manager.record_post_step() # -- reset envs that terminated/timed-out and log the episode information # NOTE: Interim path (intentional). From 8e43938cca7223e1889b4a5c1957dd785c64e8b8 Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Mon, 26 Oct 2026 14:50:49 -0700 Subject: [PATCH 7/9] Remove obsolete recorder accumulation and finalization APIs --- docs/source/api/lab/isaaclab.managers.rst | 7 ++ .../changelog.d/recorder-capture.changed.rst | 5 + .../isaaclab/managers/recorder_manager.py | 39 +------- .../isaaclab/utils/datasets/episode_data.py | 46 +-------- .../cli/test_replay_demos_loop_termination.py | 2 +- .../test/managers/test_recorder_manager.py | 6 +- .../isaaclab/test/utils/test_episode_data.py | 94 +------------------ .../utils/test_hdf5_dataset_file_handler.py | 33 +++---- 8 files changed, 39 insertions(+), 193 deletions(-) diff --git a/docs/source/api/lab/isaaclab.managers.rst b/docs/source/api/lab/isaaclab.managers.rst index c7b3bbf2ae79..953a868ed7bf 100644 --- a/docs/source/api/lab/isaaclab.managers.rst +++ b/docs/source/api/lab/isaaclab.managers.rst @@ -272,6 +272,13 @@ dimension. Reading a snapshot can synchronize device indices and copy data. Chan do not change live recording; use ``add_to_episodes`` and ``set_success_to_episodes`` for live writes. File export materializes these snapshots; the hot capture path does not construct per-world episodes. +``EpisodeData`` now holds tensor snapshots and playback cursors. The former ``add()`` and +``pre_export()`` methods are removed: collect through ``RecorderManager.add_to_episodes()``, +or assign nested tensors with leading sample dimensions to ``episode.data`` when constructing a +completed episode directly. HDF5 loading and playback retain the same tensor format. +The unused ``RecorderTerm.close(file_path)`` hook is also removed. Dataset handlers own file +finalization; applications explicitly close any additional resources they own. + .. autoclass:: RecorderManager :members: :inherited-members: diff --git a/source/isaaclab/changelog.d/recorder-capture.changed.rst b/source/isaaclab/changelog.d/recorder-capture.changed.rst index 77d151e0620f..8c4ad5bd3e45 100644 --- a/source/isaaclab/changelog.d/recorder-capture.changed.rst +++ b/source/isaaclab/changelog.d/recorder-capture.changed.rst @@ -10,6 +10,11 @@ snapshot with tensor leaves. To update live recording, use ``add_to_episodes`` and ``set_success_to_episodes``; do not mutate the snapshot. Set ``buffer_initial_capacity`` to control the initial allocation per field. +* **Breaking:** Removed ``EpisodeData.add()`` and ``pre_export()``. Collection now belongs to + ``RecorderManager.add_to_episodes()``; assign nested tensors with leading sample dimensions + to ``EpisodeData.data`` when constructing an episode directly. Playback and HDF5 loading remain supported. +* **Breaking:** Removed the unused ``RecorderTerm.close(file_path)`` hook. Dataset handlers own + file finalization; applications must explicitly close any additional resources they own. * Documented independent capture, retention and export choices, with task-selected schemas and consumer-owned processing. Core recorder storage remains independent of task and learning packages. diff --git a/source/isaaclab/isaaclab/managers/recorder_manager.py b/source/isaaclab/isaaclab/managers/recorder_manager.py index d85bf26c98b4..71d648b0760f 100644 --- a/source/isaaclab/isaaclab/managers/recorder_manager.py +++ b/source/isaaclab/isaaclab/managers/recorder_manager.py @@ -80,16 +80,6 @@ class RecorderTerm(ManagerTermBase): it runs after rewards and terminations, before automatic reset and the policy observation update. """ - def __init__(self, cfg: RecorderTermCfg, env: ManagerBasedEnv): - """Initialize the recorder term. - - Args: - cfg: The configuration object. - env: The environment instance. - """ - # call the base class constructor - super().__init__(cfg, env) - """ User-defined callbacks. """ @@ -157,17 +147,6 @@ def record_post_physics_decimation_step(self) -> tuple[str | None, torch.Tensor """ return None, None - def close(self, file_path: str): - """Finalize and "clean up" the recorder term. - - This can include tasks such as appending metadata (e.g. labels) to a file - and properly closing any associated file handles or resources. - - Args: - file_path: the absolute path to the file - """ - pass - class RecorderManager(ManagerBase): """Manager for recording data from recorder terms.""" @@ -366,30 +345,18 @@ def set_success_to_episodes(self, env_ids: Sequence[int] | slice | None, success def record_pre_step(self) -> None: """Trigger recorder terms for pre-step functions.""" - # Do nothing if no active recorder terms are provided - if len(self.active_terms) == 0: - return - for term in self._terms.values(): key, value = term.record_pre_step() self.add_to_episodes(key, value) def record_post_step(self) -> None: """Trigger recorder terms for post-step functions.""" - # Do nothing if no active recorder terms are provided - if len(self.active_terms) == 0: - return - for term in self._terms.values(): key, value = term.record_post_step() self.add_to_episodes(key, value) def record_post_physics_decimation_step(self) -> None: """Trigger recorder terms for post-physics step functions in the decimation loop.""" - # Do nothing if no active recorder terms are provided - if len(self.active_terms) == 0: - return - for term in self._terms.values(): key, value = term.record_post_physics_decimation_step() self.add_to_episodes(key, value) @@ -532,9 +499,7 @@ def export_episodes( handler.flush() def close(self): - """Closes the recorder manager by exporting any remaining data to file as well as properly - closes the recorder terms. - """ + """Export remaining episodes if configured and close dataset files.""" # Do nothing if no active recorder terms are provided if len(self.active_terms) == 0: return @@ -544,8 +509,6 @@ def close(self): self._dataset_file_handler.close() if self._failed_episode_dataset_file_handler is not None: self._failed_episode_dataset_file_handler.close() - for term in self._terms.values(): - term.close(os.path.join(self.cfg.dataset_export_dir_path, self.cfg.dataset_filename)) """ Helper functions. diff --git a/source/isaaclab/isaaclab/utils/datasets/episode_data.py b/source/isaaclab/isaaclab/utils/datasets/episode_data.py index bb528d4c630a..c401906b3aa4 100644 --- a/source/isaaclab/isaaclab/utils/datasets/episode_data.py +++ b/source/isaaclab/isaaclab/utils/datasets/episode_data.py @@ -9,7 +9,11 @@ class EpisodeData: - """Class to store episode data.""" + """An episode's tensor data and playback cursors. + + Data leaves are tensors with a leading sample dimension. Collection belongs to the recorder + manager; this container holds snapshots or loaded datasets without accumulating samples. + """ def __init__(self) -> None: """Initializes episode data class.""" @@ -85,34 +89,6 @@ def is_empty(self): """Check if the episode data is empty.""" return not self._data - def add(self, key: str, value: torch.Tensor | dict, clone: bool = True): - """Add a key-value pair to the dataset. - - The key can be nested by using the "/" character. - For example: "obs/joint_pos". - - Args: - key: The key name. - value: The corresponding value of tensor type or of dict type. - clone: Whether to clone the tensor value before storing it in the episode data. - """ - if isinstance(value, dict): - for sub_key, sub_value in value.items(): - self.add(f"{key}/{sub_key}", sub_value, clone=clone) - return - - stored = value.clone() if (clone and isinstance(value, torch.Tensor)) else value - sub_keys = key.split("/") - data = self._data - for sub_key in sub_keys[:-1]: - if sub_key not in data: - data[sub_key] = {} - data = data[sub_key] - # Accumulate in lists to avoid copying tensors on each append. - if sub_keys[-1] not in data: - data[sub_keys[-1]] = [] - data[sub_keys[-1]].append(stored) - def get_initial_state(self) -> torch.Tensor | None: """Get the initial state from the dataset.""" return self._data.get("initial_state") @@ -158,18 +134,6 @@ def get_next_joint_target(self) -> dict | torch.Tensor | None: self._next_joint_target_index += 1 return joint_target - def pre_export(self): - """Prepare data for export by converting lists to tensors.""" - - def pre_export_helper(data): - for key, value in data.items(): - if isinstance(value, list): - data[key] = torch.stack(value) - elif isinstance(value, dict): - pre_export_helper(value) - - pre_export_helper(self._data) - def _index_nested(data: dict | torch.Tensor, index: int, keep_dim: bool) -> dict | torch.Tensor | None: """Select ``index`` along the first dimension of every tensor in a (nested) dict. diff --git a/source/isaaclab/test/cli/test_replay_demos_loop_termination.py b/source/isaaclab/test/cli/test_replay_demos_loop_termination.py index 0692374b59ea..542f42469d90 100644 --- a/source/isaaclab/test/cli/test_replay_demos_loop_termination.py +++ b/source/isaaclab/test/cli/test_replay_demos_loop_termination.py @@ -77,7 +77,7 @@ def __init__(self, actions: torch.Tensor): def load_episode(self, episode_name, device): episode = EpisodeData() - episode.data = {"initial_state": {}, "actions": list(self._actions)} + episode.data = {"initial_state": {}, "actions": self._actions} return episode diff --git a/source/isaaclab/test/managers/test_recorder_manager.py b/source/isaaclab/test/managers/test_recorder_manager.py index d463fbcdeb17..6711a99baa34 100644 --- a/source/isaaclab/test/managers/test_recorder_manager.py +++ b/source/isaaclab/test/managers/test_recorder_manager.py @@ -249,7 +249,6 @@ def forbidden(*args, **kwargs): # per-world container appends, nor device-to-host scalar reads. Export-none also stays batched. with monkeypatch.context() as patch: patch.setattr(EpisodeData, "__init__", forbidden) - patch.setattr(EpisodeData, "add", forbidden) patch.setattr(torch.Tensor, "item", forbidden) patch.setattr(torch, "empty", forbidden) manager.add_to_episodes("obs/state", torch.zeros(3, 5, dtype=torch.bool)) @@ -307,6 +306,11 @@ def test_recorder_storage_boundary(): import isaaclab.managers.recorder_manager as module source = ast.parse(inspect.getsource(module)) + # Batched buffers own collection; snapshots and capture terms have no second writer lifecycle. + assert not hasattr(EpisodeData, "add") + assert not hasattr(EpisodeData, "pre_export") + assert "__init__" not in vars(module.RecorderTerm) + assert not hasattr(module.RecorderTerm, "close") # Observation evaluation belongs to explicit terms, never generic capture dispatch. assert not hasattr(module.RecorderTerm, "requires_post_step_observations") assert not hasattr(module.RecorderManager, "requires_post_step_observations") diff --git a/source/isaaclab/test/utils/test_episode_data.py b/source/isaaclab/test/utils/test_episode_data.py index 1d9536dc05ac..8727f6356c52 100644 --- a/source/isaaclab/test/utils/test_episode_data.py +++ b/source/isaaclab/test/utils/test_episode_data.py @@ -17,102 +17,18 @@ def test_is_empty(device): episode = EpisodeData() assert episode.is_empty() - episode.add("key", torch.tensor([1, 2, 3], device=device)) + episode.data["key"] = torch.tensor([[1, 2, 3]], device=device) assert not episode.is_empty() -@pytest.mark.parametrize("device", test_devices(DeviceScope.CPU)) -def test_add_tensors(device): - """Test appending tensor data to the episode.""" - dummy_data_0 = torch.tensor([0], device=device) - dummy_data_1 = torch.tensor([1], device=device) - expected_added_data = torch.cat((dummy_data_0.unsqueeze(0), dummy_data_1.unsqueeze(0))) - episode = EpisodeData() - - # test adding data to a key that does not exist - episode.add("key", dummy_data_0) - key_data = torch.stack(episode.data.get("key")) - assert key_data is not None - assert torch.equal(key_data, dummy_data_0.unsqueeze(0)) - - # test adding data to a key that exists - episode.add("key", dummy_data_1) - key_data = torch.stack(episode.data.get("key")) - assert key_data is not None - assert torch.equal(key_data, expected_added_data) - - # test adding data to a key with "/" in the name - episode.add("first/second", dummy_data_0) - first_data = episode.data.get("first") - assert first_data is not None - second_data = torch.stack(first_data.get("second")) - assert second_data is not None - assert torch.equal(second_data, dummy_data_0.unsqueeze(0)) - - # test adding data to a key with "/" in the name that already exists - episode.add("first/second", dummy_data_1) - first_data = episode.data.get("first") - assert first_data is not None - second_data = torch.stack(first_data.get("second")) - assert second_data is not None - assert torch.equal(second_data, expected_added_data) - - -@pytest.mark.parametrize("device", test_devices(DeviceScope.CPU)) -def test_add_dict_tensors(device): - """Test appending dict data to the episode.""" - dummy_dict_data_0 = { - "key_0": torch.tensor([0], device=device), - "key_1": {"key_1_0": torch.tensor([1], device=device), "key_1_1": torch.tensor([2], device=device)}, - } - dummy_dict_data_1 = { - "key_0": torch.tensor([3], device=device), - "key_1": {"key_1_0": torch.tensor([4], device=device), "key_1_1": torch.tensor([5], device=device)}, - } - - episode = EpisodeData() - - # test adding dict data to a key that does not exist - episode.add("key", dummy_dict_data_0) - key_data = episode.data.get("key") - assert key_data is not None - key_0_data = torch.stack(key_data.get("key_0")) - assert key_0_data is not None - assert torch.equal(key_0_data, torch.tensor([[0]], device=device)) - key_1_data = key_data.get("key_1") - assert key_1_data is not None - key_1_0_data = torch.stack(key_1_data.get("key_1_0")) - assert key_1_0_data is not None - assert torch.equal(key_1_0_data, torch.tensor([[1]], device=device)) - key_1_1_data = torch.stack(key_1_data.get("key_1_1")) - assert key_1_1_data is not None - assert torch.equal(key_1_1_data, torch.tensor([[2]], device=device)) - - # test adding dict data to a key that exists - episode.add("key", dummy_dict_data_1) - key_data = episode.data.get("key") - assert key_data is not None - key_0_data = torch.stack(key_data.get("key_0")) - assert key_0_data is not None - assert torch.equal(key_0_data, torch.tensor([[0], [3]], device=device)) - key_1_data = key_data.get("key_1") - assert key_1_data is not None - key_1_0_data = torch.stack(key_1_data.get("key_1_0")) - assert key_1_0_data is not None - assert torch.equal(key_1_0_data, torch.tensor([[1], [4]], device=device)) - key_1_1_data = torch.stack(key_1_data.get("key_1_1")) - assert key_1_1_data is not None - assert torch.equal(key_1_1_data, torch.tensor([[2], [5]], device=device)) - - @pytest.mark.parametrize("device", test_devices(DeviceScope.CPU)) def test_get_initial_state(device): """Test getting the initial state of the episode.""" dummy_initial_state = torch.tensor([1, 2, 3], device=device) episode = EpisodeData() - episode.add("initial_state", dummy_initial_state) - initial_state = torch.stack(episode.get_initial_state()) + episode.data["initial_state"] = dummy_initial_state.unsqueeze(0) + initial_state = episode.get_initial_state() assert initial_state is not None assert torch.equal(initial_state, dummy_initial_state.unsqueeze(0)) @@ -128,9 +44,7 @@ def test_get_next_action(device): episode = EpisodeData() assert episode.get_next_action() is None - episode.add("actions", action1) - episode.add("actions", action2) - episode.add("actions", action3) + episode.data["actions"] = torch.stack((action1, action2, action3)) # check if actions are returned in the correct order next_action = episode.get_next_action() diff --git a/source/isaaclab/test/utils/test_hdf5_dataset_file_handler.py b/source/isaaclab/test/utils/test_hdf5_dataset_file_handler.py index 1fb78180ac5f..923b562b013f 100644 --- a/source/isaaclab/test/utils/test_hdf5_dataset_file_handler.py +++ b/source/isaaclab/test/utils/test_hdf5_dataset_file_handler.py @@ -18,26 +18,6 @@ pytestmark = pytest.mark.unit -def create_test_episode(device): - """create a test episode with dummy data.""" - test_episode = EpisodeData() - - test_episode.seed = 0 - test_episode.success = True - - test_episode.add("initial_state", torch.tensor([1, 2, 3], device=device)) - - test_episode.add("actions", torch.tensor([1, 2, 3], device=device)) - test_episode.add("actions", torch.tensor([4, 5, 6], device=device)) - test_episode.add("actions", torch.tensor([7, 8, 9], device=device)) - - test_episode.add("obs/policy/term1", torch.tensor([1, 2, 3, 4, 5], device=device)) - test_episode.add("obs/policy/term1", torch.tensor([6, 7, 8, 9, 10], device=device)) - test_episode.add("obs/policy/term1", torch.tensor([11, 12, 13, 14, 15], device=device)) - - return test_episode - - @pytest.fixture def temp_dir(): """Create a temporary directory for test datasets.""" @@ -121,10 +101,16 @@ def test_write_and_load_episode(temp_dir, device): dataset_file_handler = HDF5DatasetFileHandler() dataset_file_handler.create(dataset_file_path, "test_env_name") - test_episode = create_test_episode(device) + test_episode = EpisodeData() + test_episode.seed = 0 + test_episode.success = True + test_episode.data = { + "initial_state": torch.tensor([[1, 2, 3]], device=device), + "actions": torch.tensor([[1, 2, 3], [4, 5, 6], [7, 8, 9]], device=device), + "obs": {"policy": {"term1": torch.arange(1, 16, device=device).reshape(3, 5)}}, + } # write the episode to the dataset - test_episode.pre_export() dataset_file_handler.write_episode(test_episode) dataset_file_handler.flush() @@ -156,7 +142,10 @@ def test_write_and_load_episode(temp_dir, device): assert torch.equal(loaded_episode.get_initial_state(), test_episode.get_initial_state()) + assert torch.equal(loaded_episode.data["obs"]["policy"]["term1"], test_episode.data["obs"]["policy"]["term1"]) + for action in test_episode.data["actions"]: assert torch.equal(loaded_episode.get_next_action(), action) + assert loaded_episode.get_next_action() is None dataset_file_handler.close() From d6078acb3ea9076a2388190327a87175839caa7b Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Mon, 26 Oct 2026 16:17:39 -0700 Subject: [PATCH 8/9] Consolidate recorder and dataset tests at their owning boundaries --- .../test/managers/test_recorder_manager.py | 132 ++++-------------- .../isaaclab/test/utils/test_episode_data.py | 61 -------- .../utils/test_hdf5_dataset_file_handler.py | 80 +++-------- 3 files changed, 47 insertions(+), 226 deletions(-) delete mode 100644 source/isaaclab/test/utils/test_episode_data.py diff --git a/source/isaaclab/test/managers/test_recorder_manager.py b/source/isaaclab/test/managers/test_recorder_manager.py index 6711a99baa34..c4b791a0aa37 100644 --- a/source/isaaclab/test/managers/test_recorder_manager.py +++ b/source/isaaclab/test/managers/test_recorder_manager.py @@ -6,12 +6,9 @@ import ast import inspect -import os -import shutil import sys -import tempfile -import uuid from collections.abc import Sequence +from types import SimpleNamespace from typing import TYPE_CHECKING, cast import h5py @@ -53,112 +50,35 @@ def record_post_step(self) -> tuple[str | None, torch.Tensor | None]: class DummyRecorderManagerCfg(RecorderManagerBaseCfg): """Dummy recorder configurations.""" - @configclass - class DummyResetRecorderTermCfg(RecorderTermCfg): - """Configuration for the dummy reset recorder term.""" - - class_type: type[RecorderTerm] = DummyResetRecorderTerm - - @configclass - class DummyStepRecorderTermCfg(RecorderTermCfg): - """Configuration for the dummy step recorder term.""" - - class_type: type[RecorderTerm] = DummyStepRecorderTerm - - record_reset_term = DummyResetRecorderTermCfg() - record_step_term = DummyStepRecorderTermCfg() + record_reset_term = RecorderTermCfg(class_type=DummyResetRecorderTerm) + record_step_term = RecorderTermCfg(class_type=DummyStepRecorderTerm) dataset_export_mode = DatasetExportMode.EXPORT_ALL -@configclass -class DummyEnvCfg: - """Dummy environment configuration.""" - - @configclass - class DummySimCfg: - """Configuration for the dummy sim.""" - - dt: float = 0.01 - render_interval: int = 1 - - @configclass - class DummySceneCfg: - """Configuration for the dummy scene.""" - - num_envs: int = 1 - - decimation: int = 1 - sim: DummySimCfg = DummySimCfg() - scene: DummySceneCfg = DummySceneCfg() - - -class DummySimulation: - """Minimal simulation double used by :class:`RecorderManager`.""" - - def is_playing(self) -> bool: - """Return whether the simulated timeline is playing.""" - return True - - -class DummyTerminationManager: - """Minimal termination manager double without success terms.""" - - active_terms: list[str] = [] - - -class DummyEnv: - """Minimal environment double used by recorder terms and metadata export.""" - - def __init__(self, device: str = "cpu", num_envs: int = 20) -> None: - self.num_envs = num_envs - self.device = device - self.sim = DummySimulation() - self.cfg = DummyEnvCfg() - self.cfg.scene.num_envs = num_envs - self.termination_manager = DummyTerminationManager() - - -def create_dummy_env(device: str = "cpu", num_envs: int = 20) -> ManagerBasedEnv: +def create_dummy_env(device: str = "cpu", num_envs: int = 3) -> ManagerBasedEnv: """Create a minimal environment double.""" - - return cast("ManagerBasedEnv", DummyEnv(device=device, num_envs=num_envs)) - - -@pytest.fixture -def dataset_dir(): - """Create directory to dump results.""" - test_dir = tempfile.mkdtemp() - yield test_dir - # Cleanup - shutil.rmtree(test_dir) - - -def test_str(dataset_dir): - """Test the string representation of the recorder manager.""" - # create recorder manager - cfg = DummyRecorderManagerCfg() - cfg.dataset_export_dir_path = dataset_dir - cfg.dataset_filename = f"{uuid.uuid4()}.hdf5" - recorder_manager = RecorderManager(cfg, create_dummy_env()) - assert len(recorder_manager.active_terms) == 2 - manager_str = str(recorder_manager) - assert "contains 2 active terms" in manager_str - assert "record_reset_term" in manager_str - assert "record_step_term" in manager_str - # the dataset file is created on construction - assert os.path.exists(os.path.join(cfg.dataset_export_dir_path, cfg.dataset_filename)) - recorder_manager.close() - - -def test_record(dataset_dir): + env = SimpleNamespace( + num_envs=num_envs, + device=device, + sim=SimpleNamespace(is_playing=lambda: True), + cfg=SimpleNamespace( + decimation=1, + sim=SimpleNamespace(dt=0.01, render_interval=1), + scene=SimpleNamespace(num_envs=num_envs), + ), + termination_manager=SimpleNamespace(active_terms=[]), + ) + return cast("ManagerBasedEnv", env) + + +def test_record(tmp_path): """Test the recording of the data.""" env = create_dummy_env() # create recorder manager - cfg = DummyRecorderManagerCfg() - cfg.dataset_export_dir_path = dataset_dir - cfg.dataset_filename = f"{uuid.uuid4()}.hdf5" + cfg = DummyRecorderManagerCfg(dataset_export_dir_path=str(tmp_path), dataset_filename="dataset.hdf5") recorder_manager = RecorderManager(cfg, env) + assert (tmp_path / cfg.dataset_filename).is_file() # record the step data recorder_manager.record_pre_step() @@ -262,9 +182,9 @@ def forbidden(*args, **kwargs): @pytest.mark.parametrize("mode", list(DatasetExportMode)) -def test_export_batched_episodes(dataset_dir, mode): +def test_export_batched_episodes(tmp_path, mode): """Export modes, custom IDs, nested tensor schema and counters survive partial finalization.""" - cfg = DummyRecorderManagerCfg(dataset_export_dir_path=dataset_dir, dataset_export_mode=mode, export_in_close=True) + cfg = DummyRecorderManagerCfg(dataset_export_dir_path=str(tmp_path), dataset_export_mode=mode, export_in_close=True) manager = RecorderManager(cfg, create_dummy_env(num_envs=3)) values = torch.tensor([[1.0, -0.0], [2.0, float("nan")], [3.0, 4.0]]) manager.add_to_episodes("actions", values) @@ -284,9 +204,9 @@ def test_export_batched_episodes(dataset_dir, mode): assert manager.exported_failed_episode_count == 1 manager.close() if mode == DatasetExportMode.EXPORT_NONE: - assert os.listdir(dataset_dir) == [] + assert list(tmp_path.iterdir()) == [] return - with h5py.File(os.path.join(dataset_dir, "dataset.hdf5")) as file: + with h5py.File(tmp_path / "dataset.hdf5") as file: expected = {"demo_30", "demo_20"} if mode == DatasetExportMode.EXPORT_ALL: expected.add("demo_10") @@ -295,7 +215,7 @@ def test_export_batched_episodes(dataset_dir, mode): assert bool(file["data/demo_30/obs/flag"][0]) assert bool(file["data/demo_20"].attrs["success"]) if mode == DatasetExportMode.EXPORT_SUCCEEDED_FAILED_IN_SEPARATE_FILES: - with h5py.File(os.path.join(dataset_dir, "dataset_failed.hdf5")) as file: + with h5py.File(tmp_path / "dataset_failed.hdf5") as file: assert set(file["data"]) == {"demo_10"} assert file["data/demo_10/actions"][()].tobytes() == values[1:2].numpy().tobytes() assert not bool(file["data/demo_10"].attrs["success"]) diff --git a/source/isaaclab/test/utils/test_episode_data.py b/source/isaaclab/test/utils/test_episode_data.py deleted file mode 100644 index 8727f6356c52..000000000000 --- a/source/isaaclab/test/utils/test_episode_data.py +++ /dev/null @@ -1,61 +0,0 @@ -# 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 -import pytest -import torch - -from isaaclab.test.utils import DeviceScope, test_devices -from isaaclab.utils.datasets import EpisodeData - -pytestmark = pytest.mark.unit - - -@pytest.mark.parametrize("device", test_devices(DeviceScope.CPU)) -def test_is_empty(device): - """Test checking whether the episode is empty.""" - episode = EpisodeData() - assert episode.is_empty() - - episode.data["key"] = torch.tensor([[1, 2, 3]], device=device) - assert not episode.is_empty() - - -@pytest.mark.parametrize("device", test_devices(DeviceScope.CPU)) -def test_get_initial_state(device): - """Test getting the initial state of the episode.""" - dummy_initial_state = torch.tensor([1, 2, 3], device=device) - episode = EpisodeData() - - episode.data["initial_state"] = dummy_initial_state.unsqueeze(0) - initial_state = episode.get_initial_state() - assert initial_state is not None - assert torch.equal(initial_state, dummy_initial_state.unsqueeze(0)) - - -@pytest.mark.parametrize("device", test_devices(DeviceScope.CPU)) -def test_get_next_action(device): - """Test getting next actions.""" - # dummy actions - action1 = torch.tensor([1, 2, 3], device=device) - action2 = torch.tensor([4, 5, 6], device=device) - action3 = torch.tensor([7, 8, 9], device=device) - - episode = EpisodeData() - assert episode.get_next_action() is None - - episode.data["actions"] = torch.stack((action1, action2, action3)) - - # check if actions are returned in the correct order - next_action = episode.get_next_action() - assert next_action is not None - assert torch.equal(next_action, action1) - next_action = episode.get_next_action() - assert next_action is not None - assert torch.equal(next_action, action2) - next_action = episode.get_next_action() - assert next_action is not None - assert torch.equal(next_action, action3) - - # check if None is returned when all actions are exhausted - assert episode.get_next_action() is None diff --git a/source/isaaclab/test/utils/test_hdf5_dataset_file_handler.py b/source/isaaclab/test/utils/test_hdf5_dataset_file_handler.py index 923b562b013f..3d6dc5455af6 100644 --- a/source/isaaclab/test/utils/test_hdf5_dataset_file_handler.py +++ b/source/isaaclab/test/utils/test_hdf5_dataset_file_handler.py @@ -3,10 +3,6 @@ # # SPDX-License-Identifier: BSD-3-Clause import json -import os -import shutil -import tempfile -import uuid import h5py import pytest @@ -18,49 +14,19 @@ pytestmark = pytest.mark.unit -@pytest.fixture -def temp_dir(): - """Create a temporary directory for test datasets.""" - temp_dir = tempfile.mkdtemp() - yield temp_dir - # cleanup after tests - shutil.rmtree(temp_dir) +def test_create_dataset_file(tmp_path, monkeypatch): + """Accept absolute paths, omitted extensions and bare filenames.""" + monkeypatch.chdir(tmp_path) + for path in (str(tmp_path / "absolute.hdf5"), str(tmp_path / "no_extension"), "relative.hdf5"): + dataset_file_handler = HDF5DatasetFileHandler() + dataset_file_handler.create(path, "test_env_name") + dataset_file_handler.close() + assert {path.name for path in tmp_path.iterdir()} == {"absolute.hdf5", "no_extension.hdf5", "relative.hdf5"} -def test_create_dataset_file(temp_dir, monkeypatch): - """Test creating a new dataset file.""" - # create a dataset file given a file name with extension - dataset_file_path = os.path.join(temp_dir, f"{uuid.uuid4()}.hdf5") - dataset_file_handler = HDF5DatasetFileHandler() - dataset_file_handler.create(dataset_file_path, "test_env_name") - dataset_file_handler.close() - - # check if the dataset is created - assert os.path.exists(dataset_file_path) - - # create a dataset file given a file name without extension - dataset_file_path = os.path.join(temp_dir, f"{uuid.uuid4()}") - dataset_file_handler = HDF5DatasetFileHandler() - dataset_file_handler.create(dataset_file_path, "test_env_name") - dataset_file_handler.close() - - # check if the dataset is created - assert os.path.exists(dataset_file_path + ".hdf5") - - # create a dataset file given a bare file name in the current working directory - monkeypatch.chdir(temp_dir) - dataset_file_name = f"{uuid.uuid4()}.hdf5" - dataset_file_handler = HDF5DatasetFileHandler() - dataset_file_handler.create(dataset_file_name, "test_env_name") - dataset_file_handler.close() - - # check if the dataset is created - assert os.path.exists(os.path.join(temp_dir, dataset_file_name)) - - -def test_add_env_args_preserves_existing_args_after_reopen(temp_dir): - """Test extending environment arguments after reopening a dataset.""" - dataset_file_path = os.path.join(temp_dir, f"{uuid.uuid4()}.hdf5") +def test_env_args_follow_active_file(tmp_path): + """Preserve metadata on reopen, then discard it when reusing the handler for a new file.""" + dataset_file_path = str(tmp_path / "first.hdf5") dataset_file_handler = HDF5DatasetFileHandler() dataset_file_handler.create(dataset_file_path, "test_env_name") dataset_file_handler.close() @@ -75,16 +41,7 @@ def test_add_env_args_preserves_existing_args_after_reopen(temp_dir): assert env_args == {"env_name": "test_env_name", "type": 2, "custom_arg": "custom_value"} - -def test_create_resets_env_args_when_reusing_handler(temp_dir): - """Test environment arguments do not leak between datasets.""" - first_dataset_path = os.path.join(temp_dir, f"{uuid.uuid4()}.hdf5") - second_dataset_path = os.path.join(temp_dir, f"{uuid.uuid4()}.hdf5") - dataset_file_handler = HDF5DatasetFileHandler() - dataset_file_handler.create(first_dataset_path, "first_env") - dataset_file_handler.add_env_args({"custom_arg": "custom_value"}) - dataset_file_handler.close() - + second_dataset_path = str(tmp_path / "second.hdf5") dataset_file_handler.create(second_dataset_path, "second_env") dataset_file_handler.close() @@ -95,13 +52,15 @@ def test_create_resets_env_args_when_reusing_handler(temp_dir): @pytest.mark.parametrize("device", test_devices()) -def test_write_and_load_episode(temp_dir, device): - """Test writing and loading an episode to and from the dataset file.""" - dataset_file_path = os.path.join(temp_dir, f"{uuid.uuid4()}.hdf5") +def test_write_and_load_episode(tmp_path, device): + """Round-trip episode data and metadata, then play every action through exhaustion.""" + dataset_file_path = str(tmp_path / "episodes.hdf5") dataset_file_handler = HDF5DatasetFileHandler() dataset_file_handler.create(dataset_file_path, "test_env_name") test_episode = EpisodeData() + assert test_episode.is_empty() + assert test_episode.get_next_action() is None test_episode.seed = 0 test_episode.success = True test_episode.data = { @@ -109,6 +68,9 @@ def test_write_and_load_episode(temp_dir, device): "actions": torch.tensor([[1, 2, 3], [4, 5, 6], [7, 8, 9]], device=device), "obs": {"policy": {"term1": torch.arange(1, 16, device=device).reshape(3, 5)}}, } + assert not test_episode.is_empty() + # Reading an absent action must not advance the cursor before data becomes available. + assert torch.equal(test_episode.get_next_action(), test_episode.data["actions"][0]) # write the episode to the dataset dataset_file_handler.write_episode(test_episode) @@ -140,7 +102,7 @@ def test_write_and_load_episode(temp_dir, device): assert loaded_episode.seed == test_episode.seed assert loaded_episode.success == test_episode.success - assert torch.equal(loaded_episode.get_initial_state(), test_episode.get_initial_state()) + assert torch.equal(loaded_episode.get_initial_state(), test_episode.data["initial_state"]) assert torch.equal(loaded_episode.data["obs"]["policy"]["term1"], test_episode.data["obs"]["policy"]["term1"]) From 6d112c52b267e3d520c0333c5458f93474a6b02b Mon Sep 17 00:00:00 2001 From: Octi Zhang Date: Tue, 27 Oct 2026 23:43:38 -0700 Subject: [PATCH 9/9] Simplify recorder documentation and preserve reset selectors --- docs/source/api/lab/isaaclab.managers.rst | 77 ++++++++----------- .../changelog.d/recorder-capture.changed.rst | 1 + .../isaaclab/envs/mdp/recorders/recorders.py | 2 +- .../isaaclab/managers/recorder_manager.py | 59 +++++--------- .../test/managers/test_recorder_manager.py | 69 ++++++++--------- 5 files changed, 84 insertions(+), 124 deletions(-) diff --git a/docs/source/api/lab/isaaclab.managers.rst b/docs/source/api/lab/isaaclab.managers.rst index 953a868ed7bf..251a56cb6654 100644 --- a/docs/source/api/lab/isaaclab.managers.rst +++ b/docs/source/api/lab/isaaclab.managers.rst @@ -228,56 +228,39 @@ Curriculum Manager :members: :exclude-members: __init__ +.. _recorder-capture: + Recorder Manager ---------------- -Recorder terms select which tensors to capture at the existing pre/post step and reset callbacks. -For an online consumer, a term can keep its latest batch in preallocated tensors and return -``(None, None)`` from its callbacks. The training root obtains that term with -``env.recorder_manager.get_term(name)`` and copies the batch into its own replay storage. -The term owns the lifetime of its published tensors; consumers must copy them before the next capture. - -The task's recorder-term configuration owns the captured schema: select the state, applied controls, -context and terminal flags that the consumer needs. Core capture/storage remains independent of -downstream applications. Consumers own data processing, replay and learning; recorder callbacks only -capture and publish the configured data. - -An export mode of ``EXPORT_NONE`` disables file export, not sample retention. To avoid episode buffers, -a term must return ``(None, None)``. A task can offer its own ``record_to_episodes`` option that changes -this return value while keeping capture identical. Disabling the term in the task configuration removes -capture itself. These are three separate choices: capture, retain and export. - -In an RL environment, post-step callbacks capture the completed physical transition after rewards -and terminations, before automatic reset. Recorder dispatch does not compute observations. -Physical-state terms read the scene directly; pre-step observation terms read the existing -``env.obs_buf`` used by the policy. - -**Migration:** Custom post-step terms must no longer assume that ``env.obs_buf`` has been refreshed; -it still contains the preceding policy input. To capture observations of the completed transition, -call ``env.observation_manager.compute_group("recording", update_history=False)`` explicitly -inside the term. Configure that observation group without history or delay when current measurements -are needed. This does not advance observation history, but noise and modifiers still run: -use a separate noiseless group to avoid changing subsequent policy noise draws. No extra -observation computation occurs just because a recorder is enabled. Non-RL manager-based environments -already update their normal observations before post-step capture. - -Returning a field path and a batched tensor (or nested dictionary of tensors) retains samples in -the manager. Each field has a time-major allocation and independent per-world insertion indices, -so reset and per-step fields can have different sample rates. ``buffer_initial_capacity`` controls -the first allocation per field: choose it with the payload size and expected episode length in mind, -especially for images. Storage grows without dropping samples and is reused across resets. - -``get_episode(env_id)`` returns an owned snapshot whose leaves are tensors with a leading sample -dimension. Reading a snapshot can synchronize device indices and copy data. Changes to that snapshot -do not change live recording; use ``add_to_episodes`` and ``set_success_to_episodes`` for live writes. -File export materializes these snapshots; the hot capture path does not construct per-world episodes. - -``EpisodeData`` now holds tensor snapshots and playback cursors. The former ``add()`` and -``pre_export()`` methods are removed: collect through ``RecorderManager.add_to_episodes()``, -or assign nested tensors with leading sample dimensions to ``episode.data`` when constructing a -completed episode directly. HDF5 loading and playback retain the same tensor format. -The unused ``RecorderTerm.close(file_path)`` hook is also removed. Dataset handlers own file -finalization; applications explicitly close any additional resources they own. +Tasks configure recorder terms to capture the state, controls and other fields their consumers need. +Terms run at pre/post step and reset callbacks; reset callbacks receive slices or indices directly. +Consumers own subsequent processing, replay and learning. + +Returning a field path and a batched tensor (or nested dictionary of tensors) retains samples. +For online use without retention, publish the batch on the term and return ``(None, None)``. +Consumers access it through ``env.recorder_manager.get_term(name)`` and copy data before the next capture. +``EXPORT_NONE`` disables file export only; disabling the term removes capture itself. + +Fields may have different sample rates. ``buffer_initial_capacity`` sets the initial samples per world +and field; choose it with payload size and episode length in mind. Storage grows without dropping +samples and is reused across resets. + +**Observation migration:** RL post-step callbacks run after rewards and terminations, before reset +and observation updates. ``env.obs_buf`` still contains the preceding policy input. Read physical +state from the scene or call ``env.observation_manager.compute_group("recording", update_history=False)`` +for current observations. Use a separate noiseless group without history or delay to avoid stale +measurements and additional policy noise draws. History is not advanced, but noise and modifiers still +run. Non-RL environments update their normal observations before post-step capture. + +**Storage migration:** ``get_episode(env_id)`` returns an owned tensor snapshot with a leading sample +dimension; reading copies data and may synchronize the device. Modify live recording through +``add_to_episodes`` and ``set_success_to_episodes``, not the snapshot. ``EpisodeData.add()`` and +``pre_export()`` are removed: collect through the manager, or assign completed tensors to +``episode.data``. HDF5 loading and playback keep the same tensor format. + +The unused ``RecorderTerm.close(file_path)`` hook is removed. Dataset handlers finalize files; +applications explicitly close any additional resources they own. .. autoclass:: RecorderManager :members: diff --git a/source/isaaclab/changelog.d/recorder-capture.changed.rst b/source/isaaclab/changelog.d/recorder-capture.changed.rst index 8c4ad5bd3e45..3e2c6396515e 100644 --- a/source/isaaclab/changelog.d/recorder-capture.changed.rst +++ b/source/isaaclab/changelog.d/recorder-capture.changed.rst @@ -1,6 +1,7 @@ * Replaced per-world recorder episode containers with batched field allocations and independent insertion indices, reusing storage across resets and growing without dropping samples. * Added :meth:`~isaaclab.managers.RecorderManager.get_term` for direct access to captured batches. +* Preserve slices through recorder reset callbacks; terms accept slices or indices directly. * **Breaking:** Removed the implicit observation computation before RL post-step recorder callbacks. Physical capture no longer consumes extra observation noise. Custom terms needing observations of the completed transition must call ``env.observation_manager.compute_group(name, update_history=False)`` diff --git a/source/isaaclab/isaaclab/envs/mdp/recorders/recorders.py b/source/isaaclab/isaaclab/envs/mdp/recorders/recorders.py index a9315bbca63d..55050f5f43a5 100644 --- a/source/isaaclab/isaaclab/envs/mdp/recorders/recorders.py +++ b/source/isaaclab/isaaclab/envs/mdp/recorders/recorders.py @@ -14,7 +14,7 @@ class InitialStateRecorder(RecorderTerm): """Recorder term that records the initial state of the environment after reset.""" - def record_post_reset(self, env_ids: Sequence[int] | None): + def record_post_reset(self, env_ids: Sequence[int] | slice | None): def extract_env_ids_values(value): nonlocal env_ids if isinstance(value, dict): diff --git a/source/isaaclab/isaaclab/managers/recorder_manager.py b/source/isaaclab/isaaclab/managers/recorder_manager.py index 71d648b0760f..58b352b76eaa 100644 --- a/source/isaaclab/isaaclab/managers/recorder_manager.py +++ b/source/isaaclab/isaaclab/managers/recorder_manager.py @@ -55,11 +55,9 @@ class RecorderManagerBaseCfg: """Whether to export episodes in the close call.""" buffer_initial_capacity: int = 64 - """Initial samples per world and field. Must be positive. + """Initial samples per world and field. Must be positive; storage grows as needed. - Fields allocate on first use and double their capacity when necessary, retaining all samples. - Resets reuse storage. Set this to the expected episode length (including decimation callbacks) - to avoid growth during recording. Each field has independent write indices. + Set to the expected episode length, including decimation callbacks, to avoid reallocations. """ dataset_compression: bool = True @@ -84,7 +82,7 @@ class RecorderTerm(ManagerTermBase): User-defined callbacks. """ - def record_pre_reset(self, env_ids: Sequence[int] | None) -> tuple[str | None, torch.Tensor | dict | None]: + def record_pre_reset(self, env_ids: Sequence[int] | slice | None) -> tuple[str | None, torch.Tensor | dict | None]: """Record data at the beginning of env.reset() before reset is effective. Args: @@ -99,7 +97,7 @@ def record_pre_reset(self, env_ids: Sequence[int] | None) -> tuple[str | None, t """ return None, None - def record_post_reset(self, env_ids: Sequence[int] | None) -> tuple[str | None, torch.Tensor | dict | None]: + def record_post_reset(self, env_ids: Sequence[int] | slice | None) -> tuple[str | None, torch.Tensor | dict | None]: """Record data at the end of env.reset(). Args: @@ -123,14 +121,9 @@ def record_pre_step(self) -> tuple[str | None, torch.Tensor | dict | None]: def record_post_step(self) -> tuple[str | None, torch.Tensor | dict | None]: """Record the completed physical transition. - In RL environments, this runs after rewards and terminations, before automatic reset. - The environment does not compute observations for recorders: `env.obs_buf` still holds - the preceding policy input. Terms needing observations of the completed transition must - explicitly call `env.observation_manager.compute_group(name, update_history=False)`. - For current measurements, configure that group without history or delay. Noise and - modifiers still run, so a noisy group can affect subsequent policy noise draws. - - In non-RL manager-based environments, the normal observation update precedes this callback. + In RL environments, this runs before automatic reset and observation updates; + ``env.obs_buf`` still holds the preceding policy input. See :ref:`recorder-capture` + for recording current observations. Non-RL environments update observations first. Returns: A tuple of key and value to be recorded. @@ -230,25 +223,13 @@ def active_terms(self) -> list[str]: @property def exported_successful_episode_count(self) -> int: - """Number of successful episodes. - - Returns: - The number of successful episodes. - """ - if not hasattr(self, "_exported_counts"): - return 0 - return int(self._exported_counts[1].item()) + """Number of successful episodes.""" + return int(self._exported_counts[1].item()) if self.active_terms else 0 @property def exported_failed_episode_count(self) -> int: - """Number of failed episodes. - - Returns: - The number of failed episodes. - """ - if not hasattr(self, "_exported_counts"): - return 0 - return int(self._exported_counts[0].item()) + """Number of failed episodes.""" + return int(self._exported_counts[0].item()) if self.active_terms else 0 """ Operations. @@ -279,10 +260,8 @@ def get_term(self, name: str) -> RecorderTerm: def get_episode(self, env_id: int) -> EpisodeData: """Return an owned snapshot of a world's current episode. - Leaves are tensors with a leading sample dimension, matching the exported dataset. - Reading synchronizes write indices and copies the recorded samples. Later recording, - reset, or snapshot mutation cannot affect the other copy. Add live data through - :meth:`add_to_episodes`, and set success through :meth:`set_success_to_episodes`. + Tensor leaves have a leading sample dimension. Reading copies data and may synchronize + the device. For live writes, use :meth:`add_to_episodes` and :meth:`set_success_to_episodes`. """ episode = EpisodeData() if not self.active_terms or not 0 <= env_id < self._env.num_envs: @@ -300,7 +279,7 @@ def get_episode(self, env_id: int) -> EpisodeData: return episode def add_to_episodes(self, key: str, value: torch.Tensor | dict, env_ids: Sequence[int] | slice | None = None): - """Append a batch to preallocated storage, advancing selected worlds' write indices. + """Append a batch to the selected episodes. Args: key: Field path, with nested keys separated by '/'. @@ -372,16 +351,14 @@ def record_pre_reset(self, env_ids: Sequence[int] | slice | None, force_export_o return if env_ids is None: - env_ids = self._env_ids - elif isinstance(env_ids, slice): - env_ids = self._env_ids[env_ids] + env_ids = slice(None) for term in self._terms.values(): key, value = term.record_pre_reset(env_ids) self.add_to_episodes(key, value, env_ids) # Set task success values for the relevant episodes - success_results = torch.zeros(len(env_ids), dtype=bool, device=self._env.device) + success_results = torch.zeros_like(self._success[env_ids], dtype=torch.bool) # Check success indicator from termination terms if hasattr(self._env, "termination_manager"): if "success" in self._env.termination_manager.active_terms: @@ -401,8 +378,8 @@ def record_post_reset(self, env_ids: Sequence[int] | slice | None) -> None: if len(self.active_terms) == 0: return - if isinstance(env_ids, slice): - env_ids = self._env_ids[env_ids] + if env_ids is None: + env_ids = slice(None) for term in self._terms.values(): key, value = term.record_post_reset(env_ids) diff --git a/source/isaaclab/test/managers/test_recorder_manager.py b/source/isaaclab/test/managers/test_recorder_manager.py index c4b791a0aa37..e0396da5543e 100644 --- a/source/isaaclab/test/managers/test_recorder_manager.py +++ b/source/isaaclab/test/managers/test_recorder_manager.py @@ -29,11 +29,13 @@ class DummyResetRecorderTerm(RecorderTerm): """Dummy recorder term that records dummy data.""" - def record_pre_reset(self, env_ids: Sequence[int] | None) -> tuple[str | None, torch.Tensor | None]: - return "record_pre_reset", torch.ones(self._env.num_envs, 2, device=self._env.device) + def record_pre_reset(self, env_ids: Sequence[int] | slice | None) -> tuple[str, torch.Tensor]: + self.env_ids = env_ids + return "record_pre_reset", torch.ones(self._env.num_envs, 2, device=self._env.device)[env_ids] - def record_post_reset(self, env_ids: Sequence[int] | None) -> tuple[str | None, torch.Tensor | None]: - return "record_post_reset", torch.ones(self._env.num_envs, 3, device=self._env.device) + def record_post_reset(self, env_ids: Sequence[int] | slice | None) -> tuple[str, torch.Tensor]: + self.env_ids = env_ids + return "record_post_reset", torch.ones(self._env.num_envs, 3, device=self._env.device)[env_ids] class DummyStepRecorderTerm(RecorderTerm): @@ -73,40 +75,36 @@ def create_dummy_env(device: str = "cpu", num_envs: int = 3) -> ManagerBasedEnv: def test_record(tmp_path): - """Test the recording of the data.""" + """Capture callbacks and full/partial resets preserve episode boundaries and selectors.""" env = create_dummy_env() - # create recorder manager + for cfg in (None, {}, RecorderManagerBaseCfg()): + disabled = RecorderManager(cfg, env) + assert disabled.exported_successful_episode_count == disabled.exported_failed_episode_count == 0 cfg = DummyRecorderManagerCfg(dataset_export_dir_path=str(tmp_path), dataset_filename="dataset.hdf5") - recorder_manager = RecorderManager(cfg, env) + manager = RecorderManager(cfg, env) assert (tmp_path / cfg.dataset_filename).is_file() + reset_term = manager.get_term("record_reset_term") - # record the step data - recorder_manager.record_pre_step() - recorder_manager.record_post_step() - - recorder_manager.record_pre_step() - recorder_manager.record_post_step() - - # check the recorded data + for _ in range(2): + manager.record_pre_step() + manager.record_post_step() for env_id in range(env.num_envs): - episode = recorder_manager.get_episode(env_id) + episode = manager.get_episode(env_id) assert episode.data["record_pre_step"].shape == (2, 4) assert episode.data["record_post_step"].shape == (2, 5) - # Trigger pre-reset callbacks which then export and clean the episode data - recorder_manager.record_pre_reset(env_ids=slice(None)) - for env_id in range(env.num_envs): - episode = recorder_manager.get_episode(env_id) - assert episode.is_empty() - - recorder_manager.record_post_reset(env_ids=slice(None)) + manager.record_pre_reset(slice(None)) # Capture, export and clear the completed episodes. + assert reset_term.env_ids == slice(None) + assert all(manager.get_episode(i).is_empty() for i in range(env.num_envs)) + manager.record_post_reset(None) + assert reset_term.env_ids == slice(None) + manager.reset(slice(1, None, 2)) for env_id in range(env.num_envs): - episode = recorder_manager.get_episode(env_id) - assert episode.data["record_post_reset"].shape == (1, 3) - recorder_manager.reset(slice(1, None, 2)) - for env_id in range(env.num_envs): - assert recorder_manager.get_episode(env_id).is_empty() == (env_id % 2 == 1) - recorder_manager.close() + assert manager.get_episode(env_id).is_empty() == (env_id % 2 == 1) + manager.record_post_reset(slice(1, None, 2)) + assert reset_term.env_ids == slice(1, None, 2) + assert all(manager.get_episode(i).data["record_post_reset"].shape == (1, 3) for i in range(env.num_envs)) + manager.close() def test_batched_episode_indices_and_ownership(monkeypatch): @@ -148,8 +146,6 @@ def test_batched_episode_indices_and_ownership(monkeypatch): with pytest.raises(ValueError, match="changed shape"): manager.add_to_episodes("obs/state", torch.zeros(3, 5)) - with pytest.raises(ValueError, match="requires 3 rows"): - manager.add_to_episodes("obs/state", torch.zeros(2, 2)) with pytest.raises(ValueError, match="conflicts"): manager.add_to_episodes("obs", torch.zeros(3, 2)) manager.add_to_episodes("obs/state", torch.empty(0, 2), []) @@ -227,21 +223,24 @@ def test_recorder_storage_boundary(): source = ast.parse(inspect.getsource(module)) # Batched buffers own collection; snapshots and capture terms have no second writer lifecycle. - assert not hasattr(EpisodeData, "add") - assert not hasattr(EpisodeData, "pre_export") + assert not {"add", "pre_export"} & vars(EpisodeData).keys() assert "__init__" not in vars(module.RecorderTerm) assert not hasattr(module.RecorderTerm, "close") # Observation evaluation belongs to explicit terms, never generic capture dispatch. assert not hasattr(module.RecorderTerm, "requires_post_step_observations") assert not hasattr(module.RecorderManager, "requires_post_step_observations") - assert not any(isinstance(node, ast.Attribute) and node.attr == "observation_manager" for node in ast.walk(source)) - assert not any(isinstance(node, ast.Attribute) and node.attr == "_episodes" for node in ast.walk(source)) + assert not any( + isinstance(node, ast.Attribute) and node.attr in {"observation_manager", "_episodes"} + for node in ast.walk(source) + ) allowed = sys.stdlib_module_names | {"isaaclab", "torch", "warp", "prettytable"} for node in ast.walk(source): if isinstance(node, ast.Import): assert all(alias.name.split(".")[0] in allowed for alias in node.names) elif isinstance(node, ast.ImportFrom) and node.level == 0: assert (node.module or "").split(".")[0] in allowed + elif isinstance(node, ast.FunctionDef) and node.name.startswith("exported_"): + assert not any(isinstance(child, ast.Name) and child.id == "hasattr" for child in ast.walk(node)) field = next(node for node in source.body if isinstance(node, ast.ClassDef) and node.name == "_RecorderField") append = next(node for node in field.body if isinstance(node, ast.FunctionDef) and node.name == "append") assert not any(isinstance(node, (ast.For, ast.While, ast.comprehension)) for node in ast.walk(append))