Skip to content

Commit de615cd

Browse files
authored
Merge pull request dnv-opensource#2 from aleksandarbabicdnv/refactor/split-test-files
Refactor/split test files
2 parents 6ac4085 + 43c6337 commit de615cd

15 files changed

Lines changed: 604 additions & 398 deletions

README.rst

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,14 +70,91 @@ Installation
7070
Running
7171
-------
7272

73-
Training and evaluation are driven through the test suite (run with ``pytest``) or by executing
74-
the test files directly as scripts. The main test file is ``tests/test_crane_pendulum.py``.
73+
Install dependencies and run the test suite with ``uv``:
7574

7675
.. code-block:: shell
7776
78-
pytest tests/test_crane_pendulum.py -v
77+
uv run pytest tests/ -v
7978
80-
Saved models (PPO ``.zip``) and Q-tables (``.json``) are stored in the ``tests/`` directory.
79+
Test files are organised by algorithm:
80+
81+
- ``tests/test_crane_pendulum.py`` — environment, Q-learning, and algorithm tests
82+
- ``tests/test_ppo.py`` — PPO pipeline smoke test (``test_monitor``)
83+
84+
Tests are suitable for CI/CD — no plot windows are produced.
85+
86+
Training
87+
--------
88+
89+
**PPO:**
90+
91+
.. code-block:: shell
92+
93+
uv run python scripts/train_ppo.py
94+
95+
Key options:
96+
97+
- ``--steps N`` — total training timesteps (default: 100 000)
98+
- ``--n-envs N`` — number of parallel environments (default: 4)
99+
- ``--save-path PATH`` — where to write the trained model (default: ``models/ppo_AntiPendulumEnv.zip``)
100+
- ``--dry-run`` — run 1 000 steps with a live reward-tracking plot and no model saved
101+
102+
**Q-learning:**
103+
104+
.. code-block:: shell
105+
106+
uv run python scripts/train_q.py
107+
108+
Key options:
109+
110+
- ``--episodes N`` — total training episodes (default: 10 000)
111+
- ``--v0 F`` — initial crane speed; negative = stop mode, positive = start mode (default: ``-1.0``)
112+
- ``--reward-limit F`` — per-episode termination threshold (default: ``-0.05``)
113+
- ``--save-path PATH`` — where to write the Q-table (default: ``models/q_AntiPendulumEnv.json``)
114+
- ``--trained PATH`` — continue training from an existing Q-table JSON
115+
- ``--intervals N`` — run interval training: N rounds of 10 episodes each
116+
- ``--dry-run`` — run 50 episodes with a reward plot and no model saved
117+
118+
Playing
119+
-------
120+
121+
Run a trained agent visually. Both scripts accept ``--render-mode`` with the following options:
122+
123+
- ``plot`` — 4-panel figure per episode (load angle, crane position/speed, rewards)
124+
- ``play-back`` — animated crane trajectory after each episode
125+
- ``reward-tracking`` — live reward line plot updating every step
126+
127+
**PPO** (default render-mode: ``play-back``):
128+
129+
.. code-block:: shell
130+
131+
uv run python scripts/play_ppo.py --model-path models/ppo_AntiPendulumEnv.zip
132+
uv run python scripts/play_ppo.py --model-path models/ppo_AntiPendulumEnv.zip --render-mode plot --episodes 3
133+
134+
**Q-learning** (default render-mode: ``plot``):
135+
136+
.. code-block:: shell
137+
138+
uv run python scripts/play_q.py --model-path models/q_AntiPendulumEnv.json
139+
uv run python scripts/play_q.py --model-path tests/anti-pendulum.json --render-mode play-back --episodes 3
140+
141+
Analysing
142+
---------
143+
144+
Inspect a trained Q-table without running the environment:
145+
146+
.. code-block:: shell
147+
148+
uv run python scripts/analyse_q.py --model-path tests/anti-pendulum.json
149+
150+
Prints per-pos/speed average Q-values for a quick sanity check. To drill into
151+
specific states, use ``--obs`` with 5 integers (use ``-1`` as a wildcard):
152+
153+
.. code-block:: shell
154+
155+
uv run python scripts/analyse_q.py --model-path tests/anti-pendulum.json --obs -1 0 0 -1 -1
156+
157+
The five observation dimensions are: ``[energy, pos, speed, distance, sector]``.
81158

