dsh-eval-guard 0.7.4 → 0.7.5

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.
Files changed (30) hide show
  1. package/ISSUES.md +2 -2
  2. package/README.md +1 -1
  3. package/assets/neural-pendulum/SPEC.md +2 -0
  4. package/assets/neural-pendulum/TASK.md +3 -1
  5. package/assets/neural-pendulum/sim.cpp +25 -3
  6. package/assets/neural-pendulum/sim_agent +0 -0
  7. package/assets/neural-pendulum/sim_eval +0 -0
  8. package/assets/neural-pendulum/workspace.zip +0 -0
  9. package/neural-pendulum-eval/README.md +4 -2
  10. package/neural-pendulum-eval/run_eval.py +3 -1
  11. package/neural-pendulum-eval/sim_eval +0 -0
  12. package/package.json +1 -1
  13. package/assets/neural-pendulum/rl-feasibility/README.md +0 -60
  14. package/assets/neural-pendulum/rl-feasibility/RESULTS.md +0 -370
  15. package/assets/neural-pendulum/rl-feasibility/evaluate.py +0 -107
  16. package/assets/neural-pendulum/rl-feasibility/finalize_run.py +0 -168
  17. package/assets/neural-pendulum/rl-feasibility/models.py +0 -124
  18. package/assets/neural-pendulum/rl-feasibility/obs.py +0 -205
  19. package/assets/neural-pendulum/rl-feasibility/plot_selected.py +0 -55
  20. package/assets/neural-pendulum/rl-feasibility/reference_controller.py +0 -112
  21. package/assets/neural-pendulum/rl-feasibility/results/curves_selected.png +0 -0
  22. package/assets/neural-pendulum/rl-feasibility/results/reference_controller.json +0 -417
  23. package/assets/neural-pendulum/rl-feasibility/results/summary.json +0 -1599
  24. package/assets/neural-pendulum/rl-feasibility/results/table.md +0 -39
  25. package/assets/neural-pendulum/rl-feasibility/sim.py +0 -324
  26. package/assets/neural-pendulum/rl-feasibility/summarize.py +0 -175
  27. package/assets/neural-pendulum/rl-feasibility/task.py +0 -342
  28. package/assets/neural-pendulum/rl-feasibility/test_contract.py +0 -188
  29. package/assets/neural-pendulum/rl-feasibility/train_ppo.py +0 -403
  30. package/assets/neural-pendulum/rl-feasibility/train_sac.py +0 -406
