33"""
44
55import random
6+ from typing import List
67
8+ import gymnasium as gym
79from gymnasium .envs .mujoco .half_cheetah_v4 import HalfCheetahEnv
810import numpy as np
911
@@ -37,7 +39,19 @@ def step(self, action):
3739
3840
3941class HalfCheetahV4LogVelocity (_HalfCheetahV4ExposeVelReward ):
40- """A wrapper around HalfCheetah-V4 that will automatically log velocity metrics when used in AMAGO."""
42+ """A wrapper around HalfCheetah-V4 that will automatically log velocity metrics when used in AMAGO.
43+
44+ Args:
45+ max_episode_steps: Step horizon at which the inner episode is
46+ truncated. Default 1000 matches the original gym registration.
47+ Lower values are useful for k-episode meta-trial wrappers that
48+ want to fit multiple inner episodes inside a fixed sequence
49+ length.
50+ """
51+
52+ def __init__ (self , max_episode_steps : int = 1000 , ** kwargs ):
53+ super ().__init__ (** kwargs )
54+ self .max_episode_steps = int (max_episode_steps )
4155
4256 def reset (self , * args , ** kwargs ):
4357 obs , info = super ().reset (* args , ** kwargs )
@@ -51,7 +65,7 @@ def step(self, action):
5165 # gym registration that would normally handle it.
5266 self .step_count += 1
5367 self ._velocity_history .append (info ["x_velocity" ])
54- truncated = truncated or self .step_count >= 1000
68+ truncated = truncated or self .step_count >= self . max_episode_steps
5569 if terminated or truncated :
5670 v = np .array (self ._velocity_history )
5771 info [f"{ AMAGO_ENV_LOG_PREFIX } Mean Velocity" ] = v .mean ().item ()
@@ -69,6 +83,17 @@ class HalfCheetahV4_MetaVelocity(HalfCheetahV4LogVelocity):
6983 Reward terms are based on the version featured in the VariBAD codebase
7084 (https://github.com/lmzintgraf/varibad/blob/57e1795be142ace52d0c353097acf193d9067200/environments/mujoco/half_cheetah_vel.py#L8)
7185
86+ A single hidden ``target_velocity`` persists across ``k_episodes`` inner
87+ episodes (each truncated by the parent class at ``max_episode_steps``).
88+ Between inner episodes the simulator is soft-reset; the hidden task is
89+ only resampled when ``reset(new_task=True)`` is called from outside (the
90+ default at the start of every meta-trial). A soft-reset flag (0/1) is
91+ appended to the obs so the agent can detect inner-episode boundaries
92+ without losing task identity. Per-episode metrics from the parent class
93+ (Mean / Max / Avg. Last 50 Timestep Velocity) are promoted to
94+ ``Trial {i} ...`` keys, matching the meta-RL logging convention used by
95+ ``KEpisodeMetaworld``.
96+
7297 Args:
7398 ctrl_cost_weight: Defaults to half the normal ctrl cost, as in the original HalfCheetahVelocity task.
7499 velocity_reward_weight: Defaults to 1.0.
@@ -80,6 +105,10 @@ class HalfCheetahV4_MetaVelocity(HalfCheetahV4LogVelocity):
80105
81106 task_min_velocity: Minimum target velocity that determines the hidden task. Defaults to 0.0.
82107 task_max_velocity: Maximum target velocity that determines the hidden task. Defaults to 3.0.
108+ k_episodes: Number of inner episodes per meta-trial. Default 1
109+ recovers the unwrapped single-episode task. Set ``>=2`` for
110+ true meta-RL trials (e.g. 3 inner episodes of 200 steps with
111+ ``max_episode_steps=200``).
83112 """
84113
85114 def __init__ (
@@ -93,6 +122,7 @@ def __init__(
93122 # We can use the HalfCheetahV4LogVelocity env above to verify this.
94123 task_min_velocity : float = 0.0 ,
95124 task_max_velocity : float = 3.0 ,
125+ k_episodes : int = 1 ,
96126 ** kwargs ,
97127 ):
98128 super ().__init__ (
@@ -102,8 +132,24 @@ def __init__(
102132 )
103133 self .task_min_velocity = task_min_velocity
104134 self .task_max_velocity = task_max_velocity
135+ self .k_episodes = int (k_episodes )
136+
137+ # Append a soft-reset flag (0/1) to the parent observation space.
138+ flat = self .observation_space
139+ low = np .append (flat .low .astype (np .float32 ), np .float32 (0.0 ))
140+ high = np .append (flat .high .astype (np .float32 ), np .float32 (1.0 ))
141+ self .observation_space = gym .spaces .Box (low = low , high = high , dtype = np .float32 )
142+
143+ self .target_velocity : float = 0.0
144+ self .current_trial : int = 0
145+ self ._trial_returns : List [float ] = []
146+ self ._current_trial_return : float = 0.0
105147 self .reset ()
106148
149+ @staticmethod
150+ def _augment (obs : np .ndarray , soft_reset : bool ) -> np .ndarray :
151+ return np .append (obs , float (soft_reset )).astype (np .float32 )
152+
107153 def velocity_reward_term (self , x_velocity ):
108154 return (
109155 self ._forward_reward_weight
@@ -117,16 +163,64 @@ def sample_target_velocity(self):
117163 # tasks are different across async parallel actors!
118164 return random .uniform (self .task_min_velocity , self .task_max_velocity )
119165
120- def reset (self , * args , ** kwargs ):
121- obs , info = super ().reset (* args , ** kwargs )
122- self .target_velocity = self .sample_target_velocity ()
123- return obs , info
166+ def reset (self , * , new_task : bool = True , seed = None , options = None ):
167+ # ``new_task=False`` is reserved for soft resets *inside* step();
168+ # external callers should use the default to start a fresh meta-trial.
169+ obs , info = super ().reset (seed = seed , options = options )
170+ if new_task :
171+ self .target_velocity = self .sample_target_velocity ()
172+ self .current_trial = 0
173+ self ._trial_returns = []
174+ self ._current_trial_return = 0.0
175+ return self ._augment (obs , soft_reset = True ), info
176+
177+ def _log_per_trial_metrics (self , info : dict ) -> None :
178+ ti = self .current_trial
179+ for suffix in (
180+ "Mean Velocity" ,
181+ "Max Velocity" ,
182+ "Avg. Last 50 Timestep Velocity" ,
183+ ):
184+ src = f"{ AMAGO_ENV_LOG_PREFIX } { suffix } "
185+ if src in info :
186+ info [f"{ AMAGO_ENV_LOG_PREFIX } Trial { ti } { suffix } " ] = info .pop (src )
187+ achieved_key = (
188+ f"{ AMAGO_ENV_LOG_PREFIX } Trial { ti } Avg. Last 50 Timestep Velocity"
189+ )
190+ if achieved_key in info :
191+ info [
192+ f"{ AMAGO_ENV_LOG_PREFIX } Trial { ti } Target Velocity Error at Final 50 Timesteps"
193+ ] = abs (self .target_velocity - info [achieved_key ])
194+ info [f"{ AMAGO_ENV_LOG_PREFIX } Trial { ti } Return" ] = self ._current_trial_return
124195
125196 def step (self , action ):
126197 obs , reward , terminated , truncated , info = super ().step (action )
198+ self ._current_trial_return += float (reward )
199+ soft_reset = False
200+
127201 if terminated or truncated :
128- achieved = info [f"{ AMAGO_ENV_LOG_PREFIX } Avg. Last 50 Timestep Velocity" ]
129- info [
130- f"{ AMAGO_ENV_LOG_PREFIX } Target Velocity Error at Final 50 Timesteps"
131- ] = abs (self .target_velocity - achieved )
132- return obs , reward , terminated , truncated , info
202+ self ._log_per_trial_metrics (info )
203+ self ._trial_returns .append (self ._current_trial_return )
204+ self .current_trial += 1
205+
206+ # Always soft-reset the simulator on inner-episode end (mirrors
207+ # KEpisodeMetaworld). The augmented obs's soft-reset flag is set
208+ # to 1 so the agent can detect the boundary.
209+ obs , _ = super ().reset ()
210+ soft_reset = True
211+ self ._current_trial_return = 0.0
212+
213+ if self .current_trial >= self .k_episodes :
214+ # Meta-trial end is a time-limit truncation, not a terminal
215+ # state: the underlying MDP could continue if we kept
216+ # observing. Leave terminated=False so the critic
217+ # bootstraps as usual.
218+ terminated = False
219+ truncated = True
220+ info [f"{ AMAGO_ENV_LOG_PREFIX } Total Return" ] = float (
221+ np .sum (self ._trial_returns )
222+ )
223+ else :
224+ terminated = truncated = False
225+
226+ return self ._augment (obs , soft_reset ), reward , terminated , truncated , info
0 commit comments