82159
Contributing
83160
------------

models/.gitkeep

Whitespace-only changes.

scripts/analyse_q.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Inspect a trained Q-table for the AntiPendulumEnv.
2+
3+
Prints a per-pos/speed average summary by default. Use --obs to drill
4+
into specific states; negative values act as wildcards (match any).
5+
6+
The observation tuple has 5 dimensions:
7+
[energy, pos, speed, distance, sector]
8+
9+
Example:
10+
uv run python scripts/analyse_q.py --model-path tests/anti-pendulum.json
11+
uv run python scripts/analyse_q.py --model-path tests/anti-pendulum.json --obs -1 0 0 -1 -1
12+
uv run python scripts/analyse_q.py --model-path tests/anti-pendulum.json --obs -1 1 1 -1 -1
13+
"""
14+
15+
import argparse
16+
17+
import numpy as np
18+
19+
from crane_controller.crane_factory import build_crane
20+
from crane_controller.envs.controlled_crane_pendulum import AntiPendulumEnv
21+
from crane_controller.q_agent import QLearningAgent
22+
23+
24+
def _build_dummy_env():
25+
"""Minimal env needed to satisfy QLearningAgent constructor (action_space.n)."""
26+
return AntiPendulumEnv(build_crane, discrete=QLearningAgent.DEFAULT_DISCRETE.copy())
27+
28+
29+
def main():
30+
parser = argparse.ArgumentParser(description="Inspect a trained Q-table for the crane anti-pendulum task.")
31+
parser.add_argument("--model-path", type=str, required=True, help="Path to a trained Q-table JSON")
32+
parser.add_argument(
33+
"--obs",
34+
type=int,
35+
nargs=5,
36+
metavar=("ENERGY", "POS", "SPEED", "DISTANCE", "SECTOR"),
37+
help="Filter Q-values for a specific observation (use -1 as wildcard)",
38+
)
39+
args = parser.parse_args()
40+
41+
env = _build_dummy_env()
42+
agent = QLearningAgent(env, trained=(args.model_path, True))
43+
44+
print(f"Q-table: {len(agent.q_values)} states ({args.model_path})")
45+
print()
46+
47+
if args.obs:
48+
print(f"Q-values matching obs {args.obs} (columns: state, q-values, best-action, mean, cv)")
49+
print("-" * 72)
50+
agent.analyse_q(tuple(args.obs))
51+
else:
52+
print("Per-pos/speed average Q-values (actions: left=0, coast=1, right=2)")
53+
print("-" * 52)
54+
for pos in (0, 1):
55+
for speed in (0, 1):
56+
res = {k: v for k, v in agent.q_values.items() if k[1] == pos and k[2] == speed}
57+
avgs = [np.average([x[i] for x in res.values()]) for i in range(3)]
58+
print(f" pos={pos} speed={speed} -> {[f'{a:.4f}' for a in avgs]}")
59+
60+
61+
if __name__ == "__main__":
62+
main()

scripts/play_ppo.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Run a trained PPO agent on the AntiPendulumEnv.
2+
3+
Example:
4+
uv run python scripts/play_ppo.py --model-path models/ppo_AntiPendulumEnv.zip
5+
uv run python scripts/play_ppo.py --model-path models/ppo.zip --render-mode plot --episodes 3
6+
"""
7+
8+
import argparse
9+
10+
from crane_controller.crane_factory import build_crane
11+
from crane_controller.envs.controlled_crane_pendulum import AntiPendulumEnv
12+
from crane_controller.ppo_agent import ProximalPolicyOptimizationAgent
13+
14+
15+
def main():
16+
parser = argparse.ArgumentParser(description="Run a trained PPO agent on the crane anti-pendulum task.")
17+
parser.add_argument("--model-path", type=str, required=True, help="Path to a trained .zip model")
18+
parser.add_argument("--render-mode", type=str, default="play-back", help="Render mode for playback")
19+
parser.add_argument("--episodes", type=int, default=1, help="Number of episodes to run")
20+
args = parser.parse_args()
21+
22+
agent = ProximalPolicyOptimizationAgent(
23+
AntiPendulumEnv, # type: ignore[arg-type]
24+
n_envs=0, # load-from-file mode
25+
env_kwargs={
26+
"crane": build_crane,
27+
"start_speed": 1.0,
28+
"render_mode": args.render_mode,
29+
},
30+
trained=(args.model_path, True),
31+
)
32+
33+
for episode in range(args.episodes):
34+
print(f"Episode {episode + 1}/{args.episodes}")
35+
agent.do_one_episode(seed=episode + 1)
36+
37+
38+
if __name__ == "__main__":
39+
main()

