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.
- tau_ctrl/__init__.py +75 -0
- tau_ctrl/algorithms/__init__.py +91 -0
- tau_ctrl/algorithms/adapters.py +150 -0
- tau_ctrl/algorithms/base.py +207 -0
- tau_ctrl/algorithms/cbf.py +98 -0
- tau_ctrl/algorithms/envs/__init__.py +6 -0
- tau_ctrl/algorithms/envs/toy.py +109 -0
- tau_ctrl/algorithms/ilqr.py +182 -0
- tau_ctrl/algorithms/mppi.py +265 -0
- tau_ctrl/algorithms/off_policy.py +156 -0
- tau_ctrl/algorithms/pid.py +70 -0
- tau_ctrl/algorithms/ppo.py +205 -0
- tau_ctrl/algorithms/sac.py +236 -0
- tau_ctrl/algorithms/strategy.py +303 -0
- tau_ctrl/algorithms/td3.py +209 -0
- tau_ctrl/algorithms/vec_env.py +172 -0
- tau_ctrl/py.typed +0 -0
- tau_ctrl/tuning/__init__.py +7 -0
- tau_ctrl/tuning/auto_tuner.py +112 -0
- tau_ctrl/tuning/bayesian_opt.py +107 -0
- tau_ctrl/tuning/genetic_opt.py +119 -0
- tau_ctrl-0.1.0.dist-info/METADATA +154 -0
- tau_ctrl-0.1.0.dist-info/RECORD +26 -0
- tau_ctrl-0.1.0.dist-info/WHEEL +5 -0
- tau_ctrl-0.1.0.dist-info/licenses/LICENSE +201 -0
- tau_ctrl-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|