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 ADDED
@@ -0,0 +1,75 @@
1
+ """
2
+ tau-ctrl — an SB3-like, simulator-agnostic controller/algorithm framework.
3
+
4
+ Call algorithms directly off the package, the way you would with SB3:
5
+
6
+ from tau_ctrl import make, PID, MPPI, CEM, ICEM, ILQR, CBFFilter, PPO, SAC, TD3
7
+
8
+ ctrl = make("mppi", env, horizon=25, n_samples=200) # or PID(env), PPO(env), ...
9
+ action, _ = ctrl.predict(obs) # SB3-style
10
+ ctrl.learn(total_timesteps=100_000) # trainable methods (ppo/sac/td3)
11
+
12
+ Every algorithm shares one interface (``predict``/``learn``/``save``/``load``)
13
+ over a plain ``gymnasium.Env`` — feedback (PID), sampling-based MPC (MPPI,
14
+ CEM, ICEM), gradient-based MPC (ILQR), a safety filter (CBF), on-policy RL
15
+ (PPO), and off-policy RL (SAC, TD3 — replay-buffer based, far more
16
+ sample-efficient than PPO for continuous control).
17
+
18
+ Model-based methods (MPPI/CEM/ICEM/ILQR/CBF) need the env to be *branchable*
19
+ — to expose ``get_state()``/``set_state()``. Feedback and RL work on any gym
20
+ env. :mod:`tau_ctrl.tuning` adds Bayesian/genetic auto-tuning of any gains.
21
+ """
22
+
23
+ __version__ = "0.1.0"
24
+
25
+ from .algorithms import (
26
+ CBFFilter,
27
+ CEM,
28
+ ICEM,
29
+ ILQR,
30
+ MPPI,
31
+ PID,
32
+ PPO,
33
+ SAC,
34
+ TD3,
35
+ BaseController,
36
+ ControllerManifest,
37
+ Trainer,
38
+ available,
39
+ get_state,
40
+ is_branchable,
41
+ make,
42
+ probe_env,
43
+ probe_hardware,
44
+ register,
45
+ resolve_device,
46
+ set_state,
47
+ )
48
+ from .tuning import AutoTuner
49
+
50
+ __all__ = [
51
+ "make",
52
+ "register",
53
+ "available",
54
+ "is_branchable",
55
+ "get_state",
56
+ "set_state",
57
+ "resolve_device",
58
+ "BaseController",
59
+ "ControllerManifest",
60
+ "PID",
61
+ "MPPI",
62
+ "CEM",
63
+ "ICEM",
64
+ "ILQR",
65
+ "CBFFilter",
66
+ "PPO",
67
+ "SAC",
68
+ "TD3",
69
+ "Trainer",
70
+ "probe_env",
71
+ "probe_hardware",
72
+ "AutoTuner",
73
+ "algorithms",
74
+ "tuning",
75
+ ]
@@ -0,0 +1,91 @@
1
+ """tau-ctrl's SB3-like, simulator-agnostic controller/algorithm framework.
2
+
3
+ One interface for the whole controller spectrum over a Gymnasium env, called
4
+ the way you'd call SB3:
5
+
6
+ from tau_ctrl import make, PID, MPPI, CEM, ICEM, ILQR, CBFFilter, PPO, SAC, TD3
7
+
8
+ ctrl = make("mppi", env, horizon=25, n_samples=200) # or PID(env), PPO(env), ...
9
+ action, _ = ctrl.predict(obs) # SB3-style
10
+ ctrl.learn(total_timesteps=100_000) # for trainable ones (ppo/sac/td3)
11
+
12
+ Design goals:
13
+ - **Simulator-agnostic:** talks only to the gym API (reset/step/spaces). No
14
+ MuJoCo/MJX assumptions — works with whatever simulator hands it an env.
15
+ - **GPU-optional:** torch device is auto-selected for learned methods; sampling
16
+ planners parallelise across a vectorized env when one is supplied.
17
+ - **Unified:** feedback (PID), sampling-MPC (MPPI/CEM/ICEM), gradient-based
18
+ MPC (ILQR), safety (CBF), and RL (PPO on-policy; SAC/TD3 off-policy) all
19
+ share ``predict``/``learn``/``save``/``load``.
20
+
21
+ Model-based methods (MPPI/CEM/ICEM/ILQR/CBF) need the env to be *branchable*
22
+ — to expose ``get_state()``/``set_state()``. Feedback and RL work on any gym
23
+ env.
24
+ """
25
+
26
+ from .base import (
27
+ BaseController,
28
+ BranchingNotSupported,
29
+ ControllerManifest,
30
+ available,
31
+ get_state,
32
+ is_branchable,
33
+ make,
34
+ register,
35
+ resolve_device,
36
+ set_state,
37
+ )
38
+ from .cbf import CBFFilter
39
+ from .ilqr import ILQR
40
+ from .mppi import CEM, ICEM, MPPI
41
+ from .pid import PID
42
+ from .ppo import PPO
43
+ from .sac import SAC
44
+ from .adapters import GymVectorAdapter, SyncTorchVecEnv, jax_to_torch
45
+ from .strategy import (
46
+ EnvCaps,
47
+ HardwareCaps,
48
+ Plan,
49
+ Strategy,
50
+ Trainer,
51
+ probe_env,
52
+ probe_hardware,
53
+ select_strategy,
54
+ )
55
+ from .td3 import TD3
56
+ from .vec_env import TorchPendulum, TorchVecEnv
57
+
58
+ __all__ = [
59
+ "BaseController",
60
+ "ControllerManifest",
61
+ "make",
62
+ "register",
63
+ "available",
64
+ "is_branchable",
65
+ "get_state",
66
+ "set_state",
67
+ "BranchingNotSupported",
68
+ "resolve_device",
69
+ "PID",
70
+ "MPPI",
71
+ "CEM",
72
+ "ICEM",
73
+ "ILQR",
74
+ "CBFFilter",
75
+ "PPO",
76
+ "SAC",
77
+ "TD3",
78
+ "TorchVecEnv",
79
+ "TorchPendulum",
80
+ "GymVectorAdapter",
81
+ "SyncTorchVecEnv",
82
+ "jax_to_torch",
83
+ "Trainer",
84
+ "Strategy",
85
+ "Plan",
86
+ "EnvCaps",
87
+ "HardwareCaps",
88
+ "probe_env",
89
+ "probe_hardware",
90
+ "select_strategy",
91
+ ]
@@ -0,0 +1,150 @@
1
+ """Adapters: make *whatever env you actually have* speak the TorchVecEnv
2
+ contract, so the same SAC/TD3 vectorized loop runs unchanged.
3
+
4
+ You rarely own the environment. The batched, tensor-native
5
+ :class:`~tau_ctrl.algorithms.vec_env.TorchVecEnv` is the ideal, but real
6
+ projects hand you one of three things, and tau-ctrl adapts to all three behind
7
+ the same trainer (the loop only needs ``num_envs`` + ``reset`` + ``step`` →
8
+ ``(next_obs, reward, done, start_obs)``):
9
+
10
+ 1. **Already batched & on-device** — MJX / Brax (JAX) or Isaac Gym (torch).
11
+ Wrap with :func:`jax_to_torch` (zero-copy via dlpack) or, for a torch-native
12
+ batched env, nothing at all — it already fits the contract.
13
+
14
+ 2. **Batched but numpy/CPU** — a ``gymnasium.vector.VectorEnv``.
15
+ :class:`GymVectorAdapter` moves each step's batch to the device with one
16
+ copy. Collection stays CPU-bound, but the replay buffer and gradient update
17
+ are batched and on-device.
18
+
19
+ 3. **A single, non-batchable env** — PyBullet, classic MuJoCo, a real robot.
20
+ You can't turn its dynamics into tensor ops, but you can run ``N`` copies.
21
+ :class:`SyncTorchVecEnv` steps them in a Python loop and presents the batch
22
+ on-device. No GPU *env* parallelism (the env is the bottleneck), but the
23
+ exact same trainer runs and the update still saturates the GPU.
24
+
25
+ If you truly have one env and can't make copies (a single real robot), just use
26
+ the ordinary single-env ``learn()`` path — there the GPU only accelerates the
27
+ update, which is honest: you can't parallelize physics you don't control.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from typing import Any, Callable
33
+
34
+ import numpy as np
35
+
36
+ from .vec_env import TorchVecEnv
37
+
38
+
39
+ def jax_to_torch(x, device: str):
40
+ """Zero-copy JAX array → torch tensor via dlpack (for MJX / Brax on GPU).
41
+
42
+ Both frameworks expose ``__dlpack__``; the buffer is shared, so no host
43
+ round-trip and no extra device allocation. Use this inside a thin
44
+ ``TorchVecEnv`` subclass that drives an MJX/Brax ``step`` and hands the
45
+ resulting device arrays straight to the trainer.
46
+ """
47
+ import torch # noqa: PLC0415
48
+
49
+ t = torch.from_dlpack(x)
50
+ return t.to(device) if str(t.device) != str(device) else t
51
+
52
+
53
+ class GymVectorAdapter(TorchVecEnv):
54
+ """Wrap a ``gymnasium.vector.VectorEnv`` (numpy batches) as a TorchVecEnv.
55
+
56
+ Each ``step`` moves the batch to the device with a single H→D copy. The env
57
+ still runs on CPU (that's inherent to gym's vector API), but the buffer and
58
+ update are batched on-device — the same win as a native ``TorchVecEnv``
59
+ minus the on-device env stepping.
60
+
61
+ Handles gym autoreset: on the step where an env is done, gym returns the
62
+ *reset* observation and stashes the true terminal obs in
63
+ ``info["final_observation"]``. We store the terminal obs as ``next_obs``
64
+ (correct bootstrap target) and carry the reset obs as ``start_obs``.
65
+ """
66
+
67
+ def __init__(self, venv: Any, device: str = "cpu") -> None:
68
+ super().__init__(getattr(venv, "num_envs"), device)
69
+ self.venv = venv
70
+ # gym VectorEnv exposes single_* spaces describing one env.
71
+ self.observation_space = getattr(venv, "single_observation_space", None) or venv.observation_space
72
+ self.action_space = getattr(venv, "single_action_space", None) or venv.action_space
73
+
74
+ def _to_t(self, arr):
75
+ t = self._torch
76
+ return t.as_tensor(np.asarray(arr, dtype=np.float32), device=self.device)
77
+
78
+ def reset(self):
79
+ obs, _ = self.venv.reset()
80
+ return self._to_t(obs)
81
+
82
+ def step(self, actions):
83
+ t = self._torch
84
+ a = actions.detach().cpu().numpy()
85
+ obs, reward, terminated, truncated, info = self.venv.step(a)
86
+ done = np.asarray(terminated, dtype=np.float32)
87
+ start_obs = np.asarray(obs, dtype=np.float32).copy()
88
+ next_obs = start_obs.copy() # true post-dynamics obs
89
+
90
+ # Recover terminal observations for envs that just reset.
91
+ finals = info.get("final_observation") if isinstance(info, dict) else None
92
+ if finals is not None:
93
+ for i, fo in enumerate(finals):
94
+ if fo is not None:
95
+ next_obs[i] = np.asarray(fo, dtype=np.float32)
96
+ return (
97
+ self._to_t(next_obs),
98
+ self._to_t(reward),
99
+ t.as_tensor(done, device=self.device),
100
+ self._to_t(start_obs),
101
+ )
102
+
103
+
104
+ class SyncTorchVecEnv(TorchVecEnv):
105
+ """Run ``N`` copies of a single (non-batchable) gym env as one on-device batch.
106
+
107
+ For PyBullet, classic MuJoCo, or any env whose dynamics you can't rewrite as
108
+ tensor ops but can instantiate ``N`` times. The envs step in a plain Python
109
+ loop on CPU — so you get no GPU env parallelism — but the same vectorized
110
+ SAC/TD3 loop runs unchanged and the replay buffer + gradient update stay
111
+ batched on-device.
112
+
113
+ ``env_fns`` is a list of zero-arg factories (one per env), matching the SB3
114
+ / gym.vector convention so seeds/configs can differ per env.
115
+ """
116
+
117
+ def __init__(self, env_fns: list[Callable[[], Any]], device: str = "cpu") -> None:
118
+ self.envs = [fn() for fn in env_fns]
119
+ super().__init__(len(self.envs), device)
120
+ self.observation_space = self.envs[0].observation_space
121
+ self.action_space = self.envs[0].action_space
122
+
123
+ def reset(self):
124
+ obs = np.stack([np.asarray(e.reset()[0], dtype=np.float32) for e in self.envs])
125
+ return self._torch.as_tensor(obs, device=self.device)
126
+
127
+ def step(self, actions):
128
+ t = self._torch
129
+ a = actions.detach().cpu().numpy()
130
+ n = self.num_envs
131
+ next_obs = np.empty((n, *self.observation_space.shape), dtype=np.float32)
132
+ start_obs = np.empty_like(next_obs)
133
+ reward = np.empty(n, dtype=np.float32)
134
+ done = np.zeros(n, dtype=np.float32)
135
+ for i, e in enumerate(self.envs):
136
+ o, r, term, trunc, _ = e.step(a[i])
137
+ next_obs[i] = np.asarray(o, dtype=np.float32)
138
+ reward[i] = r
139
+ done[i] = float(term) # truncation resets but doesn't break bootstrap
140
+ if term or trunc:
141
+ o2, _ = e.reset()
142
+ start_obs[i] = np.asarray(o2, dtype=np.float32)
143
+ else:
144
+ start_obs[i] = next_obs[i]
145
+ return (
146
+ t.as_tensor(next_obs, device=self.device),
147
+ t.as_tensor(reward, device=self.device),
148
+ t.as_tensor(done, device=self.device),
149
+ t.as_tensor(start_obs, device=self.device),
150
+ )
@@ -0,0 +1,207 @@
1
+ """Core abstractions for tau-ctrl's algorithm framework — an SB3-like,
2
+ simulator-agnostic controller framework over a Gymnasium environment.
3
+
4
+ Every method (PID, MPPI, CEM-MPC, CBF safety filter, PPO, ...) implements the
5
+ same :class:`BaseController` interface — ``predict(obs) -> action`` like SB3,
6
+ plus an optional ``learn()`` for the ones that train. Nothing here imports a
7
+ simulator: controllers talk only to the ``gymnasium.Env`` API. Methods that
8
+ need to roll the dynamics forward (MPPI, CEM, CBF) do so through the optional
9
+ *branching* capability (:func:`get_state` / :func:`set_state`), which any env
10
+ can provide; nothing is MuJoCo- or MJX-specific.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import pickle
16
+ from abc import ABC, abstractmethod
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any, Optional
20
+
21
+ import numpy as np
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Registry
25
+ # ---------------------------------------------------------------------------
26
+
27
+ _REGISTRY: dict[str, type["BaseController"]] = {}
28
+
29
+
30
+ def register(name: str):
31
+ def deco(cls):
32
+ cls.name = name
33
+ _REGISTRY[name] = cls
34
+ return cls
35
+
36
+ return deco
37
+
38
+
39
+ def make(name: str, env: Any, **kwargs: Any) -> "BaseController":
40
+ """Construct a controller by name, SB3-style: ``make("mppi", env, ...)``."""
41
+ if name not in _REGISTRY:
42
+ raise KeyError(f"unknown controller {name!r}; available: {sorted(_REGISTRY)}")
43
+ return _REGISTRY[name](env, **kwargs)
44
+
45
+
46
+ def available() -> list[str]:
47
+ return sorted(_REGISTRY)
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Manifest
52
+ # ---------------------------------------------------------------------------
53
+
54
+ @dataclass
55
+ class ControllerManifest:
56
+ name: str
57
+ family: str # "feedback" | "sampling_mpc" | "safety" | "rl"
58
+ requires_branching: bool = False # needs get_state/set_state for rollouts
59
+ uses_reward: bool = False # uses the env's reward as its objective
60
+ trainable: bool = False # has a meaningful learn()
61
+ gpu_capable: bool = False
62
+ extra: dict = field(default_factory=dict)
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Branching capability (simulator-agnostic)
67
+ # ---------------------------------------------------------------------------
68
+
69
+ class BranchingNotSupported(RuntimeError):
70
+ pass
71
+
72
+
73
+ def get_state(env: Any) -> Any:
74
+ """Snapshot the env's dynamical state so a rollout can branch from it.
75
+
76
+ Resolution order (no simulator assumptions): an explicit ``get_state`` on
77
+ the (unwrapped) env, else classic-control ``.state``. Simulators that want
78
+ model-based control just expose ``get_state``/``set_state``.
79
+ """
80
+ u = getattr(env, "unwrapped", env)
81
+ if hasattr(u, "get_state"):
82
+ return u.get_state()
83
+ if hasattr(u, "state") and u.state is not None:
84
+ return np.array(u.state, dtype=float)
85
+ raise BranchingNotSupported(
86
+ f"{type(u).__name__} exposes no get_state()/.state; model-based methods "
87
+ "(MPPI/CEM/CBF) need a branchable env."
88
+ )
89
+
90
+
91
+ def set_state(env: Any, state: Any) -> None:
92
+ u = getattr(env, "unwrapped", env)
93
+ if hasattr(u, "set_state"):
94
+ u.set_state(state)
95
+ return
96
+ if hasattr(u, "state"):
97
+ u.state = np.array(state, dtype=float)
98
+ return
99
+ raise BranchingNotSupported(f"{type(u).__name__} exposes no set_state().")
100
+
101
+
102
+ def is_branchable(env: Any) -> bool:
103
+ try:
104
+ get_state(env)
105
+ return True
106
+ except BranchingNotSupported:
107
+ return False
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # Device (only relevant for the learned/torch methods)
112
+ # ---------------------------------------------------------------------------
113
+
114
+ def resolve_device(pref: str = "auto") -> str:
115
+ if pref not in ("auto", "cuda", "cpu"):
116
+ return pref
117
+ if pref == "cpu":
118
+ return "cpu"
119
+ try:
120
+ import torch
121
+
122
+ if torch.cuda.is_available():
123
+ return "cuda"
124
+ except Exception:
125
+ pass
126
+ return "cpu"
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # Base controller
131
+ # ---------------------------------------------------------------------------
132
+
133
+ class BaseController(ABC):
134
+ """SB3-like controller: ``predict`` always; ``learn`` when trainable."""
135
+
136
+ name: str = "base"
137
+ manifest: ControllerManifest
138
+
139
+ def __init__(self, env: Any, device: str = "auto", seed: Optional[int] = None) -> None:
140
+ self.env = env
141
+ self.observation_space = getattr(env, "observation_space", None)
142
+ self.action_space = getattr(env, "action_space", None)
143
+ self.device = resolve_device(device)
144
+ self.seed = seed
145
+ self.rng = np.random.default_rng(seed)
146
+ if seed is not None:
147
+ # Seed torch's global RNG too, before _setup() builds any network —
148
+ # weight init and stochastic-policy sampling (torch.randn_like) use
149
+ # torch's own RNG, not self.rng, so this is required for
150
+ # reproducibility in any torch-based controller (PPO/SAC/TD3/...).
151
+ try:
152
+ import torch
153
+
154
+ torch.manual_seed(seed)
155
+ except ImportError:
156
+ pass
157
+ self._setup()
158
+
159
+ # Subclasses override _setup / predict; learn is optional.
160
+ def _setup(self) -> None: # noqa: B027
161
+ pass
162
+
163
+ @abstractmethod
164
+ def predict(
165
+ self, obs: Any, state: Any = None, deterministic: bool = True
166
+ ) -> tuple[np.ndarray, Any]:
167
+ """Return ``(action, next_internal_state)`` — SB3 signature."""
168
+
169
+ def learn(self, total_timesteps: int = 0, **kwargs: Any) -> "BaseController":
170
+ """Train the controller. Analytic controllers are no-ops."""
171
+ return self
172
+
173
+ def reset(self) -> None:
174
+ """Reset any internal controller state (call at episode start)."""
175
+
176
+ # --- helpers ---------------------------------------------------------
177
+ def _clip_action(self, a: np.ndarray) -> np.ndarray:
178
+ a = np.asarray(a, dtype=float)
179
+ sp = self.action_space
180
+ if sp is not None and getattr(sp, "low", None) is not None:
181
+ a = np.clip(a, sp.low, sp.high)
182
+ return a
183
+
184
+ @property
185
+ def action_dim(self) -> int:
186
+ return int(np.prod(self.action_space.shape))
187
+
188
+ # --- persistence -----------------------------------------------------
189
+ def _state_dict(self) -> dict:
190
+ """Override to persist trained parameters."""
191
+ return {}
192
+
193
+ def _load_state_dict(self, sd: dict) -> None: # noqa: B027
194
+ pass
195
+
196
+ def save(self, path: str | Path) -> None:
197
+ blob = {"name": self.name, "state": self._state_dict()}
198
+ with open(path, "wb") as f:
199
+ pickle.dump(blob, f)
200
+
201
+ @classmethod
202
+ def load(cls, path: str | Path, env: Any, **kwargs: Any) -> "BaseController":
203
+ with open(path, "rb") as f:
204
+ blob = pickle.load(f)
205
+ obj = cls(env, **kwargs)
206
+ obj._load_state_dict(blob.get("state", {}))
207
+ return obj
@@ -0,0 +1,98 @@
1
+ """Control Barrier Function safety filter (discrete-time, model-free).
2
+
3
+ Wraps any base controller and minimally edits its action so a safety margin
4
+ ``h(state) >= 0`` is maintained. It enforces the discrete-time CBF condition
5
+
6
+ h(next_state) >= (1 - alpha) * h(state), alpha in (0, 1]
7
+
8
+ Since we make no simulator assumptions, the one-step effect of an action on
9
+ ``h`` is measured by *probing* the branchable env (set_state -> step -> read
10
+ h). The barrier is linearized in the action via finite differences, giving a
11
+ single-inequality QP whose solution is a closed-form projection of the nominal
12
+ action. This keeps it fully simulator-agnostic and cheap.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any, Callable, Sequence
18
+
19
+ import numpy as np
20
+
21
+ from .base import BaseController, ControllerManifest, get_state, register, set_state
22
+
23
+ Barrier = Callable[[Any], float] # state -> margin (>= 0 is safe)
24
+
25
+
26
+ @register("cbf")
27
+ class CBFFilter(BaseController):
28
+ """Safety filter: ``u = argmin ||u - u_nom||^2 s.t. discrete-CBF holds``."""
29
+
30
+ manifest = ControllerManifest(
31
+ name="cbf", family="safety", requires_branching=True,
32
+ )
33
+
34
+ def __init__(
35
+ self,
36
+ env: Any,
37
+ base: BaseController,
38
+ barriers: Barrier | Sequence[Barrier],
39
+ alpha: float = 0.5,
40
+ fd_eps: float = 1e-3,
41
+ passes: int = 3,
42
+ **kw: Any,
43
+ ) -> None:
44
+ self.base = base
45
+ self.barriers: list[Barrier] = list(barriers) if isinstance(barriers, (list, tuple)) else [barriers]
46
+ self.alpha = float(alpha)
47
+ self.fd_eps = float(fd_eps)
48
+ self.passes = int(passes)
49
+ super().__init__(env, **kw)
50
+
51
+ def reset(self) -> None:
52
+ self.base.reset()
53
+
54
+ def _h_next(self, state0: Any, u: np.ndarray, h: Barrier) -> float:
55
+ set_state(self.env, state0)
56
+ self.env.step(u)
57
+ val = h(get_state(self.env))
58
+ return float(val)
59
+
60
+ def predict(self, obs, state=None, deterministic: bool = True):
61
+ u_nom, _ = self.base.predict(obs, state, deterministic)
62
+ u = np.array(u_nom, dtype=float).ravel()
63
+ state0 = get_state(self.env)
64
+
65
+ for _ in range(self.passes):
66
+ adjusted = False
67
+ for h in self.barriers:
68
+ h0 = float(h(state0))
69
+ margin = self._h_next(state0, u, h) - (1.0 - self.alpha) * h0
70
+ if margin >= 0:
71
+ continue
72
+ # Linearize h_next in u via finite differences: g = ∂h_next/∂u.
73
+ # Perturb *inward* per dimension so that when u sits on an
74
+ # actuator bound the probe isn't clipped away (which would
75
+ # falsely read a zero gradient).
76
+ g = np.zeros_like(u)
77
+ base_val = self._h_next(state0, u, h)
78
+ sp = self.action_space
79
+ hi = getattr(sp, "high", None)
80
+ for i in range(u.size):
81
+ step = self.fd_eps
82
+ if hi is not None and u[i] + step > hi[i]:
83
+ step = -self.fd_eps
84
+ up = u.copy()
85
+ up[i] += step
86
+ g[i] = (self._h_next(state0, up, h) - base_val) / step
87
+ denom = float(g @ g)
88
+ if denom < 1e-12:
89
+ continue # action has no first-order effect on this barrier
90
+ # Project: min ||Δ||^2 s.t. gᵀΔ + margin >= 0 -> Δ = -margin g/‖g‖²
91
+ u = u + (-margin) * g / denom
92
+ u = self._clip_action(u)
93
+ adjusted = True
94
+ if not adjusted:
95
+ break
96
+
97
+ set_state(self.env, state0) # restore the real env
98
+ return self._clip_action(u), None
@@ -0,0 +1,6 @@
1
+ """Pure-Python toy envs (no simulator dependency) for tests and examples."""
2
+
3
+ from ..vec_env import TorchPendulum, TorchVecEnv
4
+ from .toy import DoubleIntegrator, PendulumSwingUp
5
+
6
+ __all__ = ["PendulumSwingUp", "DoubleIntegrator", "TorchVecEnv", "TorchPendulum"]