tau-ctrl 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,109 @@
1
+ """Tiny pure-Python Gymnasium envs for testing/examples.
2
+
3
+ No simulator dependency (no MuJoCo/MJX) — they exist so tau-ctrl's algorithms
4
+ can be tested end-to-end anywhere. Both expose ``get_state``/``set_state`` so
5
+ the model-based controllers (MPPI/CEM/CBF) can branch rollouts, exactly as a
6
+ real simulator env would once tau-sim adds that capability.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Optional
12
+
13
+ import numpy as np
14
+
15
+ try:
16
+ import gymnasium as gym
17
+ from gymnasium import spaces
18
+ except Exception as e: # pragma: no cover
19
+ raise ImportError("tau_ctrl.algorithms requires gymnasium") from e
20
+
21
+
22
+ class PendulumSwingUp(gym.Env):
23
+ """Torque-limited pendulum. angle 0 = upright; reward peaks upright.
24
+
25
+ obs = [cos(th), sin(th), thdot]. Reward = -(th^2 + 0.1*thdot^2 + 1e-3*u^2).
26
+ """
27
+
28
+ metadata = {"render_modes": []}
29
+
30
+ def __init__(self, max_torque: float = 6.0, dt: float = 0.05,
31
+ g: float = 10.0, m: float = 1.0, l: float = 1.0,
32
+ start_angle: float = np.pi, max_speed: float = 8.0):
33
+ self.max_torque = float(max_torque)
34
+ self.max_speed = float(max_speed)
35
+ self.dt, self.g, self.m, self.l = dt, g, m, l
36
+ self.start_angle = float(start_angle)
37
+ self.state = np.array([self.start_angle, 0.0])
38
+ high = np.array([1.0, 1.0, self.max_speed], dtype=np.float32)
39
+ self.observation_space = spaces.Box(-high, high, dtype=np.float32)
40
+ self.action_space = spaces.Box(-self.max_torque, self.max_torque, (1,), np.float32)
41
+
42
+ # --- branching capability ---
43
+ def get_state(self):
44
+ return np.array(self.state, dtype=float)
45
+
46
+ def set_state(self, s):
47
+ self.state = np.array(s, dtype=float)
48
+
49
+ def _obs(self):
50
+ th, thd = self.state
51
+ return np.array([np.cos(th), np.sin(th), thd], dtype=np.float32)
52
+
53
+ def reset(self, *, seed: Optional[int] = None, options: Optional[dict] = None):
54
+ super().reset(seed=seed)
55
+ self.state = np.array([self.start_angle, 0.0])
56
+ return self._obs(), {}
57
+
58
+ def step(self, action):
59
+ th, thd = self.state
60
+ u = float(np.clip(np.asarray(action).reshape(-1)[0], -self.max_torque, self.max_torque))
61
+ # angle measured from upright: gravity accelerates away from upright.
62
+ thddot = (self.g / self.l) * np.sin(th) + u / (self.m * self.l ** 2)
63
+ thd = np.clip(thd + thddot * self.dt, -self.max_speed, self.max_speed)
64
+ th = th + thd * self.dt
65
+ th = ((th + np.pi) % (2 * np.pi)) - np.pi # wrap to [-pi, pi]
66
+ self.state = np.array([th, thd])
67
+ cost = th ** 2 + 0.1 * thd ** 2 + 1e-3 * u ** 2
68
+ return self._obs(), float(-cost), False, False, {}
69
+
70
+
71
+ class DoubleIntegrator(gym.Env):
72
+ """1-D point mass: state=[x, v], action = acceleration (bounded).
73
+
74
+ Reward drives x -> 0. Handy for testing a CBF that must keep x <= x_max.
75
+ obs = [x, v].
76
+ """
77
+
78
+ metadata = {"render_modes": []}
79
+
80
+ def __init__(self, dt: float = 0.05, a_max: float = 3.0, x_max: float = 1.0,
81
+ x0: float = 0.5, v0: float = 1.5):
82
+ self.dt, self.a_max, self.x_max = dt, float(a_max), float(x_max)
83
+ self.x0, self.v0 = float(x0), float(v0)
84
+ self.state = np.array([x0, v0])
85
+ self.observation_space = spaces.Box(-np.inf, np.inf, (2,), np.float32)
86
+ self.action_space = spaces.Box(-self.a_max, self.a_max, (1,), np.float32)
87
+
88
+ def get_state(self):
89
+ return np.array(self.state, dtype=float)
90
+
91
+ def set_state(self, s):
92
+ self.state = np.array(s, dtype=float)
93
+
94
+ def _obs(self):
95
+ return self.state.astype(np.float32)
96
+
97
+ def reset(self, *, seed=None, options=None):
98
+ super().reset(seed=seed)
99
+ self.state = np.array([self.x0, self.v0])
100
+ return self._obs(), {}
101
+
102
+ def step(self, action):
103
+ x, v = self.state
104
+ a = float(np.clip(np.asarray(action).reshape(-1)[0], -self.a_max, self.a_max))
105
+ v = v + a * self.dt
106
+ x = x + v * self.dt
107
+ self.state = np.array([x, v])
108
+ reward = -(x ** 2 + 0.1 * v ** 2)
109
+ return self._obs(), float(reward), False, False, {}
@@ -0,0 +1,182 @@
1
+ """iLQR — gradient-based trajectory optimization (a different family from
2
+ MPPI/CEM/ICEM's sampling-based search).
3
+
4
+ Where the sampling-MPC family searches by rolling out many random action
5
+ sequences, iLQR alternates a **backward pass** (locally linearize the
6
+ dynamics and quadratically approximate the cost along the current nominal
7
+ trajectory, solve the resulting LQR problem for a feedback law) with a
8
+ **forward pass** (apply that feedback law, with a backtracking line search on
9
+ the feedforward term). It converges in far fewer trajectory evaluations when
10
+ the dynamics are reasonably smooth, at the cost of being more sensitive to
11
+ strong nonlinearities/discontinuities than sampling-based search.
12
+
13
+ Simulator-agnostic like the rest of :mod:`tau_ctrl.algorithms`: since the env
14
+ is an opaque ``step`` function, the dynamics Jacobians and cost derivatives
15
+ are estimated by finite differences on a branchable env (``get_state``/
16
+ ``set_state``), the same contract MPPI/CEM/CBF already rely on. This is a
17
+ lightweight iLQR variant: cost curvature is approximated diagonally and
18
+ cross state-action curvature is dropped (a standard, cheap simplification —
19
+ full analytic/autodiff Hessians would need a differentiable simulator).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Any, Optional
25
+
26
+ import numpy as np
27
+
28
+ from .base import BaseController, ControllerManifest, get_state, register, set_state
29
+
30
+
31
+ @register("ilqr")
32
+ class ILQR(BaseController):
33
+ manifest = ControllerManifest(
34
+ name="ilqr", family="gradient_mpc", requires_branching=True, uses_reward=True,
35
+ )
36
+
37
+ def __init__(
38
+ self,
39
+ env: Any,
40
+ horizon: int = 20,
41
+ iterations: int = 5,
42
+ fd_eps: float = 1e-3,
43
+ reg: float = 1e-2,
44
+ line_search_steps: tuple[float, ...] = (1.0, 0.5, 0.25, 0.1, 0.0),
45
+ **kw: Any,
46
+ ) -> None:
47
+ self.horizon = int(horizon)
48
+ self.iterations = int(iterations)
49
+ self.fd_eps = float(fd_eps)
50
+ self.reg = float(reg)
51
+ self.line_search_steps = line_search_steps
52
+ super().__init__(env, **kw)
53
+
54
+ def _setup(self) -> None:
55
+ self.nu = self.action_dim
56
+ state0 = get_state(self.env)
57
+ self.nx = int(np.asarray(state0).size)
58
+ self._u_nom = np.zeros((self.horizon, self.nu))
59
+
60
+ def reset(self) -> None:
61
+ self._u_nom[:] = 0.0
62
+
63
+ # ------------------------------------------------------------------
64
+ # Finite-difference probes on the branchable env
65
+ # ------------------------------------------------------------------
66
+ def _probe(self, x: np.ndarray, u: np.ndarray) -> tuple[np.ndarray, float]:
67
+ """One step from (x, u): returns (next_state, reward)."""
68
+ set_state(self.env, x)
69
+ u = self._clip_action(u)
70
+ _, r, _, _, _ = self.env.step(u)
71
+ return np.asarray(get_state(self.env), dtype=float), float(r)
72
+
73
+ def _linearize(self, x: np.ndarray, u: np.ndarray):
74
+ """Central-difference A = df/dx, B = df/du, and cost gradient l_x, l_u
75
+ (l = -reward), plus a diagonal Gauss-Newton approximation of l_xx/l_uu.
76
+ """
77
+ eps = self.fd_eps
78
+ nx, nu = self.nx, self.nu
79
+ A = np.zeros((nx, nx))
80
+ B = np.zeros((nx, nu))
81
+ l_x = np.zeros(nx)
82
+ l_u = np.zeros(nu)
83
+ l_xx = np.zeros(nx)
84
+ l_uu = np.zeros(nu)
85
+
86
+ x0, r0 = self._probe(x, u)
87
+
88
+ for i in range(nx):
89
+ dx = np.zeros(nx); dx[i] = eps
90
+ xp, rp = self._probe(x + dx, u)
91
+ xm, rm = self._probe(x - dx, u)
92
+ A[:, i] = (xp - xm) / (2 * eps)
93
+ l_x[i] = -(rp - rm) / (2 * eps)
94
+ l_xx[i] = -(rp - 2 * r0 + rm) / (eps ** 2)
95
+
96
+ for j in range(nu):
97
+ du = np.zeros(nu); du[j] = eps
98
+ xp, rp = self._probe(x, u + du)
99
+ xm, rm = self._probe(x, u - du)
100
+ B[:, j] = (xp - xm) / (2 * eps)
101
+ l_u[j] = -(rp - rm) / (2 * eps)
102
+ l_uu[j] = -(rp - 2 * r0 + rm) / (eps ** 2)
103
+
104
+ set_state(self.env, x) # leave env as found
105
+ return A, B, l_x, l_u, np.diag(np.maximum(l_xx, 0.0)), np.diag(np.maximum(l_uu, 0.0) + self.reg)
106
+
107
+ # ------------------------------------------------------------------
108
+ def _rollout(self, state0: np.ndarray, u_seq: np.ndarray):
109
+ xs = [state0.copy()]
110
+ total_cost = 0.0
111
+ x = state0.copy()
112
+ for t in range(self.horizon):
113
+ set_state(self.env, x)
114
+ u = self._clip_action(u_seq[t])
115
+ _, r, term, trunc, _ = self.env.step(u)
116
+ x = np.asarray(get_state(self.env), dtype=float)
117
+ xs.append(x.copy())
118
+ total_cost += -float(r)
119
+ if term or trunc:
120
+ break
121
+ set_state(self.env, state0)
122
+ return xs, total_cost
123
+
124
+ def _backward_pass(self, xs: list[np.ndarray], u_seq: np.ndarray):
125
+ H = len(u_seq)
126
+ V_x = np.zeros(self.nx)
127
+ V_xx = np.zeros((self.nx, self.nx))
128
+ K = [None] * H
129
+ k = [None] * H
130
+ for t in reversed(range(H)):
131
+ A, B, l_x, l_u, l_xx, l_uu = self._linearize(xs[t], u_seq[t])
132
+ Q_x = l_x + A.T @ V_x
133
+ Q_u = l_u + B.T @ V_x
134
+ Q_xx = l_xx + A.T @ V_xx @ A
135
+ Q_uu = l_uu + B.T @ V_xx @ B
136
+ Q_ux = B.T @ V_xx @ A
137
+
138
+ try:
139
+ Q_uu_inv = np.linalg.inv(Q_uu)
140
+ except np.linalg.LinAlgError:
141
+ Q_uu_inv = np.linalg.pinv(Q_uu)
142
+
143
+ K[t] = -Q_uu_inv @ Q_ux
144
+ k[t] = -Q_uu_inv @ Q_u
145
+
146
+ V_x = Q_x + K[t].T @ Q_uu @ k[t] + K[t].T @ Q_u + Q_ux.T @ k[t]
147
+ V_xx = Q_xx + K[t].T @ Q_uu @ K[t] + K[t].T @ Q_ux + Q_ux.T @ K[t]
148
+ V_xx = 0.5 * (V_xx + V_xx.T)
149
+ return K, k
150
+
151
+ def predict(self, obs, state=None, deterministic: bool = True):
152
+ state0 = get_state(self.env)
153
+ u_seq = self._u_nom.copy()
154
+ xs, best_cost = self._rollout(state0, u_seq)
155
+
156
+ for _ in range(self.iterations):
157
+ K, k = self._backward_pass(xs[: len(u_seq)], u_seq)
158
+ improved = False
159
+ for alpha in self.line_search_steps:
160
+ if alpha == 0.0:
161
+ break # no-op fallback: keep current nominal
162
+ u_try = np.zeros_like(u_seq)
163
+ x = state0.copy()
164
+ for t in range(len(u_seq)):
165
+ u_try[t] = self._clip_action(
166
+ u_seq[t] + alpha * k[t] + K[t] @ (x - xs[t])
167
+ )
168
+ x = self._probe(x, u_try[t])[0]
169
+ xs_try, cost_try = self._rollout(state0, u_try)
170
+ if cost_try < best_cost:
171
+ u_seq, xs, best_cost = u_try, xs_try, cost_try
172
+ improved = True
173
+ break
174
+ if not improved:
175
+ break
176
+
177
+ self._u_nom = u_seq
178
+ action = self._clip_action(self._u_nom[0])
179
+ # Receding horizon: shift the plan forward one step.
180
+ self._u_nom = np.roll(self._u_nom, -1, axis=0)
181
+ self._u_nom[-1] = 0.0
182
+ return action, None
@@ -0,0 +1,265 @@
1
+ """Sampling-based model-predictive control: MPPI, CEM, and ICEM.
2
+
3
+ All plan by rolling *candidate action sequences* forward through the env and
4
+ scoring them with the env's **own reward** — so they are fully simulator- and
5
+ reward-agnostic. Rollouts branch from the current state via the env's
6
+ ``get_state``/``set_state`` capability (see :mod:`.base`).
7
+
8
+ Noise: white by default (``noise_beta=0``, the original formulations); set
9
+ ``noise_beta`` in (0, 1) for correlated ("colored") noise across the horizon,
10
+ which smooths the resulting action sequences ("Smooth-MPPI"). ``ICEM``
11
+ defaults to colored noise plus elite memory across iterations — see its
12
+ docstring.
13
+
14
+ Parallelism / GPU: rollouts are embarrassingly parallel. If you pass
15
+ ``vector_env_fn`` (a callable returning a Gymnasium ``VectorEnv``), candidates
16
+ are evaluated as a batch — so a GPU-batched simulator env parallelises the
17
+ planner with no code change here. Otherwise a single env is stepped in a loop.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Any, Callable, Optional
23
+
24
+ import numpy as np
25
+
26
+ from .base import BaseController, ControllerManifest, get_state, register, set_state
27
+
28
+
29
+ class _SamplingMPC(BaseController):
30
+ """Shared machinery for MPPI/CEM: sample sequences, roll out, update."""
31
+
32
+ manifest = ControllerManifest(
33
+ name="sampling_mpc", family="sampling_mpc",
34
+ requires_branching=True, uses_reward=True, gpu_capable=True,
35
+ )
36
+
37
+ def __init__(
38
+ self,
39
+ env: Any,
40
+ horizon: int = 20,
41
+ n_samples: int = 100,
42
+ noise_sigma: float = 1.0,
43
+ noise_beta: float = 0.0,
44
+ gamma: float = 1.0,
45
+ vector_env_fn: Optional[Callable[[int], Any]] = None,
46
+ **kw: Any,
47
+ ) -> None:
48
+ self.horizon = int(horizon)
49
+ self.n_samples = int(n_samples)
50
+ self.noise_sigma = float(noise_sigma)
51
+ self.noise_beta = float(noise_beta)
52
+ self.gamma = float(gamma)
53
+ self._vector_env_fn = vector_env_fn
54
+ super().__init__(env, **kw)
55
+
56
+ def _setup(self) -> None:
57
+ self.nu = self.action_dim
58
+ self._nominal = np.zeros((self.horizon, self.nu))
59
+ self._venv = None
60
+ if self._vector_env_fn is not None:
61
+ self._venv = self._vector_env_fn(self.n_samples)
62
+
63
+ def reset(self) -> None:
64
+ self._nominal[:] = 0.0
65
+
66
+ # --- noise -------------------------------------------------------------
67
+ def _sample_noise(self, n_samples: int) -> np.ndarray:
68
+ """Standard-normal noise, shape (n_samples, horizon, nu).
69
+
70
+ ``noise_beta = 0`` (default) gives i.i.d. white noise per timestep —
71
+ vanilla MPPI/CEM. ``noise_beta`` in (0, 1) generates *colored* noise
72
+ via a first-order AR filter (unit variance preserved), correlating
73
+ noise across the horizon so sampled action sequences — and the
74
+ resulting torques — are smooth instead of jittery high-frequency
75
+ chatter ("Smooth-MPPI", e.g. Vlahov et al.). Higher beta = smoother.
76
+ """
77
+ shape = (n_samples, self.horizon, self.nu)
78
+ if self.noise_beta <= 0:
79
+ return self.rng.normal(0.0, 1.0, shape)
80
+ beta = self.noise_beta
81
+ scale = np.sqrt(1.0 - beta ** 2)
82
+ noise = np.empty(shape)
83
+ noise[:, 0, :] = self.rng.normal(0.0, 1.0, (n_samples, self.nu))
84
+ for t in range(1, self.horizon):
85
+ noise[:, t, :] = beta * noise[:, t - 1, :] + scale * self.rng.normal(
86
+ 0.0, 1.0, (n_samples, self.nu)
87
+ )
88
+ return noise
89
+
90
+ # --- rollouts --------------------------------------------------------
91
+ def _returns(self, state0: Any, seqs: np.ndarray) -> np.ndarray:
92
+ """Discounted return of each (K, H, nu) action sequence from ``state0``."""
93
+ if self._venv is not None:
94
+ return self._returns_vectorized(state0, seqs)
95
+ return self._returns_serial(state0, seqs)
96
+
97
+ def _returns_serial(self, state0: Any, seqs: np.ndarray) -> np.ndarray:
98
+ K = seqs.shape[0]
99
+ R = np.zeros(K)
100
+ for k in range(K):
101
+ set_state(self.env, state0)
102
+ disc = 1.0
103
+ for h in range(self.horizon):
104
+ _, r, term, trunc, _ = self.env.step(seqs[k, h])
105
+ R[k] += disc * r
106
+ disc *= self.gamma
107
+ if term or trunc:
108
+ break
109
+ set_state(self.env, state0) # leave the real env as we found it
110
+ return R
111
+
112
+ def _returns_vectorized(self, state0: Any, seqs: np.ndarray) -> np.ndarray:
113
+ venv = self._venv
114
+ venv.reset()
115
+ # Broadcast the current state to every worker, if supported.
116
+ if hasattr(venv, "set_state"):
117
+ venv.set_state([state0] * self.n_samples)
118
+ else: # per-worker set via the standard call_* API
119
+ venv.call("set_state", state0)
120
+ R = np.zeros(self.n_samples)
121
+ disc = 1.0
122
+ done = np.zeros(self.n_samples, dtype=bool)
123
+ for h in range(self.horizon):
124
+ _, r, term, trunc, _ = venv.step(seqs[:, h])
125
+ live = ~done
126
+ R[live] += disc * np.asarray(r)[live]
127
+ disc *= self.gamma
128
+ done |= np.asarray(term) | np.asarray(trunc)
129
+ if done.all():
130
+ break
131
+ return R
132
+
133
+ # --- update rule (subclass) -----------------------------------------
134
+ def _update(self, seqs: np.ndarray, returns: np.ndarray) -> None:
135
+ raise NotImplementedError
136
+
137
+ def predict(self, obs, state=None, deterministic: bool = True):
138
+ state0 = get_state(self.env)
139
+ noise = self._sample_noise(self.n_samples) * self.noise_sigma
140
+ seqs = self._clip_seq(self._nominal[None] + noise)
141
+ returns = self._returns(state0, seqs)
142
+ set_state(self.env, state0)
143
+ self._update(seqs, returns)
144
+
145
+ action = self._clip_action(self._nominal[0])
146
+ # Receding horizon: shift the plan forward one step.
147
+ self._nominal = np.roll(self._nominal, -1, axis=0)
148
+ self._nominal[-1] = 0.0
149
+ return action, None
150
+
151
+ def _clip_seq(self, seqs: np.ndarray) -> np.ndarray:
152
+ sp = self.action_space
153
+ if sp is not None and getattr(sp, "low", None) is not None:
154
+ return np.clip(seqs, sp.low, sp.high)
155
+ return seqs
156
+
157
+
158
+ @register("mppi")
159
+ class MPPI(_SamplingMPC):
160
+ """Model-Predictive Path Integral control (softmax-weighted update)."""
161
+
162
+ def __init__(self, env: Any, temperature: float = 1.0, **kw: Any) -> None:
163
+ self.temperature = float(temperature)
164
+ super().__init__(env, **kw)
165
+ self.manifest = ControllerManifest(
166
+ name="mppi", family="sampling_mpc",
167
+ requires_branching=True, uses_reward=True, gpu_capable=True,
168
+ )
169
+
170
+ def _update(self, seqs: np.ndarray, returns: np.ndarray) -> None:
171
+ adv = returns - returns.max() # stabilise the softmax
172
+ w = np.exp(adv / max(self.temperature, 1e-8))
173
+ w = w / (w.sum() + 1e-12)
174
+ self._nominal = np.einsum("k,khu->hu", w, seqs)
175
+
176
+
177
+ @register("cem")
178
+ class CEM(_SamplingMPC):
179
+ """Cross-Entropy Method MPC (refit a Gaussian to the elite sequences)."""
180
+
181
+ def __init__(self, env: Any, elite_frac: float = 0.1, iterations: int = 3, **kw: Any) -> None:
182
+ self.elite_frac = float(elite_frac)
183
+ self.iterations = int(iterations)
184
+ super().__init__(env, **kw)
185
+ self.manifest = ControllerManifest(
186
+ name="cem", family="sampling_mpc",
187
+ requires_branching=True, uses_reward=True, gpu_capable=True,
188
+ )
189
+
190
+ def predict(self, obs, state=None, deterministic: bool = True):
191
+ # CEM refines the distribution over several iterations before acting.
192
+ state0 = get_state(self.env)
193
+ mean = self._nominal.copy()
194
+ sigma = np.full((self.horizon, self.nu), self.noise_sigma)
195
+ n_elite = max(1, int(self.elite_frac * self.n_samples))
196
+ for _ in range(self.iterations):
197
+ noise = self._sample_noise(self.n_samples)
198
+ seqs = self._clip_seq(mean[None] + sigma[None] * noise)
199
+ returns = self._returns(state0, seqs)
200
+ set_state(self.env, state0)
201
+ elite = seqs[np.argsort(returns)[-n_elite:]]
202
+ mean = elite.mean(axis=0)
203
+ sigma = elite.std(axis=0) + 1e-6
204
+ self._nominal = mean
205
+ action = self._clip_action(self._nominal[0])
206
+ self._nominal = np.roll(self._nominal, -1, axis=0)
207
+ self._nominal[-1] = 0.0
208
+ return action, None
209
+
210
+ def _update(self, seqs, returns): # unused (predict is overridden)
211
+ pass
212
+
213
+
214
+ @register("icem")
215
+ class ICEM(CEM):
216
+ """Improved CEM (Pinneri et al. 2020): colored noise + elite memory.
217
+
218
+ Two changes over vanilla CEM for the same sample budget:
219
+
220
+ 1. **Colored noise by default** — correlated across the horizon (see
221
+ :meth:`_SamplingMPC._sample_noise`), giving smoother action sequences
222
+ than i.i.d. Gaussian sampling.
223
+ 2. **Elite memory** — the single best sequence found so far *this
224
+ planning step* is always injected back into the next iteration's
225
+ population instead of being discarded when the distribution is
226
+ refit. Vanilla CEM only keeps summary statistics (mean/std) of the
227
+ elites, so a lucky early sample can be "forgotten"; here it can't be.
228
+ """
229
+
230
+ def __init__(self, env: Any, noise_beta: float = 0.2, **kw: Any) -> None:
231
+ kw.setdefault("noise_beta", noise_beta)
232
+ super().__init__(env, **kw)
233
+ self.manifest = ControllerManifest(
234
+ name="icem", family="sampling_mpc",
235
+ requires_branching=True, uses_reward=True, gpu_capable=True,
236
+ )
237
+
238
+ def predict(self, obs, state=None, deterministic: bool = True):
239
+ state0 = get_state(self.env)
240
+ mean = self._nominal.copy()
241
+ sigma = np.full((self.horizon, self.nu), self.noise_sigma)
242
+ n_elite = max(1, int(self.elite_frac * self.n_samples))
243
+ best_seq, best_return = None, -np.inf
244
+
245
+ for _ in range(self.iterations):
246
+ noise = self._sample_noise(self.n_samples)
247
+ seqs = self._clip_seq(mean[None] + sigma[None] * noise)
248
+ if best_seq is not None:
249
+ seqs[0] = best_seq # guarantee the best-so-far is re-evaluated & eligible
250
+ returns = self._returns(state0, seqs)
251
+ set_state(self.env, state0)
252
+
253
+ order = np.argsort(returns)
254
+ elite = seqs[order[-n_elite:]]
255
+ if returns[order[-1]] > best_return:
256
+ best_return = returns[order[-1]]
257
+ best_seq = seqs[order[-1]].copy()
258
+ mean = elite.mean(axis=0)
259
+ sigma = elite.std(axis=0) + 1e-6
260
+
261
+ self._nominal = best_seq if best_seq is not None else mean
262
+ action = self._clip_action(self._nominal[0])
263
+ self._nominal = np.roll(self._nominal, -1, axis=0)
264
+ self._nominal[-1] = 0.0
265
+ return action, None