scripts/play_q.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""Run a trained Q-learning agent on the AntiPendulumEnv.
2+
3+
Example:
4+
uv run python scripts/play_q.py --model-path models/q_AntiPendulumEnv.json
5+
uv run python scripts/play_q.py --model-path tests/anti-pendulum.json --render-mode plot --episodes 3
6+
"""
7+
8+
import argparse
9+
10+
from crane_controller.crane_factory import build_crane
11+
from crane_controller.envs.controlled_crane_pendulum import AntiPendulumEnv
12+
from crane_controller.q_agent import QLearningAgent
13+
14+
15+
def main():
16+
parser = argparse.ArgumentParser(description="Run a trained Q-learning agent on the crane anti-pendulum task.")
17+
parser.add_argument("--model-path", type=str, required=True, help="Path to a trained Q-table JSON")
18+
parser.add_argument("--render-mode", type=str, default="plot", help="Render mode (plot, play-back, reward-tracking)")
19+
parser.add_argument("--episodes", type=int, default=1, help="Number of episodes to run")
20+
parser.add_argument("--v0", type=float, default=-1.0, help="Initial crane speed (negative = stop mode)")
21+
args = parser.parse_args()
22+
23+
env = AntiPendulumEnv(
24+
build_crane,
25+
start_speed=args.v0,
26+
render_mode=args.render_mode,
27+
discrete=QLearningAgent.DEFAULT_DISCRETE.copy(),
28+
)
29+
agent = QLearningAgent(env, trained=(args.model_path, True))
30+
31+
for episode in range(args.episodes):
32+
print(f"Episode {episode + 1}/{args.episodes}")
33+
env.reset(seed=episode + 1)
34+
agent.do_episodes(n_episodes=1)
35+
36+
37+
if __name__ == "__main__":
38+
main()

scripts/train_ppo.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Train a PPO agent on the AntiPendulumEnv.
2+
3+
Example:
4+
uv run python scripts/train_ppo.py
5+
uv run python scripts/train_ppo.py --steps 500000 --n-envs 8 --save-path models/ppo.zip
6+
uv run python scripts/train_ppo.py --dry-run
7+
"""
8+
9+
import argparse
10+
from pathlib import Path
11+
12+
from crane_controller.crane_factory import build_crane
13+
from crane_controller.envs.controlled_crane_pendulum import AntiPendulumEnv
14+
from crane_controller.ppo_agent import ProximalPolicyOptimizationAgent
15+
16+
17+
def main():
18+
parser = argparse.ArgumentParser(description="Train a PPO agent on the crane anti-pendulum task.")
19+
parser.add_argument("--steps", type=int, default=100_000, help="Total training timesteps")
20+
parser.add_argument("--n-envs", type=int, default=4, help="Number of parallel environments")
21+
parser.add_argument("--render-mode", type=str, default="none", help="Render mode during training")
22+
parser.add_argument(
23+
"--save-path",
24+
type=str,
25+
default="models/ppo_AntiPendulumEnv.zip",
26+
help="Where to save the trained model",
27+
)
28+
parser.add_argument(
29+
"--dry-run",
30+
action="store_true",
31+
help="Run 1000 steps with live reward-tracking plot, without saving the model.",
32+
)
33+
args = parser.parse_args()
34+
35+
if args.dry_run:
36+
agent = ProximalPolicyOptimizationAgent(
37+
AntiPendulumEnv, # type: ignore[arg-type]
38+
n_envs=1,
39+
env_kwargs={
40+
"crane": build_crane,
41+
"start_speed": -1.0,
42+
"render_mode": "reward-tracking",
43+
},
44+
trained=None,
45+
)
46+
agent.do_training(1000, progress_bar=False)
47+
else:
48+
Path(args.save_path).parent.mkdir(parents=True, exist_ok=True)
49+
agent = ProximalPolicyOptimizationAgent(
50+
AntiPendulumEnv, # type: ignore[arg-type]
51+
n_envs=args.n_envs,
52+
env_kwargs={
53+
"crane": build_crane,
54+
"start_speed": 1.0,
55+
"render_mode": args.render_mode,
56+
},
57+
trained=(args.save_path, True),
58+
)
59+
agent.do_training(args.steps)
60+
print(f"Model saved to {args.save_path}")
61+
62+
63+
if __name__ == "__main__":
64+
main()

