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
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
|