@@ -1,107 +0,0 @@
1
- """Deterministic evaluation at the fixed initial state (and optional starts).
2
-
3
- The evaluation uses the simulator API exactly like training (angles only), the
4
- same observation builder / obs mode, and the same trainer-side reward. There
5
- is no action noise.
6
-
7
- default fixed state: theta = [0.013, -0.008, 0.021] rad, omega = 0
8
- optional starts : drawn from the training reset distribution
9
-
10
- The episode always runs the full 3000 steps so that t_can, ever_up, return and
11
- the final angle error are comparable across algorithms.
12
- """
13
- from __future__ import annotations
14
-
15
- import numpy as np
16
- import torch
17
-
18
- from obs import DT, make_numpy_obs, wrap_to_pi
19
- from sim import N_ACT, N_LINKS, UP_TOL, PendulumSim
20
- from task import EVAL_OMEGA0, EVAL_THETA0, NumpyTask
21
-
22
-
23
- def evaluate(policy_fn, p, episode_steps=3000, reward_w=None, obs_mode="full"):
24
- """Run one full deterministic episode at the fixed initial state.
25
-
26
- policy_fn(obs: np.ndarray[obs_dim]) -> np.ndarray[2]
27
- """
28
- sim = PendulumSim(p, episode_steps=episode_steps)
29
- task = NumpyTask(p, reward_w=reward_w, obs_mode=obs_mode)
30
- theta = sim.reset(EVAL_THETA0, EVAL_OMEGA0)
31
- obs = task.reset(theta)
32
- acts = np.zeros((episode_steps, N_ACT), dtype=np.float64)
33
- for k in range(episode_steps):
34
- u = np.clip(np.asarray(policy_fn(obs), dtype=np.float64).reshape(N_ACT),
35
- -1.0, 1.0)
36
- acts[k] = u
37
- theta = sim.step(u)
38
- obs, _ = task.step(theta, u)
39
- summ = task.tracker.summary()
40
- a = wrap_to_pi(np.asarray(theta, dtype=np.float64) - np.pi)
41
- return dict(t_can=float(summ["t_can"]),
42
- success=float(summ["t_can"] >= 10.0),
43
- ever_up=float(summ["ever_up"]),
44
- first_up_t=float(summ["first_up_t"]),
45
- ep_return=float(summ["ep_return"]),
46
- final_ang_err=float(np.abs(a).max()),
47
- up_frac=float(summ["up_frac"]),
48
- mean_abs_action=float(np.mean(np.abs(acts))),
49
- action_std=float(np.std(acts)),
50
- mean_u1=float(acts[:, 0].mean()),
51
- mean_u3=float(acts[:, 1].mean()),
52
- steps=int(episode_steps))
53
-
54
-
55
- def evaluate_starts(policy_fn, p, starts, episode_steps=3000, reward_w=None,
56
- obs_mode="full"):
57
- """Deterministic rollouts from an explicit list of (theta, omega) starts."""
58
- rows = []
59
- for theta0, omega0 in starts:
60
- sim = PendulumSim(p, episode_steps=episode_steps)
61
- task = NumpyTask(p, reward_w=reward_w, obs_mode=obs_mode)
62
- theta = sim.reset(theta0, omega0)
63
- obs = task.reset(theta)
64
- for _ in range(episode_steps):
65
- u = np.clip(np.asarray(policy_fn(obs), dtype=np.float64).reshape(N_ACT),
66
- -1.0, 1.0)
67
- theta = sim.step(u)
68
- obs, _ = task.step(theta, u)
69
- summ = task.tracker.summary()
70
- a = wrap_to_pi(np.asarray(theta, dtype=np.float64) - np.pi)
71
- rows.append(dict(t_can=float(summ["t_can"]),
72
- success=float(summ["t_can"] >= 10.0),
73
- ever_up=float(summ["ever_up"]),
74
- ep_return=float(summ["ep_return"]),
75
- final_ang_err=float(np.abs(a).max())))
76
- tc = np.array([r["t_can"] for r in rows], dtype=np.float64)
77
- su = np.array([r["success"] for r in rows], dtype=np.float64)
78
- re = np.array([r["ep_return"] for r in rows], dtype=np.float64)
79
- ae = np.array([r["final_ang_err"] for r in rows], dtype=np.float64)
80
- eu = np.array([r["ever_up"] for r in rows], dtype=np.float64)
81
- return dict(n=int(len(rows)),
82
- success_rate=float(su.mean()) if len(rows) else 0.0,
83
- ever_up_rate=float(eu.mean()) if len(rows) else 0.0,
84
- mean_t_can=float(tc.mean()) if len(rows) else 0.0,
85
- median_t_can=float(np.median(tc)) if len(rows) else 0.0,
86
- min_t_can=float(tc.min()) if len(rows) else 0.0,
87
- max_t_can=float(tc.max()) if len(rows) else 0.0,
88
- mean_return=float(re.mean()) if len(rows) else 0.0,
89
- mean_final_ang_err=float(ae.mean()) if len(rows) else 0.0,
90
- rows=rows)
91
-
92
-
93
- def make_ppo_policy(actor):
94
- def policy(obs):
95
- with torch.no_grad():
96
- x = torch.as_tensor(obs, dtype=torch.float32).unsqueeze(0)
97
- return actor(x).squeeze(0).numpy()
98
- return policy
99
-
100
-
101
- def make_sac_policy(actor):
102
- def policy(obs):
103
- with torch.no_grad():
104
- x = torch.as_tensor(obs, dtype=torch.float32).unsqueeze(0)
105
- a, _ = actor.sample(x, det=True)
106
- return a.squeeze(0).numpy()
107
- return policy
@@ -1,168 +0,0 @@
1
- """Reconstruct results/<exp>.json for a finished run from its checkpoints,
2
- TensorBoard event file and log. Useful when the trainer crashed after saving
3
- checkpoints but before writing the JSON (e.g. the PPO robustness bug).
4
-
5
- Usage:
6
- python3 finalize_run.py --exp ppo_full_base_rand --algo ppo \
7
- --obs full --reward base --init rand --envs 64 --minutes 45
8
-
9
- The fixed-state eval and the 8-start robustness check are re-run here with the
10
- same code as the trainers, so the numbers are real, not copied from the log.
11
- The eval curve is parsed from logs/<exp>.log and the wall time from TB.
12
- """
13
- from __future__ import annotations
14
-
15
- import argparse
16
- import json
17
- import os
18
- import re
19
- from pathlib import Path
20
-
21
- import torch
22
-
23
- from evaluate import (evaluate, evaluate_starts, make_ppo_policy,
24
- make_sac_policy)
25
- from models import ActorCritic, SquashedGaussianActor
26
- from obs import obs_spec
27
- from sim import EPISODE_STEPS, Params
28
- from task import get_reward_preset, sample_train_init
29
-
30
-
31
- def build_policy(ck, algo, obs_dim):
32
- a = ck.get("args") or {}
33
- hidden = int(a.get("hidden", 128 if algo == "ppo" else 256))
34
- depth = int(a.get("depth", 2))
35
- if algo == "ppo":
36
- net = ActorCritic(obs_dim, act_dim=2, hidden=hidden, depth=depth)
37
- net.load_state_dict(ck["model"])
38
- return make_ppo_policy(net.actor), net
39
- net = SquashedGaussianActor(obs_dim, 2, hidden=hidden, depth=depth)
40
- net.load_state_dict(ck["model"])
41
- return make_sac_policy(net), net
42
-
43
-
44
- def parse_curve(log_path):
45
- rows = []
46
- if not os.path.exists(log_path):
47
- return rows
48
- pat = re.compile(
49
- r"iter\s+(\d+)\s+steps\s+([\d.]+)M.*?EVAL tc\s+([\d.]+)\s+"
50
- r"succ\s+(\d+)\s+everup\s+(\d+)\s+angerr\s+([\d.]+)\s+"
51
- r"ret\s+([-\d.]+)")
52
- pat_sac = re.compile(
53
- r"steps\s+([\d.]+)k\s+upd\s+(\d+).*?EVAL tc\s+([\d.]+)\s+"
54
- r"succ\s+(\d+)\s+everup\s+(\d+)\s+angerr\s+([\d.]+)\s+"
55
- r"ret\s+([-\d.]+)")
56
- for line in open(log_path, errors="ignore"):
57
- m = pat.search(line)
58
- if m:
59
- rows.append(dict(iter=int(m.group(1)),
60
- global_steps=int(float(m.group(2)) * 1e6),
61
- t_can=float(m.group(3)),
62
- success=float(m.group(4)),
63
- ever_up=float(m.group(5)),
64
- final_ang_err=float(m.group(6)),
65
- ep_return=float(m.group(7))))
66
- continue
67
- m = pat_sac.search(line)
68
- if m:
69
- rows.append(dict(global_steps=int(float(m.group(1)) * 1e3),
70
- n_updates=int(m.group(2)),
71
- t_can=float(m.group(3)),
72
- success=float(m.group(4)),
73
- ever_up=float(m.group(5)),
74
- final_ang_err=float(m.group(6)),
75
- ep_return=float(m.group(7))))
76
- return rows
77
-
78
-
79
- def tb_last(tb_root, exp, tag):
80
- from tensorboard.backend.event_processing.event_accumulator import (
81
- EventAccumulator)
82
- d = Path(tb_root) / exp
83
- if not d.is_dir():
84
- return None
85
- ea = EventAccumulator(str(d), size_guidance={"scalars": 0})
86
- ea.Reload()
87
- if tag not in ea.Tags().get("scalars", []):
88
- return None
89
- return ea.Scalars(tag)[-1].value
90
-
91
-
92
- def main():
93
- ap = argparse.ArgumentParser()
94
- ap.add_argument("--exp", required=True)
95
- ap.add_argument("--algo", required=True, choices=["ppo", "sac"])
96
- ap.add_argument("--obs", required=True)
97
- ap.add_argument("--reward", required=True)
98
- ap.add_argument("--init", required=True)
99
- ap.add_argument("--envs", type=int, required=True)
100
- ap.add_argument("--minutes", type=float, required=True)
101
- ap.add_argument("--seed", type=int, default=0)
102
- ap.add_argument("--ckpt-dir", default="checkpoints")
103
- ap.add_argument("--tb-root", default="tb")
104
- ap.add_argument("--out", default="results")
105
- ap.add_argument("--log-dir", default="logs")
106
- ap.add_argument("--robust-starts", type=int, default=8)
107
- args = ap.parse_args()
108
-
109
- ck_final = torch.load(os.path.join(args.ckpt_dir, args.exp + "_final.pt"),
110
- map_location="cpu")
111
- ck_best = torch.load(os.path.join(args.ckpt_dir, args.exp + "_best.pt"),
112
- map_location="cpu")
113
- obs_dim = ck_final["obs_dim"]
114
- spec = ck_final.get("obs") or obs_spec(args.obs)
115
- reward_w = ck_final.get("reward") or get_reward_preset(args.reward)
116
- p = Params()
117
- pol_final, _ = build_policy(ck_final, args.algo, obs_dim)
118
- pol_best, _ = build_policy(ck_best, args.algo, obs_dim)
119
- ev_final = evaluate(pol_final, p, EPISODE_STEPS, reward_w=reward_w,
120
- obs_mode=args.obs)
121
- ev_best = evaluate(pol_best, p, EPISODE_STEPS, reward_w=reward_w,
122
- obs_mode=args.obs)
123
-
124
- g = torch.Generator().manual_seed(args.seed * 1000 + 123)
125
- th, om = sample_train_init(args.robust_starts, gen=g)
126
- starts = list(zip(th.numpy(), om.numpy()))
127
- rob = evaluate_starts(pol_final, p, starts, EPISODE_STEPS,
128
- reward_w=reward_w, obs_mode=args.obs)
129
- rob_best = evaluate_starts(pol_best, p, starts, EPISODE_STEPS,
130
- reward_w=reward_w, obs_mode=args.obs)
131
-
132
- curve = parse_curve(os.path.join(args.log_dir, args.exp + ".log"))
133
- elapsed = tb_last(args.tb_root, args.exp, "time/elapsed_min")
134
- wall = float(elapsed) * 60.0 if elapsed is not None else 0.0
135
- steps = ck_final.get("global_steps")
136
- if not steps:
137
- steps = tb_last(args.tb_root, args.exp, "train/global_steps")
138
- out = dict(algo=args.algo, exp=args.exp, seed=args.seed,
139
- envs=args.envs, obs=spec, obs_dim=obs_dim,
140
- init=ck_final.get("init"), reward=reward_w,
141
- reward_preset=args.reward, args=ck_final.get("args"),
142
- minutes_budget=args.minutes, global_steps=int(steps or 0),
143
- wall_time_s=wall,
144
- steps_per_s=(float(steps) / wall) if wall > 0 else None,
145
- final_eval=ev_final, best_eval=ev_best,
146
- best_t_can=ev_best["t_can"], eval_curve=curve,
147
- robust=rob, robust_best=rob_best,
148
- checkpoints=dict(best=args.exp + "_best.pt",
149
- last=args.exp + "_last.pt",
150
- final=args.exp + "_final.pt"),
151
- reconstructed=True,
152
- reconstruct_note=("results JSON reconstructed by finalize_run.py "
153
- "after the trainer crashed post-checkpoint; "
154
- "fixed eval and robustness were re-run."))
155
- with open(os.path.join(args.out, args.exp + ".json"), "w") as f:
156
- json.dump(out, f, indent=2)
157
- print(json.dumps(dict(exp=args.exp, steps=out["global_steps"],
158
- wall_min=round(wall / 60.0, 1),
159
- final_t_can=ev_final["t_can"],
160
- best_t_can=ev_best["t_can"],
161
- final_success=ev_final["success"],
162
- best_success=ev_best["success"],
163
- robust_final=rob["success_rate"],
164
- robust_best=rob_best["success_rate"]), indent=2))
165
-
166
-
167
- if __name__ == "__main__":
168
- main()
@@ -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()