scripts/train_q.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Train a Q-learning agent on the AntiPendulumEnv.
2+
3+
Example:
4+
uv run python scripts/train_q.py
5+
uv run python scripts/train_q.py --episodes 50000 --v0 1.0 --save-path models/q_start.json
6+
uv run python scripts/train_q.py --trained models/q_AntiPendulumEnv.json
7+
uv run python scripts/train_q.py --intervals 10
8+
uv run python scripts/train_q.py --dry-run
9+
"""
10+
11+
import argparse
12+
from pathlib import Path
13+
14+
from crane_controller.crane_factory import build_crane
15+
from crane_controller.envs.controlled_crane_pendulum import AntiPendulumEnv
16+
from crane_controller.q_agent import QLearningAgent
17+
18+
19+
def main():
20+
parser = argparse.ArgumentParser(description="Train a Q-learning agent on the crane anti-pendulum task.")
21+
parser.add_argument("--episodes", type=int, default=10_000, help="Total training episodes")
22+
parser.add_argument("--v0", type=float, default=-1.0, help="Initial crane speed (negative = stop mode)")
23+
parser.add_argument("--reward-limit", type=float, default=-0.05, help="Per-episode reward termination threshold")
24+
parser.add_argument(
25+
"--save-path",
26+
type=str,
27+
default="models/q_AntiPendulumEnv.json",
28+
help="Where to save the trained Q-table",
29+
)
30+
parser.add_argument("--trained", type=str, default=None, help="Path to an existing Q-table JSON to continue from")
31+
parser.add_argument(
32+
"--intervals",
33+
type=int,
34+
default=0,
35+
help="Run interval training: N intervals of 10 episodes each (0 = disabled)",
36+
)
37+
parser.add_argument(
38+
"--dry-run",
39+
action="store_true",
40+
help="Run 50 episodes with a reward plot and no model saved, for a quick visual sanity check.",
41+
)
42+
args = parser.parse_args()
43+
44+
env = AntiPendulumEnv(
45+
build_crane,
46+
start_speed=args.v0,
47+
render_mode="plot" if args.dry_run else "none",
48+
reward_limit=args.reward_limit,
49+
discrete=QLearningAgent.DEFAULT_DISCRETE.copy(),
50+
)
51+
52+
if args.dry_run:
53+
agent = QLearningAgent(env, trained=None)
54+
agent.do_episodes(n_episodes=50, max_steps=1000)
55+
56+
elif args.intervals > 0:
57+
Path(args.save_path).parent.mkdir(parents=True, exist_ok=True)
58+
agent = QLearningAgent(env, trained=(args.save_path, False))
59+
for i in range(args.intervals):
60+
env.reset(seed=i + 1)
61+
agent.do_episodes(n_episodes=10)
62+
if i == 0:
63+
agent = QLearningAgent(env, trained=(args.save_path, True))
64+
print(f"Model saved to {args.save_path}")
65+
66+
else:
67+
Path(args.save_path).parent.mkdir(parents=True, exist_ok=True)
68+
trained = (args.trained, True) if args.trained else (args.save_path, False)
69+
agent = QLearningAgent(env, trained=trained)
70+
agent.do_episodes(n_episodes=args.episodes, max_steps=5000)
71+
print(f"Model saved to {args.save_path}")
72+
73+
74+
if __name__ == "__main__":
75+
main()

0 commit comments

Comments
 (0)