dsh-eval-guard 0.7.4 → 0.7.6
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.
- package/ISSUES.md +4 -2
- package/README.md +3 -3
- package/assets/neural-pendulum/SPEC.md +40 -42
- package/assets/neural-pendulum/TASK.md +34 -47
- package/assets/neural-pendulum/sim.cpp +97 -45
- package/assets/neural-pendulum/sim_agent +0 -0
- package/assets/neural-pendulum/sim_eval +0 -0
- package/assets/neural-pendulum/workspace.zip +0 -0
- package/lib/index.js +27 -10
- package/neural-pendulum-eval/README.md +4 -3
- package/neural-pendulum-eval/run_eval.py +133 -70
- package/neural-pendulum-eval/sim_eval +0 -0
- package/package.json +1 -1
- package/assets/neural-pendulum/rl-feasibility/README.md +0 -60
- package/assets/neural-pendulum/rl-feasibility/RESULTS.md +0 -370
- package/assets/neural-pendulum/rl-feasibility/evaluate.py +0 -107
- package/assets/neural-pendulum/rl-feasibility/finalize_run.py +0 -168
- package/assets/neural-pendulum/rl-feasibility/models.py +0 -124
- package/assets/neural-pendulum/rl-feasibility/obs.py +0 -205
- package/assets/neural-pendulum/rl-feasibility/plot_selected.py +0 -55
- package/assets/neural-pendulum/rl-feasibility/reference_controller.py +0 -112
- package/assets/neural-pendulum/rl-feasibility/results/curves_selected.png +0 -0
- package/assets/neural-pendulum/rl-feasibility/results/reference_controller.json +0 -417
- package/assets/neural-pendulum/rl-feasibility/results/summary.json +0 -1599
- package/assets/neural-pendulum/rl-feasibility/results/table.md +0 -39
- package/assets/neural-pendulum/rl-feasibility/sim.py +0 -324
- package/assets/neural-pendulum/rl-feasibility/summarize.py +0 -175
- package/assets/neural-pendulum/rl-feasibility/task.py +0 -342
- package/assets/neural-pendulum/rl-feasibility/test_contract.py +0 -188
- package/assets/neural-pendulum/rl-feasibility/train_ppo.py +0 -403
- package/assets/neural-pendulum/rl-feasibility/train_sac.py +0 -406
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
"""Networks: PPO actor-critic MLP, SAC squashed-Gaussian actor, twin Q.
|
|
2
|
-
|
|
3
|
-
PPO: 2x128 tanh, separate actor and critic, state-independent log_std capped
|
|
4
|
-
to [-2.3, -0.25] (the recipe of the sibling rounds).
|
|
5
|
-
SAC: 2x256 tanh actor with tanh-Gaussian head and a shared-trunk twin Q (two
|
|
6
|
-
output heads). Sharing the critic trunk keeps the CPU update affordable while
|
|
7
|
-
preserving the double-Q target.
|
|
8
|
-
"""
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
import math
|
|
12
|
-
|
|
13
|
-
import numpy as np
|
|
14
|
-
import torch
|
|
15
|
-
import torch.nn as nn
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class MLP(nn.Module):
|
|
19
|
-
def __init__(self, in_dim, hidden=128, depth=2, out_dim=1, out_gain=0.01):
|
|
20
|
-
super().__init__()
|
|
21
|
-
layers = []
|
|
22
|
-
d = in_dim
|
|
23
|
-
for _ in range(depth):
|
|
24
|
-
lin = nn.Linear(d, hidden)
|
|
25
|
-
nn.init.orthogonal_(lin.weight, gain=np.sqrt(2))
|
|
26
|
-
nn.init.zeros_(lin.bias)
|
|
27
|
-
layers += [lin, nn.Tanh()]
|
|
28
|
-
d = hidden
|
|
29
|
-
self.body = nn.Sequential(*layers)
|
|
30
|
-
self.out = nn.Linear(d, out_dim)
|
|
31
|
-
nn.init.orthogonal_(self.out.weight, gain=out_gain)
|
|
32
|
-
nn.init.zeros_(self.out.bias)
|
|
33
|
-
|
|
34
|
-
def forward(self, x):
|
|
35
|
-
return self.out(self.body(x))
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
# ------------------------------- PPO ---------------------------------------
|
|
39
|
-
LOG_STD_MIN, LOG_STD_MAX = -2.3, -0.25
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
class ActorCritic(nn.Module):
|
|
43
|
-
def __init__(self, obs_dim, act_dim=2, hidden=128, depth=2):
|
|
44
|
-
super().__init__()
|
|
45
|
-
self.act_dim = act_dim
|
|
46
|
-
self.actor = MLP(obs_dim, hidden, depth, act_dim)
|
|
47
|
-
self.critic = MLP(obs_dim, hidden, depth, 1, out_gain=1.0)
|
|
48
|
-
self.log_std = nn.Parameter(torch.full((act_dim,), -0.7))
|
|
49
|
-
|
|
50
|
-
def dist(self, obs):
|
|
51
|
-
mean = self.actor(obs)
|
|
52
|
-
std = self.effective_log_std().exp().expand_as(mean)
|
|
53
|
-
return torch.distributions.Normal(mean, std)
|
|
54
|
-
|
|
55
|
-
def effective_log_std(self):
|
|
56
|
-
return self.log_std.clamp(LOG_STD_MIN, LOG_STD_MAX)
|
|
57
|
-
|
|
58
|
-
def value(self, obs):
|
|
59
|
-
return self.critic(obs).squeeze(-1)
|
|
60
|
-
|
|
61
|
-
def deterministic_action(self, obs):
|
|
62
|
-
return self.actor(obs)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
# ------------------------------- SAC ---------------------------------------
|
|
66
|
-
SAC_LOG_STD_MIN, SAC_LOG_STD_MAX = -5.0, 2.0
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
class SquashedGaussianActor(nn.Module):
|
|
70
|
-
def __init__(self, in_dim, act_dim=2, hidden=256, depth=2):
|
|
71
|
-
super().__init__()
|
|
72
|
-
self.body = nn.Sequential()
|
|
73
|
-
d = in_dim
|
|
74
|
-
for _ in range(depth):
|
|
75
|
-
lin = nn.Linear(d, hidden)
|
|
76
|
-
nn.init.orthogonal_(lin.weight, gain=math.sqrt(2))
|
|
77
|
-
nn.init.zeros_(lin.bias)
|
|
78
|
-
self.body.extend([lin, nn.Tanh()])
|
|
79
|
-
d = hidden
|
|
80
|
-
self.mean = nn.Linear(hidden, act_dim)
|
|
81
|
-
self.log_std = nn.Linear(hidden, act_dim)
|
|
82
|
-
nn.init.orthogonal_(self.mean.weight, gain=0.01)
|
|
83
|
-
nn.init.zeros_(self.mean.bias)
|
|
84
|
-
nn.init.orthogonal_(self.log_std.weight, gain=0.01)
|
|
85
|
-
nn.init.zeros_(self.log_std.bias)
|
|
86
|
-
self.act_dim = act_dim
|
|
87
|
-
|
|
88
|
-
def forward(self, obs):
|
|
89
|
-
h = self.body(obs)
|
|
90
|
-
return self.mean(h), self.log_std(h).clamp(SAC_LOG_STD_MIN, SAC_LOG_STD_MAX)
|
|
91
|
-
|
|
92
|
-
def sample(self, obs, det=False):
|
|
93
|
-
if det:
|
|
94
|
-
return torch.tanh(self.mean(self.body(obs))), None
|
|
95
|
-
mean, log_std = self(obs)
|
|
96
|
-
std = log_std.exp()
|
|
97
|
-
eps = torch.randn_like(mean)
|
|
98
|
-
x = mean + std * eps
|
|
99
|
-
a = torch.tanh(x)
|
|
100
|
-
logp = (-0.5 * eps ** 2 - log_std - 0.5 * math.log(2 * math.pi)).sum(-1)
|
|
101
|
-
logp = logp - torch.log(1 - a ** 2 + 1e-6).sum(-1)
|
|
102
|
-
return a, logp
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
class TwinQ(nn.Module):
|
|
106
|
-
"""Shared trunk, two Q heads; forward returns (B, 2)."""
|
|
107
|
-
|
|
108
|
-
def __init__(self, in_dim, act_dim=2, hidden=256, depth=2):
|
|
109
|
-
super().__init__()
|
|
110
|
-
self.body = nn.Sequential()
|
|
111
|
-
d = in_dim + act_dim
|
|
112
|
-
for _ in range(depth):
|
|
113
|
-
lin = nn.Linear(d, hidden)
|
|
114
|
-
nn.init.orthogonal_(lin.weight, gain=math.sqrt(2))
|
|
115
|
-
nn.init.zeros_(lin.bias)
|
|
116
|
-
self.body.extend([lin, nn.Tanh()])
|
|
117
|
-
d = hidden
|
|
118
|
-
self.q = nn.Linear(hidden, 2)
|
|
119
|
-
nn.init.orthogonal_(self.q.weight, gain=1.0)
|
|
120
|
-
nn.init.zeros_(self.q.bias)
|
|
121
|
-
|
|
122
|
-
def forward(self, obs, act):
|
|
123
|
-
x = torch.cat([obs, act], dim=-1)
|
|
124
|
-
return self.q(self.body(x))
|
|
@@ -1,205 +0,0 @@
|
|
|
1
|
-
"""Policy-side angle observation for the 3-link pendulum (switchable modes).
|
|
2
|
-
|
|
3
|
-
The simulator only exposes the three joint angles theta. The observation is
|
|
4
|
-
built entirely from that angle stream (no omega, no tip, no energy):
|
|
5
|
-
|
|
6
|
-
a_i(t) = wrap_to_pi(theta_i(t) - pi) (upright-relative)
|
|
7
|
-
da_i(t) = wrap_to_pi(a_i(t) - a_i(t-1)) / DT, da_i(0) = 0 at reset
|
|
8
|
-
|
|
9
|
-
frame(t) = [cos a1, sin a1, cos a2, sin a2, cos a3, sin a3,
|
|
10
|
-
da1/5, da2/5, da3/5] (9 dims, diff=True)
|
|
11
|
-
frame(t) = [cos a1, sin a1, cos a2, sin a2, cos a3, sin a3]
|
|
12
|
-
(6 dims, diff=False)
|
|
13
|
-
|
|
14
|
-
obs(t) = H frames, oldest first, flattened
|
|
15
|
-
|
|
16
|
-
Modes (--obs):
|
|
17
|
-
full H=4, diff=True -> 36 dims (the original observation)
|
|
18
|
-
angle H=4, diff=False -> 24 dims (cos/sin only, no manual velocity)
|
|
19
|
-
full_h1 H=1, diff=True -> 9 dims (single frame incl. da/5)
|
|
20
|
-
angle_h1 H=1, diff=False -> 6 dims (single frame cos/sin only)
|
|
21
|
-
full_h2 H=2, diff=True -> 18 dims (extra mode, optional)
|
|
22
|
-
angle_h2 H=2, diff=False -> 12 dims (extra mode, optional)
|
|
23
|
-
|
|
24
|
-
Even when diff=False the builder still computes da internally, because the
|
|
25
|
-
trainer-side reward uses it; the channel is only left out of the policy
|
|
26
|
-
observation. theta is used in radians exactly as produced by the simulator
|
|
27
|
-
(never divided by pi). The history is initialised by repeating the reset
|
|
28
|
-
frame H times.
|
|
29
|
-
"""
|
|
30
|
-
from __future__ import annotations
|
|
31
|
-
|
|
32
|
-
import numpy as np
|
|
33
|
-
|
|
34
|
-
DT = 0.01
|
|
35
|
-
H = 4
|
|
36
|
-
VEL_SCALE = 5.0
|
|
37
|
-
|
|
38
|
-
# mode -> (history length, whether the manual-difference velocity is observed)
|
|
39
|
-
OBS_MODES = {
|
|
40
|
-
"full": dict(h=4, diff=True),
|
|
41
|
-
"angle": dict(h=4, diff=False),
|
|
42
|
-
"full_h1": dict(h=1, diff=True),
|
|
43
|
-
"angle_h1": dict(h=1, diff=False),
|
|
44
|
-
"full_h2": dict(h=2, diff=True),
|
|
45
|
-
"angle_h2": dict(h=2, diff=False),
|
|
46
|
-
"angle_h8": dict(h=8, diff=False), # extra: can longer history replace da?
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def frame_dim(diff: bool) -> int:
|
|
51
|
-
return 9 if diff else 6
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def obs_spec(mode: str = "full") -> dict:
|
|
55
|
-
if mode not in OBS_MODES:
|
|
56
|
-
raise ValueError(f"unknown obs mode {mode!r}; choose from {sorted(OBS_MODES)}")
|
|
57
|
-
c = OBS_MODES[mode]
|
|
58
|
-
fd = frame_dim(c["diff"])
|
|
59
|
-
notes = ("cos/sin(a) + da/5, H=%d, a=wrap(theta-pi), da is the manual "
|
|
60
|
-
"finite difference, da(0)=0" % c["h"])
|
|
61
|
-
if not c["diff"]:
|
|
62
|
-
notes = ("cos/sin(a) only (NO velocity channel), H=%d, "
|
|
63
|
-
"a=wrap(theta-pi)" % c["h"])
|
|
64
|
-
return dict(mode=mode, enc="sincos", diff=bool(c["diff"]), h=int(c["h"]),
|
|
65
|
-
vel_scale=VEL_SCALE, dt=DT, frame_dim=fd,
|
|
66
|
-
dim=int(c["h"] * fd), notes=notes)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
# Backwards-compatible constants for the original full observation.
|
|
70
|
-
FRAME_DIM = frame_dim(True)
|
|
71
|
-
OBS_DIM = obs_spec("full")["dim"]
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def wrap_to_pi(a):
|
|
75
|
-
return (a + np.pi) % (2.0 * np.pi) - np.pi
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def frame_np(a, da, diff=True):
|
|
79
|
-
a = np.asarray(a, dtype=np.float64)
|
|
80
|
-
cs = np.empty(6, dtype=np.float64)
|
|
81
|
-
cs[0::2] = np.cos(a)
|
|
82
|
-
cs[1::2] = np.sin(a)
|
|
83
|
-
if not diff:
|
|
84
|
-
return cs
|
|
85
|
-
return np.concatenate([cs, np.asarray(da, dtype=np.float64) / VEL_SCALE])
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
class NumpyAngleObs:
|
|
89
|
-
"""Float64 single-env builder. reset/update take theta only."""
|
|
90
|
-
|
|
91
|
-
def __init__(self, h: int = H, vel_scale: float = VEL_SCALE, dt: float = DT,
|
|
92
|
-
diff: bool = True):
|
|
93
|
-
self.h = int(h)
|
|
94
|
-
self.diff = bool(diff)
|
|
95
|
-
self.fdim = frame_dim(self.diff)
|
|
96
|
-
self.vel_scale = float(vel_scale)
|
|
97
|
-
self.dt = float(dt)
|
|
98
|
-
self.a_prev = np.zeros(3)
|
|
99
|
-
self.da = np.zeros(3)
|
|
100
|
-
self.frames = np.zeros((self.h, self.fdim))
|
|
101
|
-
self._obs = np.zeros(self.h * self.fdim, dtype=np.float32)
|
|
102
|
-
|
|
103
|
-
def _frame(self, a, da):
|
|
104
|
-
cs = np.empty(6, dtype=np.float64)
|
|
105
|
-
cs[0::2] = np.cos(a)
|
|
106
|
-
cs[1::2] = np.sin(a)
|
|
107
|
-
if not self.diff:
|
|
108
|
-
return cs
|
|
109
|
-
return np.concatenate([cs, da / self.vel_scale])
|
|
110
|
-
|
|
111
|
-
def _flatten(self):
|
|
112
|
-
self._obs = self.frames.reshape(-1).astype(np.float32)
|
|
113
|
-
return self._obs
|
|
114
|
-
|
|
115
|
-
def reset(self, theta):
|
|
116
|
-
a = wrap_to_pi(np.asarray(theta, dtype=np.float64).reshape(3) - np.pi)
|
|
117
|
-
self.a_prev = a.copy()
|
|
118
|
-
self.da = np.zeros(3)
|
|
119
|
-
f0 = self._frame(a, self.da)
|
|
120
|
-
self.frames = np.tile(f0, (self.h, 1))
|
|
121
|
-
return self._flatten()
|
|
122
|
-
|
|
123
|
-
@property
|
|
124
|
-
def obs(self):
|
|
125
|
-
return self._obs
|
|
126
|
-
|
|
127
|
-
def update(self, theta):
|
|
128
|
-
a = wrap_to_pi(np.asarray(theta, dtype=np.float64).reshape(3) - np.pi)
|
|
129
|
-
self.da = wrap_to_pi(a - self.a_prev) / self.dt
|
|
130
|
-
self.a_prev = a.copy()
|
|
131
|
-
f = self._frame(a, self.da)
|
|
132
|
-
if self.h > 1:
|
|
133
|
-
self.frames = np.roll(self.frames, -1, axis=0)
|
|
134
|
-
self.frames[-1] = f
|
|
135
|
-
return self._flatten()
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
class TorchAngleObs:
|
|
139
|
-
"""Batched float32 builder. reset/update take theta only."""
|
|
140
|
-
|
|
141
|
-
def __init__(self, n: int, h: int = H, vel_scale: float = VEL_SCALE,
|
|
142
|
-
dt: float = DT, device="cpu", diff: bool = True):
|
|
143
|
-
import torch
|
|
144
|
-
self.torch = torch
|
|
145
|
-
self.n = int(n)
|
|
146
|
-
self.h = int(h)
|
|
147
|
-
self.diff = bool(diff)
|
|
148
|
-
self.fdim = frame_dim(self.diff)
|
|
149
|
-
self.vel_scale = float(vel_scale)
|
|
150
|
-
self.dt = float(dt)
|
|
151
|
-
self.device = device
|
|
152
|
-
self.a_prev = torch.zeros(n, 3, device=device)
|
|
153
|
-
self.da = torch.zeros(n, 3, device=device)
|
|
154
|
-
self.frames = torch.zeros(n, self.h, self.fdim, device=device)
|
|
155
|
-
|
|
156
|
-
def _frame(self, a, da):
|
|
157
|
-
t = self.torch
|
|
158
|
-
cs = t.stack([t.cos(a), t.sin(a)], dim=2).reshape(a.shape[0], 6)
|
|
159
|
-
if not self.diff:
|
|
160
|
-
return cs
|
|
161
|
-
return t.cat([cs, da / self.vel_scale], dim=1)
|
|
162
|
-
|
|
163
|
-
def obs(self):
|
|
164
|
-
return self.frames.reshape(self.n, -1)
|
|
165
|
-
|
|
166
|
-
def reset(self, theta, idx=None):
|
|
167
|
-
t = self.torch
|
|
168
|
-
theta = t.as_tensor(theta, dtype=t.float32, device=self.device).reshape(-1, 3)
|
|
169
|
-
if idx is None:
|
|
170
|
-
a = t.remainder(theta - np.pi + np.pi, 2 * np.pi) - np.pi
|
|
171
|
-
self.a_prev = a.clone()
|
|
172
|
-
self.da = t.zeros_like(a)
|
|
173
|
-
f0 = self._frame(a, self.da)
|
|
174
|
-
self.frames = f0.unsqueeze(1).expand(-1, self.h, -1).clone()
|
|
175
|
-
return self.obs()
|
|
176
|
-
idx = t.as_tensor(idx, dtype=t.long, device=self.device).flatten()
|
|
177
|
-
a = t.remainder(theta - np.pi + np.pi, 2 * np.pi) - np.pi
|
|
178
|
-
self.a_prev[idx] = a
|
|
179
|
-
self.da[idx] = 0.0
|
|
180
|
-
f0 = self._frame(a, t.zeros_like(a))
|
|
181
|
-
self.frames[idx] = f0.unsqueeze(1).expand(-1, self.h, -1)
|
|
182
|
-
return self.obs()
|
|
183
|
-
|
|
184
|
-
def update(self, theta):
|
|
185
|
-
t = self.torch
|
|
186
|
-
theta = t.as_tensor(theta, dtype=t.float32, device=self.device).reshape(-1, 3)
|
|
187
|
-
a = t.remainder(theta - np.pi + np.pi, 2 * np.pi) - np.pi
|
|
188
|
-
self.da = (t.remainder(a - self.a_prev + np.pi, 2 * np.pi) - np.pi) / self.dt
|
|
189
|
-
self.a_prev = a.clone()
|
|
190
|
-
f = self._frame(a, self.da)
|
|
191
|
-
if self.h > 1:
|
|
192
|
-
self.frames = t.cat([self.frames[:, 1:], f.unsqueeze(1)], dim=1)
|
|
193
|
-
else:
|
|
194
|
-
self.frames[:, -1] = f
|
|
195
|
-
return self.obs()
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
def make_numpy_obs(mode: str = "full", **kw):
|
|
199
|
-
c = OBS_MODES[mode]
|
|
200
|
-
return NumpyAngleObs(h=c["h"], diff=c["diff"], **kw)
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
def make_torch_obs(n: int, mode: str = "full", **kw):
|
|
204
|
-
c = OBS_MODES[mode]
|
|
205
|
-
return TorchAngleObs(n, h=c["h"], diff=c["diff"], **kw)
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
"""Plot selected eval t_can curves (PPO left, SAC right)."""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
import json
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
|
|
7
|
-
import matplotlib
|
|
8
|
-
matplotlib.use("Agg")
|
|
9
|
-
import matplotlib.pyplot as plt
|
|
10
|
-
|
|
11
|
-
ROOT = Path(__file__).resolve().parent
|
|
12
|
-
RES = ROOT / "results"
|
|
13
|
-
|
|
14
|
-
PPO = ["ppo_full_base_rand", "ppo_full_base_rand_s1",
|
|
15
|
-
"ppo_full_base_rand_s2", "ppo_angle_base_rand", "ppo_angle_h8_rand",
|
|
16
|
-
"ppo_full_h1_rand", "ppo_full_h1_mix", "ppo_full_base_fixed"]
|
|
17
|
-
SAC = ["sac_full_base_rand", "sac_full_base_fixed", "sac_angle_base_rand"]
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def load(exp):
|
|
21
|
-
p = RES / (exp + ".json")
|
|
22
|
-
if not p.exists():
|
|
23
|
-
return None
|
|
24
|
-
with open(p) as f:
|
|
25
|
-
return json.load(f)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def main():
|
|
29
|
-
fig, axes = plt.subplots(1, 2, figsize=(14, 5.0), sharey=True)
|
|
30
|
-
for ax, names, title in [(axes[0], PPO, "PPO"), (axes[1], SAC, "SAC")]:
|
|
31
|
-
for exp in names:
|
|
32
|
-
r = load(exp)
|
|
33
|
-
if not r:
|
|
34
|
-
continue
|
|
35
|
-
c = r.get("eval_curve") or []
|
|
36
|
-
if not c:
|
|
37
|
-
continue
|
|
38
|
-
x = [v["global_steps"] / 1e6 for v in c]
|
|
39
|
-
y = [v["t_can"] for v in c]
|
|
40
|
-
ax.plot(x, y, lw=1.3, label=f"{exp} (best {r['best_t_can']:.2f}s)")
|
|
41
|
-
ax.axhline(10.0, color="k", ls=":", lw=1)
|
|
42
|
-
ax.set_title(f"{title}: fixed-initial eval t_can")
|
|
43
|
-
ax.set_xlabel("environment steps (M)")
|
|
44
|
-
ax.set_ylabel("t_can (s)")
|
|
45
|
-
ax.grid(alpha=0.3)
|
|
46
|
-
ax.legend(fontsize=7)
|
|
47
|
-
fig.tight_layout()
|
|
48
|
-
out = RES / "curves_selected.png"
|
|
49
|
-
fig.savefig(out, dpi=130)
|
|
50
|
-
plt.close(fig)
|
|
51
|
-
print(out)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if __name__ == "__main__":
|
|
55
|
-
main()
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
"""Non-RL feasibility check: a pole-placement stabiliser around upright.
|
|
2
|
-
|
|
3
|
-
This is NOT a learned policy and NOT part of the RL results. It answers a
|
|
4
|
-
single question: is the upright hold physically reachable with the fixed
|
|
5
|
-
torque mapping (tau1, -tau3, tau3) and |u| <= 1?
|
|
6
|
-
|
|
7
|
-
The controller uses privileged full state (theta and the simulator omega),
|
|
8
|
-
so it is an upper bound / existence proof only. It is deliberately simple:
|
|
9
|
-
u = clip(-K [wrap(theta-pi); omega], -1, 1)
|
|
10
|
-
with K from pole placement on the linearisation at upright.
|
|
11
|
-
|
|
12
|
-
Writes results/reference_controller.json.
|
|
13
|
-
"""
|
|
14
|
-
from __future__ import annotations
|
|
15
|
-
|
|
16
|
-
import json
|
|
17
|
-
from pathlib import Path
|
|
18
|
-
|
|
19
|
-
import numpy as np
|
|
20
|
-
from scipy.signal import place_poles
|
|
21
|
-
|
|
22
|
-
from sim import EPISODE_STEPS, N_ACT, Params, PendulumSim, make_torch_consts
|
|
23
|
-
from task import EVAL_OMEGA0, EVAL_THETA0, NumpyTracker, sample_train_init, wrap_to_pi
|
|
24
|
-
|
|
25
|
-
ROOT = Path(__file__).resolve().parent
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def design(p: Params, poles=(-5.0, -6.0, -7.0, -8.0, -9.0, -10.0)):
|
|
29
|
-
M = p.A
|
|
30
|
-
K = np.diag(p.g * p.gvec)
|
|
31
|
-
Minv = np.linalg.inv(M)
|
|
32
|
-
B = np.zeros((3, 2))
|
|
33
|
-
B[0, 0] = 1.0
|
|
34
|
-
B[1, 1] = -1.0
|
|
35
|
-
B[2, 1] = 1.0
|
|
36
|
-
Be = Minv @ B
|
|
37
|
-
A = np.zeros((6, 6))
|
|
38
|
-
A[:3, 3:] = np.eye(3)
|
|
39
|
-
A[3:, :3] = Minv @ K
|
|
40
|
-
B6 = np.vstack([np.zeros((3, 2)), Be])
|
|
41
|
-
res = place_poles(A, B6, np.asarray(poles, dtype=float))
|
|
42
|
-
return res.gain_matrix
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def rollout(p, gain, theta0, omega0, episode_steps=EPISODE_STEPS,
|
|
46
|
-
alpha=0.5):
|
|
47
|
-
sim = PendulumSim(p, episode_steps=episode_steps)
|
|
48
|
-
tr = NumpyTracker()
|
|
49
|
-
theta = sim.reset(theta0, omega0)
|
|
50
|
-
tr.reset()
|
|
51
|
-
sat = 0
|
|
52
|
-
uf = np.zeros(2)
|
|
53
|
-
for _ in range(episode_steps):
|
|
54
|
-
x = np.r_[wrap_to_pi(theta - np.pi), sim._omega]
|
|
55
|
-
raw = np.clip(-gain @ x, -1.0, 1.0)
|
|
56
|
-
# first-order low-pass on the torque command: the unfiltered
|
|
57
|
-
# pole-placement controller chatters at 100 Hz and only holds ~1 s.
|
|
58
|
-
uf = alpha * raw + (1.0 - alpha) * uf
|
|
59
|
-
sat += int(np.abs(uf).max() > 1.0)
|
|
60
|
-
theta = sim.step(uf)
|
|
61
|
-
tr.update(theta, 0.0)
|
|
62
|
-
summ = tr.summary()
|
|
63
|
-
return dict(t_can=float(summ["t_can"]),
|
|
64
|
-
ever_up=float(summ["ever_up"]),
|
|
65
|
-
final_ang_err=float(np.abs(wrap_to_pi(theta - np.pi)).max()),
|
|
66
|
-
sat_frac=float(sat / episode_steps),
|
|
67
|
-
final_theta=theta.tolist())
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def main():
|
|
71
|
-
p = Params()
|
|
72
|
-
gain = design(p)
|
|
73
|
-
# This is a LOCAL stabiliser: start near upright (theta = pi + offset).
|
|
74
|
-
fixed_theta0 = np.pi + EVAL_THETA0
|
|
75
|
-
fixed = rollout(p, gain, fixed_theta0, EVAL_OMEGA0)
|
|
76
|
-
rng = np.random.default_rng(0)
|
|
77
|
-
th = np.pi + rng.uniform(-0.05, 0.05, size=(32, 3))
|
|
78
|
-
om = np.zeros((32, 3))
|
|
79
|
-
rows = [rollout(p, gain, th[i], om[i]) for i in range(th.shape[0])]
|
|
80
|
-
tc = np.array([r["t_can"] for r in rows])
|
|
81
|
-
out = dict(
|
|
82
|
-
note=("Non-RL existence check. Pole-placement stabiliser using "
|
|
83
|
-
"privileged full state (theta and simulator omega); not a "
|
|
84
|
-
"learned policy. Shows whether the upright hold is physically "
|
|
85
|
-
"reachable with the fixed torque mapping. The torque command is "
|
|
86
|
-
"low-pass filtered (alpha=0.5); the unfiltered controller "
|
|
87
|
-
"chatters and only holds ~1 s."),
|
|
88
|
-
poles=[-5.0, -6.0, -7.0, -8.0, -9.0, -10.0],
|
|
89
|
-
action_filter_alpha=0.5,
|
|
90
|
-
gain=gain.tolist(),
|
|
91
|
-
fixed_start=dict(theta0=fixed_theta0.tolist(),
|
|
92
|
-
offset_from_upright=EVAL_THETA0.tolist(),
|
|
93
|
-
omega0=EVAL_OMEGA0.tolist(), **fixed),
|
|
94
|
-
random_starts=dict(n=len(rows), success_rate=float((tc >= 10.0).mean()),
|
|
95
|
-
mean_t_can=float(tc.mean()),
|
|
96
|
-
median_t_can=float(np.median(tc)),
|
|
97
|
-
max_t_can=float(tc.max()),
|
|
98
|
-
rows=rows),
|
|
99
|
-
)
|
|
100
|
-
out_path = ROOT / "results" / "reference_controller.json"
|
|
101
|
-
with open(out_path, "w") as f:
|
|
102
|
-
json.dump(out, f, indent=2)
|
|
103
|
-
print(json.dumps(dict(fixed_t_can=fixed["t_can"],
|
|
104
|
-
fixed_final_err=fixed["final_ang_err"],
|
|
105
|
-
fixed_sat=fixed["sat_frac"],
|
|
106
|
-
random_success=out["random_starts"]["success_rate"],
|
|
107
|
-
random_mean_t_can=out["random_starts"]["mean_t_can"]),
|
|
108
|
-
indent=2))
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if __name__ == "__main__":
|
|
112
|
-
main()
|
|
Binary file
|