vbar 0.0.1__tar.gz
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.
- vbar-0.0.1/.github/workflows/ci.yml +18 -0
- vbar-0.0.1/.gitignore +8 -0
- vbar-0.0.1/CITATION.cff +8 -0
- vbar-0.0.1/LICENSE +21 -0
- vbar-0.0.1/PKG-INFO +103 -0
- vbar-0.0.1/README.md +75 -0
- vbar-0.0.1/examples/v0_demo.py +60 -0
- vbar-0.0.1/pyproject.toml +44 -0
- vbar-0.0.1/src/vbar/__init__.py +7 -0
- vbar-0.0.1/src/vbar/agents/__init__.py +5 -0
- vbar-0.0.1/src/vbar/agents/base.py +29 -0
- vbar-0.0.1/src/vbar/agents/llm.py +100 -0
- vbar-0.0.1/src/vbar/agents/pd.py +40 -0
- vbar-0.0.1/src/vbar/dynamics/__init__.py +3 -0
- vbar-0.0.1/src/vbar/dynamics/cw.py +118 -0
- vbar-0.0.1/src/vbar/envs/__init__.py +3 -0
- vbar-0.0.1/src/vbar/envs/approach.py +139 -0
- vbar-0.0.1/src/vbar/runner.py +43 -0
- vbar-0.0.1/src/vbar/safety/__init__.py +48 -0
- vbar-0.0.1/src/vbar/viz.py +38 -0
- vbar-0.0.1/tests/test_cw.py +68 -0
- vbar-0.0.1/tests/test_env.py +78 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
name: tests
|
|
2
|
+
|
|
3
|
+
on: [push, pull_request]
|
|
4
|
+
|
|
5
|
+
jobs:
|
|
6
|
+
test:
|
|
7
|
+
strategy:
|
|
8
|
+
matrix:
|
|
9
|
+
os: [ubuntu-latest, macos-latest, windows-latest]
|
|
10
|
+
python: ["3.10", "3.12"]
|
|
11
|
+
runs-on: ${{ matrix.os }}
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- uses: actions/setup-python@v5
|
|
15
|
+
with:
|
|
16
|
+
python-version: ${{ matrix.python }}
|
|
17
|
+
- run: pip install -e ".[dev]"
|
|
18
|
+
- run: pytest -q
|
vbar-0.0.1/.gitignore
ADDED
vbar-0.0.1/CITATION.cff
ADDED
vbar-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jamil Hammoudeh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
vbar-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: vbar
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: A fast, headless, cross-platform benchmark for testing AI and classical spacecraft controllers, with safety filters.
|
|
5
|
+
Project-URL: Homepage, https://github.com/jamilhammoudeh/vbar
|
|
6
|
+
Project-URL: Repository, https://github.com/jamilhammoudeh/vbar
|
|
7
|
+
Project-URL: Issues, https://github.com/jamilhammoudeh/vbar/issues
|
|
8
|
+
Author-email: Jamil Hammoudeh <j.hammoudeh@outlook.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: benchmark,clohessy-wiltshire,guidance navigation and control,orbital mechanics,proximity operations,reinforcement learning,rendezvous,safety filter,spacecraft
|
|
12
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: gymnasium>=0.29
|
|
20
|
+
Requires-Dist: numpy>=1.24
|
|
21
|
+
Requires-Dist: scipy>=1.10
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: matplotlib>=3.7; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
25
|
+
Provides-Extra: plot
|
|
26
|
+
Requires-Dist: matplotlib>=3.7; extra == 'plot'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# V-BAR: Verified Benchmark for Autonomous Rendezvous
|
|
30
|
+
|
|
31
|
+
`vbar` is a fast, headless, cross-platform benchmark for testing AI and classical spacecraft controllers, with and without safety filters.
|
|
32
|
+
|
|
33
|
+
> **Status: v0 (early development).** One scenario, validated close-range dynamics, classical and LLM agent interfaces.
|
|
34
|
+
|
|
35
|
+
## Why
|
|
36
|
+
|
|
37
|
+
Testing AI controllers for spacecraft rendezvous today usually means running a full game engine (KSPDG, built on Kerbal Space Program): a paid game, GUI-only, macOS/Linux, real-time. That makes large, statistically meaningful experiments slow. V-BAR runs anywhere Python runs, with no game, and simulates an hour of flight in a fraction of a second.
|
|
38
|
+
|
|
39
|
+
Goals:
|
|
40
|
+
|
|
41
|
+
- **Fair comparisons:** the same scenarios, metrics, and number of runs for every controller: classical, reinforcement learning, or LLM.
|
|
42
|
+
- **Safety as a first-class metric:** keep-out violations are checked continuously (including between time steps), and safety filters can be switched on and off.
|
|
43
|
+
- **Reproducible:** seeds, logged prompts and replies, and validated physics.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install -e ".[dev]" # from a clone of this repo
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Quickstart
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from vbar import ApproachConfig, ApproachEnv, run_episode
|
|
55
|
+
from vbar.agents import PDAgent
|
|
56
|
+
|
|
57
|
+
env = ApproachEnv(ApproachConfig(start_state=(0, 400, 0, 0, 0, 0)))
|
|
58
|
+
result = run_episode(env, PDAgent())
|
|
59
|
+
print(result["captured"], result["keep_out_violated"], result["dv_used_mps"])
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Run the demo (saves a trajectory plot to `outputs/`):
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
python examples/v0_demo.py
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Plug in any LLM
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from vbar.agents import LLMAgent
|
|
72
|
+
|
|
73
|
+
def complete(prompt: str) -> str:
|
|
74
|
+
... # call any model and return its text reply
|
|
75
|
+
|
|
76
|
+
agent = LLMAgent(complete, name="my-model")
|
|
77
|
+
result = run_episode(env, agent)
|
|
78
|
+
agent.log # every prompt, reply, parsed action, parse failure, latency
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Scenario: approach to hold point
|
|
82
|
+
|
|
83
|
+
The chaser must reach a hold point 100 m behind the target on the V-bar and stop, without entering a 50 m keep-out sphere around the target.
|
|
84
|
+
|
|
85
|
+
**Dynamics:** Clohessy-Wiltshire relative motion (circular target orbit at 500 km, close range, no perturbations). Integrated numerically (DOP853) and **validated against the exact closed-form solution** to sub-millimeter accuracy over a full orbit (`tests/test_cw.py`).
|
|
86
|
+
|
|
87
|
+
**Metrics (in `info` and the result dict):** capture, keep-out violation (checked between steps), minimum range to target, delta-v used, time, filter interventions.
|
|
88
|
+
|
|
89
|
+
## Validation
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
pytest -q
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Tests check the dynamics against known exact results: the closed-form solution, V-bar equilibrium, closed relative ellipses, and the 12π·|x0| along-track drift per orbit.
|
|
96
|
+
|
|
97
|
+
## Citation
|
|
98
|
+
|
|
99
|
+
See [CITATION.cff](CITATION.cff).
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
MIT
|
vbar-0.0.1/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# V-BAR: Verified Benchmark for Autonomous Rendezvous
|
|
2
|
+
|
|
3
|
+
`vbar` is a fast, headless, cross-platform benchmark for testing AI and classical spacecraft controllers, with and without safety filters.
|
|
4
|
+
|
|
5
|
+
> **Status: v0 (early development).** One scenario, validated close-range dynamics, classical and LLM agent interfaces.
|
|
6
|
+
|
|
7
|
+
## Why
|
|
8
|
+
|
|
9
|
+
Testing AI controllers for spacecraft rendezvous today usually means running a full game engine (KSPDG, built on Kerbal Space Program): a paid game, GUI-only, macOS/Linux, real-time. That makes large, statistically meaningful experiments slow. V-BAR runs anywhere Python runs, with no game, and simulates an hour of flight in a fraction of a second.
|
|
10
|
+
|
|
11
|
+
Goals:
|
|
12
|
+
|
|
13
|
+
- **Fair comparisons:** the same scenarios, metrics, and number of runs for every controller: classical, reinforcement learning, or LLM.
|
|
14
|
+
- **Safety as a first-class metric:** keep-out violations are checked continuously (including between time steps), and safety filters can be switched on and off.
|
|
15
|
+
- **Reproducible:** seeds, logged prompts and replies, and validated physics.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install -e ".[dev]" # from a clone of this repo
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quickstart
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from vbar import ApproachConfig, ApproachEnv, run_episode
|
|
27
|
+
from vbar.agents import PDAgent
|
|
28
|
+
|
|
29
|
+
env = ApproachEnv(ApproachConfig(start_state=(0, 400, 0, 0, 0, 0)))
|
|
30
|
+
result = run_episode(env, PDAgent())
|
|
31
|
+
print(result["captured"], result["keep_out_violated"], result["dv_used_mps"])
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Run the demo (saves a trajectory plot to `outputs/`):
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
python examples/v0_demo.py
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Plug in any LLM
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from vbar.agents import LLMAgent
|
|
44
|
+
|
|
45
|
+
def complete(prompt: str) -> str:
|
|
46
|
+
... # call any model and return its text reply
|
|
47
|
+
|
|
48
|
+
agent = LLMAgent(complete, name="my-model")
|
|
49
|
+
result = run_episode(env, agent)
|
|
50
|
+
agent.log # every prompt, reply, parsed action, parse failure, latency
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Scenario: approach to hold point
|
|
54
|
+
|
|
55
|
+
The chaser must reach a hold point 100 m behind the target on the V-bar and stop, without entering a 50 m keep-out sphere around the target.
|
|
56
|
+
|
|
57
|
+
**Dynamics:** Clohessy-Wiltshire relative motion (circular target orbit at 500 km, close range, no perturbations). Integrated numerically (DOP853) and **validated against the exact closed-form solution** to sub-millimeter accuracy over a full orbit (`tests/test_cw.py`).
|
|
58
|
+
|
|
59
|
+
**Metrics (in `info` and the result dict):** capture, keep-out violation (checked between steps), minimum range to target, delta-v used, time, filter interventions.
|
|
60
|
+
|
|
61
|
+
## Validation
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pytest -q
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Tests check the dynamics against known exact results: the closed-form solution, V-bar equilibrium, closed relative ellipses, and the 12π·|x0| along-track drift per orbit.
|
|
68
|
+
|
|
69
|
+
## Citation
|
|
70
|
+
|
|
71
|
+
See [CITATION.cff](CITATION.cff).
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
MIT
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""v0 demo: why spacecraft controllers need safety filters.
|
|
2
|
+
|
|
3
|
+
Runs the classical PD baseline from two starting points:
|
|
4
|
+
1. A clear path: it reaches the hold point safely.
|
|
5
|
+
2. The target is in the way: it flies straight through the keep-out zone.
|
|
6
|
+
|
|
7
|
+
Then runs 30 random starts and reports the violation rate.
|
|
8
|
+
Saves a plot to outputs/v0_trajectories.png.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import matplotlib
|
|
14
|
+
|
|
15
|
+
matplotlib.use("Agg")
|
|
16
|
+
import matplotlib.pyplot as plt
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
from vbar import ApproachConfig, ApproachEnv, run_episode, run_many
|
|
20
|
+
from vbar.agents import DriftAgent, PDAgent
|
|
21
|
+
from vbar.viz import plot_trajectories
|
|
22
|
+
|
|
23
|
+
out = Path("outputs")
|
|
24
|
+
out.mkdir(exist_ok=True)
|
|
25
|
+
|
|
26
|
+
clear = ApproachEnv(ApproachConfig(start_state=(-150, -400, 0, 0, 0, 0)))
|
|
27
|
+
blocked = ApproachEnv(ApproachConfig(start_state=(0, 400, 0, 0, 0, 0)))
|
|
28
|
+
|
|
29
|
+
r_clear = run_episode(clear, PDAgent())
|
|
30
|
+
r_blocked = run_episode(blocked, PDAgent())
|
|
31
|
+
r_drift = run_episode(blocked, DriftAgent())
|
|
32
|
+
|
|
33
|
+
for name, r in [("clear path", r_clear), ("target in the way", r_blocked), ("drift", r_drift)]:
|
|
34
|
+
print(
|
|
35
|
+
f"{name:18s} captured={r['captured']!s:5s} violated={r['keep_out_violated']!s:5s} "
|
|
36
|
+
f"min_range={r['min_range_to_target_m']:7.1f} m dv={r['dv_used_mps']:.2f} m/s "
|
|
37
|
+
f"sim={r['time_s']:.0f} s in {r['wall_time_s']*1000:.0f} ms"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Batch run: random starts ahead of the target (the hard case).
|
|
41
|
+
env = ApproachEnv()
|
|
42
|
+
results = run_many(env, PDAgent(), seeds=range(30))
|
|
43
|
+
viol = np.mean([r["keep_out_violated"] for r in results])
|
|
44
|
+
cap = np.mean([r["captured"] for r in results])
|
|
45
|
+
dv = np.median([r["dv_used_mps"] for r in results])
|
|
46
|
+
print(f"\n30 random starts: capture rate {cap:.0%}, keep-out violation rate {viol:.0%}, median dv {dv:.2f} m/s")
|
|
47
|
+
|
|
48
|
+
r_clear["agent"] = "PD, clear path"
|
|
49
|
+
r_blocked["agent"] = "PD, target in the way"
|
|
50
|
+
r_drift["agent"] = "drift (no thrust)"
|
|
51
|
+
fig, ax = plt.subplots(figsize=(8, 5))
|
|
52
|
+
plot_trajectories(
|
|
53
|
+
[r_clear, r_blocked, r_drift],
|
|
54
|
+
blocked,
|
|
55
|
+
ax=ax,
|
|
56
|
+
title="Approach to hold point: a naive controller flies through the keep-out zone",
|
|
57
|
+
)
|
|
58
|
+
fig.tight_layout()
|
|
59
|
+
fig.savefig(out / "v0_trajectories.png", dpi=150)
|
|
60
|
+
print(f"\nSaved {out / 'v0_trajectories.png'}")
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vbar"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "A fast, headless, cross-platform benchmark for testing AI and classical spacecraft controllers, with safety filters."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "Jamil Hammoudeh", email = "j.hammoudeh@outlook.com" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"spacecraft", "rendezvous", "proximity operations", "guidance navigation and control",
|
|
15
|
+
"benchmark", "reinforcement learning", "safety filter", "clohessy-wiltshire", "orbital mechanics",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
19
|
+
"Intended Audience :: Science/Research",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Topic :: Scientific/Engineering :: Physics",
|
|
23
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"numpy>=1.24",
|
|
27
|
+
"scipy>=1.10",
|
|
28
|
+
"gymnasium>=0.29",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/jamilhammoudeh/vbar"
|
|
33
|
+
Repository = "https://github.com/jamilhammoudeh/vbar"
|
|
34
|
+
Issues = "https://github.com/jamilhammoudeh/vbar/issues"
|
|
35
|
+
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
plot = ["matplotlib>=3.7"]
|
|
38
|
+
dev = ["pytest>=7", "matplotlib>=3.7"]
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.wheel]
|
|
41
|
+
packages = ["src/vbar"]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""vbar: a fast, headless benchmark for AI and classical spacecraft controllers."""
|
|
2
|
+
|
|
3
|
+
from .envs import ApproachConfig, ApproachEnv
|
|
4
|
+
from .runner import run_episode, run_many
|
|
5
|
+
|
|
6
|
+
__version__ = "0.0.1"
|
|
7
|
+
__all__ = ["ApproachConfig", "ApproachEnv", "run_episode", "run_many"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Agent interface. Every controller (classical, learned, or LLM) implements this."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Agent(Protocol):
|
|
11
|
+
name: str
|
|
12
|
+
|
|
13
|
+
def reset(self, env) -> None:
|
|
14
|
+
"""Called at the start of each episode. `env` gives access to its config."""
|
|
15
|
+
|
|
16
|
+
def act(self, obs: np.ndarray, info: dict) -> np.ndarray:
|
|
17
|
+
"""Return an action: 3 numbers in [-1, 1] (fraction of max thrust per axis)."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DriftAgent:
|
|
21
|
+
"""Does nothing. Useful as a sanity check: the chaser just drifts."""
|
|
22
|
+
|
|
23
|
+
name = "drift"
|
|
24
|
+
|
|
25
|
+
def reset(self, env) -> None:
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
def act(self, obs, info):
|
|
29
|
+
return np.zeros(3)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""LLM agent: asks a language model for a thrust command at each decision step.
|
|
2
|
+
|
|
3
|
+
This module does not depend on any model provider. You pass in a function
|
|
4
|
+
`complete(prompt: str) -> str` that calls whatever model you want (a hosted
|
|
5
|
+
API or a local model). That keeps the benchmark provider-neutral and makes
|
|
6
|
+
the exact prompt part of the logged, reproducible record.
|
|
7
|
+
|
|
8
|
+
Every call's prompt, raw reply, parsed action, parse failures, and wall-clock
|
|
9
|
+
latency are logged in `self.log`.
|
|
10
|
+
|
|
11
|
+
Note: in v0 the simulation pauses while the model thinks. A "real-time" mode,
|
|
12
|
+
where the clock keeps running during the model call (as in KSPDG), is planned.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import time
|
|
20
|
+
from typing import Callable
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
PROMPT_TEMPLATE = """You are the guidance system of a chaser spacecraft near a target spacecraft.
|
|
25
|
+
|
|
26
|
+
Frame (centered on the target): x = radial (up, away from Earth), y = along-track
|
|
27
|
+
(direction of flight), z = cross-track. Units: meters and meters per second.
|
|
28
|
+
|
|
29
|
+
Goal: reach the hold point {hold} m and stop there (speed below {cap_speed} m/s).
|
|
30
|
+
Constraint: NEVER come within {keep_out} m of the target (the origin).
|
|
31
|
+
Orbital mechanics apply (Clohessy-Wiltshire dynamics, mean motion n = {n:.6f} rad/s).
|
|
32
|
+
|
|
33
|
+
Current state:
|
|
34
|
+
position = [{x:.1f}, {y:.1f}, {z:.1f}] m
|
|
35
|
+
velocity = [{vx:.3f}, {vy:.3f}, {vz:.3f}] m/s
|
|
36
|
+
distance to hold point = {dist:.1f} m, distance to target = {rng:.1f} m
|
|
37
|
+
|
|
38
|
+
Each command is held for {dt:.0f} s. Thrust per axis is a fraction of the maximum
|
|
39
|
+
({max_accel} m/s^2), between -1 and 1.
|
|
40
|
+
|
|
41
|
+
Reply with ONLY a JSON object, for example: {{"ax": 0.1, "ay": -0.5, "az": 0.0}}"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_action(text: str) -> np.ndarray | None:
|
|
45
|
+
"""Extract {"ax":..,"ay":..,"az":..} from a model reply. Returns None on failure."""
|
|
46
|
+
match = re.search(r"\{.*?\}", text, flags=re.DOTALL)
|
|
47
|
+
if not match:
|
|
48
|
+
return None
|
|
49
|
+
try:
|
|
50
|
+
data = json.loads(match.group(0))
|
|
51
|
+
a = np.array([float(data["ax"]), float(data["ay"]), float(data["az"])])
|
|
52
|
+
except (ValueError, KeyError, TypeError):
|
|
53
|
+
return None
|
|
54
|
+
if not np.all(np.isfinite(a)):
|
|
55
|
+
return None
|
|
56
|
+
return np.clip(a, -1.0, 1.0)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class LLMAgent:
|
|
60
|
+
def __init__(self, complete: Callable[[str], str], name: str = "llm"):
|
|
61
|
+
self.complete = complete
|
|
62
|
+
self.name = name
|
|
63
|
+
self.log: list[dict] = []
|
|
64
|
+
|
|
65
|
+
def reset(self, env) -> None:
|
|
66
|
+
self.env = env
|
|
67
|
+
self.log = []
|
|
68
|
+
|
|
69
|
+
def act(self, obs, info):
|
|
70
|
+
cfg = self.env.cfg
|
|
71
|
+
x, y, z, vx, vy, vz = obs
|
|
72
|
+
prompt = PROMPT_TEMPLATE.format(
|
|
73
|
+
hold=list(cfg.hold_point),
|
|
74
|
+
cap_speed=cfg.capture_speed,
|
|
75
|
+
keep_out=cfg.keep_out_radius,
|
|
76
|
+
n=self.env.n,
|
|
77
|
+
x=x, y=y, z=z, vx=vx, vy=vy, vz=vz,
|
|
78
|
+
dist=info["dist_to_hold_m"],
|
|
79
|
+
rng=info["range_to_target_m"],
|
|
80
|
+
dt=cfg.dt,
|
|
81
|
+
max_accel=cfg.max_accel,
|
|
82
|
+
)
|
|
83
|
+
t0 = time.perf_counter()
|
|
84
|
+
reply = self.complete(prompt)
|
|
85
|
+
latency = time.perf_counter() - t0
|
|
86
|
+
action = parse_action(reply)
|
|
87
|
+
failed = action is None
|
|
88
|
+
if failed:
|
|
89
|
+
action = np.zeros(3) # safe default: coast
|
|
90
|
+
self.log.append(
|
|
91
|
+
{
|
|
92
|
+
"time_s": info["time_s"],
|
|
93
|
+
"prompt": prompt,
|
|
94
|
+
"reply": reply,
|
|
95
|
+
"action": action.tolist(),
|
|
96
|
+
"parse_failed": failed,
|
|
97
|
+
"latency_s": latency,
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
return action
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Classical baseline: proportional-derivative (PD) controller with CW cancellation.
|
|
2
|
+
|
|
3
|
+
Idea: the CW equations add "orbital" accelerations (3n^2 x + 2n vy, -2n vx,
|
|
4
|
+
-n^2 z). We cancel them, then add a PD law that pulls the chaser to the hold
|
|
5
|
+
point like a damped spring:
|
|
6
|
+
|
|
7
|
+
a = -(CW terms) - Kp * (position - hold_point) - Kd * velocity
|
|
8
|
+
|
|
9
|
+
With Kp = wn^2 and Kd = 2 * zeta * wn, the error behaves like a damped
|
|
10
|
+
oscillator with natural frequency wn and damping ratio zeta.
|
|
11
|
+
|
|
12
|
+
This baseline flies (roughly) a straight line to the hold point. It does NOT
|
|
13
|
+
know about the keep-out zone, so if the target sits between the start and the
|
|
14
|
+
hold point, it will fly through the keep-out zone. That is the point: it shows
|
|
15
|
+
why a safety filter is needed.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PDAgent:
|
|
24
|
+
def __init__(self, wn: float = 0.01, zeta: float = 1.0, name: str = "pd"):
|
|
25
|
+
self.wn = wn
|
|
26
|
+
self.zeta = zeta
|
|
27
|
+
self.name = name
|
|
28
|
+
|
|
29
|
+
def reset(self, env) -> None:
|
|
30
|
+
self.n = env.n
|
|
31
|
+
self.hold = env.hold_point
|
|
32
|
+
self.max_accel = env.cfg.max_accel
|
|
33
|
+
|
|
34
|
+
def act(self, obs, info):
|
|
35
|
+
x, y, z, vx, vy, vz = obs
|
|
36
|
+
n = self.n
|
|
37
|
+
cw = np.array([3 * n**2 * x + 2 * n * vy, -2 * n * vx, -(n**2) * z])
|
|
38
|
+
kp, kd = self.wn**2, 2 * self.zeta * self.wn
|
|
39
|
+
a = -cw - kp * (obs[:3] - self.hold) - kd * obs[3:]
|
|
40
|
+
return np.clip(a / self.max_accel, -1.0, 1.0)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Clohessy-Wiltshire (CW) relative motion.
|
|
2
|
+
|
|
3
|
+
The CW equations describe how a "chaser" spacecraft moves relative to a
|
|
4
|
+
"target" spacecraft that is in a circular orbit, when the two are close
|
|
5
|
+
together (meters to a few kilometers).
|
|
6
|
+
|
|
7
|
+
Frame (Hill / LVLH frame, centered on the target):
|
|
8
|
+
x : radial, pointing away from Earth (the "R-bar")
|
|
9
|
+
y : along-track, the direction the target is flying (the "V-bar")
|
|
10
|
+
z : cross-track, completing a right-handed frame (orbit normal)
|
|
11
|
+
|
|
12
|
+
State vector: [x, y, z, vx, vy, vz] in meters and meters per second.
|
|
13
|
+
|
|
14
|
+
Equations of motion (n = mean motion of the target orbit, a = thrust accel):
|
|
15
|
+
|
|
16
|
+
x'' = 3 n^2 x + 2 n y' + a_x
|
|
17
|
+
y'' = -2 n x' + a_y
|
|
18
|
+
z'' = -n^2 z + a_z
|
|
19
|
+
|
|
20
|
+
Assumptions (state these in any paper that uses this model):
|
|
21
|
+
1. Target orbit is circular.
|
|
22
|
+
2. Chaser is close to the target compared with the orbit radius
|
|
23
|
+
(the equations come from linearizing two-body gravity).
|
|
24
|
+
3. No perturbations: no J2, drag, or solar pressure.
|
|
25
|
+
|
|
26
|
+
Reference: H. Curtis, "Orbital Mechanics for Engineering Students", ch. 7.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
from scipy.integrate import solve_ivp
|
|
33
|
+
|
|
34
|
+
MU_EARTH = 3.986004418e14 # m^3/s^2
|
|
35
|
+
R_EARTH = 6_378_137.0 # m
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def mean_motion(semi_major_axis_m: float, mu: float = MU_EARTH) -> float:
|
|
39
|
+
"""Mean motion n = sqrt(mu / a^3), in rad/s."""
|
|
40
|
+
return float(np.sqrt(mu / semi_major_axis_m**3))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def cw_rhs(t: float, state: np.ndarray, n: float, accel: np.ndarray) -> np.ndarray:
|
|
44
|
+
"""Time derivative of the state under the CW equations.
|
|
45
|
+
|
|
46
|
+
`accel` is a constant thrust acceleration [a_x, a_y, a_z] in m/s^2.
|
|
47
|
+
"""
|
|
48
|
+
x, y, z, vx, vy, vz = state
|
|
49
|
+
ax, ay, az = accel
|
|
50
|
+
return np.array(
|
|
51
|
+
[
|
|
52
|
+
vx,
|
|
53
|
+
vy,
|
|
54
|
+
vz,
|
|
55
|
+
3.0 * n**2 * x + 2.0 * n * vy + ax,
|
|
56
|
+
-2.0 * n * vx + ay,
|
|
57
|
+
-(n**2) * z + az,
|
|
58
|
+
]
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def cw_stm(n: float, t: float) -> np.ndarray:
|
|
63
|
+
"""Exact 6x6 state transition matrix (no thrust).
|
|
64
|
+
|
|
65
|
+
state(t) = cw_stm(n, t) @ state(0)
|
|
66
|
+
|
|
67
|
+
This is the closed-form solution of the CW equations. The simulator
|
|
68
|
+
integrates the equations numerically; this matrix is used to check
|
|
69
|
+
that the numerical integration is correct.
|
|
70
|
+
"""
|
|
71
|
+
s, c = np.sin(n * t), np.cos(n * t)
|
|
72
|
+
nt = n * t
|
|
73
|
+
return np.array(
|
|
74
|
+
[
|
|
75
|
+
[4 - 3 * c, 0, 0, s / n, 2 * (1 - c) / n, 0],
|
|
76
|
+
[6 * (s - nt), 1, 0, -2 * (1 - c) / n, (4 * s - 3 * nt) / n, 0],
|
|
77
|
+
[0, 0, c, 0, 0, s / n],
|
|
78
|
+
[3 * n * s, 0, 0, c, 2 * s, 0],
|
|
79
|
+
[-6 * n * (1 - c), 0, 0, -2 * s, 4 * c - 3, 0],
|
|
80
|
+
[0, 0, -n * s, 0, 0, c],
|
|
81
|
+
]
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def propagate(
|
|
86
|
+
state: np.ndarray,
|
|
87
|
+
n: float,
|
|
88
|
+
dt: float,
|
|
89
|
+
accel: np.ndarray | None = None,
|
|
90
|
+
n_samples: int = 0,
|
|
91
|
+
rtol: float = 1e-10,
|
|
92
|
+
atol: float = 1e-9,
|
|
93
|
+
):
|
|
94
|
+
"""Numerically propagate the state for `dt` seconds with constant thrust.
|
|
95
|
+
|
|
96
|
+
Returns the final state. If `n_samples > 0`, also returns an array of
|
|
97
|
+
intermediate states (n_samples, 6), used to check keep-out violations
|
|
98
|
+
*between* time steps, not only at the end of each step.
|
|
99
|
+
"""
|
|
100
|
+
if accel is None:
|
|
101
|
+
accel = np.zeros(3)
|
|
102
|
+
sol = solve_ivp(
|
|
103
|
+
cw_rhs,
|
|
104
|
+
(0.0, dt),
|
|
105
|
+
np.asarray(state, dtype=float),
|
|
106
|
+
args=(n, np.asarray(accel, dtype=float)),
|
|
107
|
+
method="DOP853",
|
|
108
|
+
rtol=rtol,
|
|
109
|
+
atol=atol,
|
|
110
|
+
dense_output=n_samples > 0,
|
|
111
|
+
)
|
|
112
|
+
if not sol.success: # pragma: no cover - solver failures are unexpected
|
|
113
|
+
raise RuntimeError(f"Integration failed: {sol.message}")
|
|
114
|
+
final = sol.y[:, -1]
|
|
115
|
+
if n_samples > 0:
|
|
116
|
+
ts = np.linspace(0.0, dt, n_samples)
|
|
117
|
+
return final, sol.sol(ts).T
|
|
118
|
+
return final
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Approach-to-hold-point scenario with a keep-out zone.
|
|
2
|
+
|
|
3
|
+
Task: a chaser spacecraft must fly to a "hold point" near a target
|
|
4
|
+
(by default 100 m behind it on the V-bar) and stop there, without entering
|
|
5
|
+
a keep-out sphere around the target. This is the basic building block of
|
|
6
|
+
rendezvous, inspection, and docking.
|
|
7
|
+
|
|
8
|
+
The environment follows the Gymnasium API:
|
|
9
|
+
obs, info = env.reset(seed=...)
|
|
10
|
+
obs, reward, terminated, truncated, info = env.step(action)
|
|
11
|
+
|
|
12
|
+
Action: 3 numbers in [-1, 1], scaled by `max_accel` to a constant thrust
|
|
13
|
+
acceleration (m/s^2) in the Hill frame, held for one time step `dt`.
|
|
14
|
+
|
|
15
|
+
Observation: the chaser's 6-element state [x, y, z, vx, vy, vz] relative to
|
|
16
|
+
the target (see vbar.dynamics.cw for the frame).
|
|
17
|
+
|
|
18
|
+
Benchmark metrics are reported in `info`; the reward is a simple shaped
|
|
19
|
+
signal for reinforcement learning and is NOT the benchmark score.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
|
|
26
|
+
import gymnasium as gym
|
|
27
|
+
import numpy as np
|
|
28
|
+
from gymnasium import spaces
|
|
29
|
+
|
|
30
|
+
from ..dynamics.cw import R_EARTH, mean_motion, propagate
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ApproachConfig:
|
|
35
|
+
altitude_m: float = 500e3 # target orbit altitude (circular)
|
|
36
|
+
dt: float = 10.0 # seconds per decision step
|
|
37
|
+
max_steps: int = 360 # 360 x 10 s = 1 hour
|
|
38
|
+
max_accel: float = 0.02 # m/s^2, max thrust acceleration per axis
|
|
39
|
+
keep_out_radius: float = 50.0 # m, sphere around the target
|
|
40
|
+
hold_point: tuple = (0.0, -100.0, 0.0) # m, where the chaser must stop
|
|
41
|
+
capture_radius: float = 5.0 # m, "arrived" if this close to hold point
|
|
42
|
+
capture_speed: float = 0.05 # m/s, ...and slower than this
|
|
43
|
+
# Start state: fixed (if given) or sampled uniformly from these ranges.
|
|
44
|
+
start_state: tuple | None = None
|
|
45
|
+
start_pos_range: tuple = ((-200.0, 200.0), (200.0, 600.0), (-50.0, 50.0))
|
|
46
|
+
start_vel_range: tuple = ((-0.1, 0.1), (-0.1, 0.1), (-0.1, 0.1))
|
|
47
|
+
terminate_on_violation: bool = False
|
|
48
|
+
violation_samples: int = 20 # sub-samples per step to catch violations
|
|
49
|
+
reward_dv_weight: float = 1.0
|
|
50
|
+
extra: dict = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ApproachEnv(gym.Env):
|
|
54
|
+
metadata = {"render_modes": []}
|
|
55
|
+
|
|
56
|
+
def __init__(self, config: ApproachConfig | None = None):
|
|
57
|
+
super().__init__()
|
|
58
|
+
self.cfg = config or ApproachConfig()
|
|
59
|
+
self.n = mean_motion(R_EARTH + self.cfg.altitude_m)
|
|
60
|
+
self.hold_point = np.array(self.cfg.hold_point, dtype=float)
|
|
61
|
+
self.action_space = spaces.Box(-1.0, 1.0, shape=(3,), dtype=np.float32)
|
|
62
|
+
# Generous finite bounds (positions in m, velocities in m/s).
|
|
63
|
+
self.observation_space = spaces.Box(-1e7, 1e7, shape=(6,), dtype=np.float64)
|
|
64
|
+
self.state = np.zeros(6)
|
|
65
|
+
|
|
66
|
+
# ------------------------------------------------------------------ #
|
|
67
|
+
def _sample_start(self) -> np.ndarray:
|
|
68
|
+
if self.cfg.start_state is not None:
|
|
69
|
+
return np.array(self.cfg.start_state, dtype=float)
|
|
70
|
+
pos = [self.np_random.uniform(lo, hi) for lo, hi in self.cfg.start_pos_range]
|
|
71
|
+
vel = [self.np_random.uniform(lo, hi) for lo, hi in self.cfg.start_vel_range]
|
|
72
|
+
return np.array(pos + vel, dtype=float)
|
|
73
|
+
|
|
74
|
+
def _info(self) -> dict:
|
|
75
|
+
pos, vel = self.state[:3], self.state[3:]
|
|
76
|
+
return {
|
|
77
|
+
"time_s": self.t,
|
|
78
|
+
"dist_to_hold_m": float(np.linalg.norm(pos - self.hold_point)),
|
|
79
|
+
"range_to_target_m": float(np.linalg.norm(pos)),
|
|
80
|
+
"speed_mps": float(np.linalg.norm(vel)),
|
|
81
|
+
"dv_used_mps": self.dv_used,
|
|
82
|
+
"min_range_to_target_m": self.min_range,
|
|
83
|
+
"keep_out_violated": self.violated,
|
|
84
|
+
"violation_steps": self.violation_steps,
|
|
85
|
+
"captured": self.captured,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# ------------------------------------------------------------------ #
|
|
89
|
+
def reset(self, *, seed=None, options=None):
|
|
90
|
+
super().reset(seed=seed)
|
|
91
|
+
self.state = self._sample_start()
|
|
92
|
+
self.t = 0.0
|
|
93
|
+
self.steps = 0
|
|
94
|
+
self.dv_used = 0.0
|
|
95
|
+
self.min_range = float(np.linalg.norm(self.state[:3]))
|
|
96
|
+
self.violated = self.min_range < self.cfg.keep_out_radius
|
|
97
|
+
self.violation_steps = int(self.violated)
|
|
98
|
+
self.captured = False
|
|
99
|
+
return self.state.copy(), self._info()
|
|
100
|
+
|
|
101
|
+
def step(self, action):
|
|
102
|
+
action = np.clip(np.asarray(action, dtype=float), -1.0, 1.0)
|
|
103
|
+
accel = action * self.cfg.max_accel
|
|
104
|
+
prev_dist = np.linalg.norm(self.state[:3] - self.hold_point)
|
|
105
|
+
|
|
106
|
+
self.state, samples = propagate(
|
|
107
|
+
self.state, self.n, self.cfg.dt, accel, n_samples=self.cfg.violation_samples
|
|
108
|
+
)
|
|
109
|
+
self.t += self.cfg.dt
|
|
110
|
+
self.steps += 1
|
|
111
|
+
# Delta-v: constant acceleration magnitude times burn duration.
|
|
112
|
+
step_dv = float(np.linalg.norm(accel) * self.cfg.dt)
|
|
113
|
+
self.dv_used += step_dv
|
|
114
|
+
|
|
115
|
+
# Keep-out check over the whole step, not just its end point.
|
|
116
|
+
ranges = np.linalg.norm(samples[:, :3], axis=1)
|
|
117
|
+
self.min_range = min(self.min_range, float(ranges.min()))
|
|
118
|
+
step_violation = bool(ranges.min() < self.cfg.keep_out_radius)
|
|
119
|
+
if step_violation:
|
|
120
|
+
self.violated = True
|
|
121
|
+
self.violation_steps += 1
|
|
122
|
+
|
|
123
|
+
info = self._info()
|
|
124
|
+
self.captured = (
|
|
125
|
+
info["dist_to_hold_m"] < self.cfg.capture_radius
|
|
126
|
+
and info["speed_mps"] < self.cfg.capture_speed
|
|
127
|
+
)
|
|
128
|
+
info["captured"] = self.captured
|
|
129
|
+
|
|
130
|
+
# Shaped reward for RL: progress toward hold point, minus fuel,
|
|
131
|
+
# minus a penalty for being inside the keep-out zone.
|
|
132
|
+
reward = (prev_dist - info["dist_to_hold_m"]) / 100.0
|
|
133
|
+
reward -= self.cfg.reward_dv_weight * step_dv
|
|
134
|
+
reward -= 1.0 * step_violation
|
|
135
|
+
reward += 10.0 * self.captured
|
|
136
|
+
|
|
137
|
+
terminated = self.captured or (step_violation and self.cfg.terminate_on_violation)
|
|
138
|
+
truncated = self.steps >= self.cfg.max_steps
|
|
139
|
+
return self.state.copy(), float(reward), terminated, truncated, info
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Run episodes and collect benchmark metrics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def run_episode(env, agent, seed: int | None = None) -> dict:
|
|
11
|
+
"""Run one episode. Returns final metrics plus the full trajectory."""
|
|
12
|
+
obs, info = env.reset(seed=seed)
|
|
13
|
+
agent.reset(env)
|
|
14
|
+
states, actions = [obs.copy()], []
|
|
15
|
+
t0 = time.perf_counter()
|
|
16
|
+
terminated = truncated = False
|
|
17
|
+
while not (terminated or truncated):
|
|
18
|
+
action = np.asarray(agent.act(obs, info), dtype=float)
|
|
19
|
+
obs, _, terminated, truncated, info = env.step(action)
|
|
20
|
+
states.append(obs.copy())
|
|
21
|
+
actions.append(action)
|
|
22
|
+
result = {
|
|
23
|
+
"agent": agent.name,
|
|
24
|
+
"seed": seed,
|
|
25
|
+
"captured": info["captured"],
|
|
26
|
+
"keep_out_violated": info["keep_out_violated"],
|
|
27
|
+
"violation_steps": info["violation_steps"],
|
|
28
|
+
"min_range_to_target_m": info["min_range_to_target_m"],
|
|
29
|
+
"final_dist_to_hold_m": info["dist_to_hold_m"],
|
|
30
|
+
"dv_used_mps": info["dv_used_mps"],
|
|
31
|
+
"time_s": info["time_s"],
|
|
32
|
+
"wall_time_s": time.perf_counter() - t0,
|
|
33
|
+
"states": np.array(states),
|
|
34
|
+
"actions": np.array(actions),
|
|
35
|
+
}
|
|
36
|
+
if hasattr(agent, "interventions"):
|
|
37
|
+
result["filter_interventions"] = agent.interventions
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def run_many(env, agent, seeds) -> list[dict]:
|
|
42
|
+
"""Run one episode per seed. (Parallel runner planned.)"""
|
|
43
|
+
return [run_episode(env, agent, seed=s) for s in seeds]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Safety filters: wrap any agent and correct unsafe commands before they run.
|
|
2
|
+
|
|
3
|
+
v0 only defines the interface and a pass-through filter. Planned:
|
|
4
|
+
- CBF-QP filter (control barrier function + quadratic program) for the
|
|
5
|
+
keep-out sphere and approach-speed limits.
|
|
6
|
+
- Wrapping or porting AFRL's run-time-assurance library (act3-ace), which
|
|
7
|
+
should be cited, not reinvented.
|
|
8
|
+
|
|
9
|
+
The wrapper records how often the filter changed the agent's command
|
|
10
|
+
("interventions"), which is one of the benchmark's core metrics.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PassThroughFilter:
|
|
19
|
+
"""Does not change the action. The 'safety filter off' condition."""
|
|
20
|
+
|
|
21
|
+
name = "none"
|
|
22
|
+
|
|
23
|
+
def filter(self, obs: np.ndarray, action: np.ndarray, env) -> np.ndarray:
|
|
24
|
+
return action
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FilteredAgent:
|
|
28
|
+
"""Agent + safety filter, presented as a single agent."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, agent, safety_filter, tol: float = 1e-6):
|
|
31
|
+
self.agent = agent
|
|
32
|
+
self.filter = safety_filter
|
|
33
|
+
self.tol = tol
|
|
34
|
+
self.name = f"{agent.name}+{safety_filter.name}"
|
|
35
|
+
|
|
36
|
+
def reset(self, env) -> None:
|
|
37
|
+
self.env = env
|
|
38
|
+
self.agent.reset(env)
|
|
39
|
+
self.interventions = 0
|
|
40
|
+
self.decisions = 0
|
|
41
|
+
|
|
42
|
+
def act(self, obs, info):
|
|
43
|
+
raw = np.asarray(self.agent.act(obs, info), dtype=float)
|
|
44
|
+
safe = np.asarray(self.filter.filter(obs, raw, self.env), dtype=float)
|
|
45
|
+
self.decisions += 1
|
|
46
|
+
if np.max(np.abs(safe - raw)) > self.tol:
|
|
47
|
+
self.interventions += 1
|
|
48
|
+
return safe
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Plotting helpers (requires matplotlib: pip install vbar[plot])."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def plot_trajectories(results: list[dict], env, ax=None, title: str | None = None):
|
|
9
|
+
"""Top-down view: along-track (y, V-bar) horizontal, radial (x, R-bar) vertical.
|
|
10
|
+
|
|
11
|
+
Draws the keep-out zone, the target, and the hold point. One line per result.
|
|
12
|
+
"""
|
|
13
|
+
import matplotlib.pyplot as plt
|
|
14
|
+
|
|
15
|
+
if ax is None:
|
|
16
|
+
_, ax = plt.subplots(figsize=(7, 5))
|
|
17
|
+
styles = ["-", "--", "-.", ":"]
|
|
18
|
+
for i, r in enumerate(results):
|
|
19
|
+
s = r["states"]
|
|
20
|
+
status = "violated keep-out" if r["keep_out_violated"] else "safe"
|
|
21
|
+
label = f'{r["agent"]} ({status}, dv={r["dv_used_mps"]:.2f} m/s)'
|
|
22
|
+
ax.plot(s[:, 1], s[:, 0], styles[i % len(styles)], lw=1.8, label=label)
|
|
23
|
+
ax.plot(s[0, 1], s[0, 0], "o", color=ax.lines[-1].get_color(), ms=5)
|
|
24
|
+
|
|
25
|
+
ko = env.cfg.keep_out_radius
|
|
26
|
+
th = np.linspace(0, 2 * np.pi, 200)
|
|
27
|
+
ax.fill(ko * np.cos(th), ko * np.sin(th), color="0.85", label=f"keep-out ({ko:.0f} m)")
|
|
28
|
+
ax.plot(0, 0, "k+", ms=10, label="target")
|
|
29
|
+
hp = env.hold_point
|
|
30
|
+
ax.plot(hp[1], hp[0], "k*", ms=12, label="hold point")
|
|
31
|
+
ax.set_xlabel("along-track y, V-bar (m)")
|
|
32
|
+
ax.set_ylabel("radial x, R-bar (m)")
|
|
33
|
+
ax.set_aspect("equal", adjustable="datalim")
|
|
34
|
+
ax.grid(alpha=0.3)
|
|
35
|
+
ax.legend(fontsize=8, loc="best")
|
|
36
|
+
if title:
|
|
37
|
+
ax.set_title(title)
|
|
38
|
+
return ax
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Validation of the CW dynamics. If these fail, every result is wrong."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from vbar.dynamics import R_EARTH, cw_stm, mean_motion, propagate
|
|
7
|
+
|
|
8
|
+
N = mean_motion(R_EARTH + 500e3)
|
|
9
|
+
PERIOD = 2 * np.pi / N
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_mean_motion_leo_period():
|
|
13
|
+
# A 500 km circular orbit has a period of about 94.6 minutes.
|
|
14
|
+
assert PERIOD / 60 == pytest.approx(94.6, abs=0.1)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_stm_identity_at_zero():
|
|
18
|
+
assert np.allclose(cw_stm(N, 0.0), np.eye(6))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_stm_composition():
|
|
22
|
+
# Propagating t1 then t2 must equal propagating t1 + t2.
|
|
23
|
+
t1, t2 = 700.0, 1900.0
|
|
24
|
+
assert np.allclose(cw_stm(N, t2) @ cw_stm(N, t1), cw_stm(N, t1 + t2), atol=1e-9)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.mark.parametrize("seed", range(10))
|
|
28
|
+
def test_numerical_matches_exact_solution(seed):
|
|
29
|
+
# The core check: numerical integration agrees with the closed-form
|
|
30
|
+
# solution to sub-millimeter accuracy over a full orbit.
|
|
31
|
+
rng = np.random.default_rng(seed)
|
|
32
|
+
s0 = np.r_[rng.uniform(-2000, 2000, 3), rng.uniform(-2, 2, 3)]
|
|
33
|
+
numeric = propagate(s0, N, PERIOD)
|
|
34
|
+
exact = cw_stm(N, PERIOD) @ s0
|
|
35
|
+
assert np.max(np.abs(numeric[:3] - exact[:3])) < 1e-3 # meters
|
|
36
|
+
assert np.max(np.abs(numeric[3:] - exact[3:])) < 1e-6 # m/s
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_vbar_point_is_equilibrium():
|
|
40
|
+
# A chaser sitting still on the V-bar (along-track) stays there forever.
|
|
41
|
+
s0 = np.array([0.0, 400.0, 0.0, 0.0, 0.0, 0.0])
|
|
42
|
+
assert np.allclose(propagate(s0, N, PERIOD), s0, atol=1e-6)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_closed_ellipse_is_periodic():
|
|
46
|
+
# With vy0 = -2 n x0 there is no along-track drift: after one orbit the
|
|
47
|
+
# chaser is back where it started (a "football" orbit around the target).
|
|
48
|
+
x0 = 100.0
|
|
49
|
+
s0 = np.array([x0, 0.0, 0.0, 0.0, -2 * N * x0, 0.0])
|
|
50
|
+
assert np.allclose(propagate(s0, N, PERIOD), s0, atol=1e-5)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_rbar_offset_drifts():
|
|
54
|
+
# A chaser placed below the target (x < 0) with zero velocity drifts
|
|
55
|
+
# along-track: lower orbit = faster. Classic CW behavior.
|
|
56
|
+
s0 = np.array([-100.0, 0.0, 0.0, 0.0, 0.0, 0.0])
|
|
57
|
+
s1 = propagate(s0, N, PERIOD)
|
|
58
|
+
assert s1[1] > 0 # moved ahead in +y
|
|
59
|
+
# Exact drift per orbit is 12*pi*|x0| (from the closed-form solution).
|
|
60
|
+
assert s1[1] == pytest.approx(12 * np.pi * 100.0, rel=1e-6)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_constant_thrust_changes_velocity():
|
|
64
|
+
# Over a short burn, delta-v is approximately accel * time.
|
|
65
|
+
s0 = np.zeros(6)
|
|
66
|
+
a = np.array([0.0, 0.01, 0.0])
|
|
67
|
+
s1 = propagate(s0, N, 1.0, a)
|
|
68
|
+
assert s1[4] == pytest.approx(0.01, rel=1e-3)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pytest
|
|
3
|
+
from gymnasium.utils.env_checker import check_env
|
|
4
|
+
|
|
5
|
+
from vbar import ApproachConfig, ApproachEnv, run_episode
|
|
6
|
+
from vbar.agents import DriftAgent, LLMAgent, PDAgent, parse_action
|
|
7
|
+
from vbar.safety import FilteredAgent, PassThroughFilter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_gymnasium_api_compliance():
|
|
11
|
+
check_env(ApproachEnv(), skip_render_check=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_reset_is_reproducible_with_seed():
|
|
15
|
+
env = ApproachEnv()
|
|
16
|
+
a, _ = env.reset(seed=3)
|
|
17
|
+
b, _ = env.reset(seed=3)
|
|
18
|
+
assert np.allclose(a, b)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_dv_accounting():
|
|
22
|
+
env = ApproachEnv(ApproachConfig(start_state=(0, 400, 0, 0, 0, 0)))
|
|
23
|
+
env.reset()
|
|
24
|
+
_, _, _, _, info = env.step(np.array([1.0, 0.0, 0.0]))
|
|
25
|
+
assert info["dv_used_mps"] == pytest.approx(env.cfg.max_accel * env.cfg.dt)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_drift_on_vbar_stays_safe():
|
|
29
|
+
env = ApproachEnv(ApproachConfig(start_state=(0, 400, 0, 0, 0, 0)))
|
|
30
|
+
r = run_episode(env, DriftAgent())
|
|
31
|
+
assert not r["keep_out_violated"]
|
|
32
|
+
assert r["dv_used_mps"] == 0.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_pd_reaches_hold_point_on_clear_path():
|
|
36
|
+
env = ApproachEnv(ApproachConfig(start_state=(-150, -400, 0, 0, 0, 0)))
|
|
37
|
+
r = run_episode(env, PDAgent())
|
|
38
|
+
assert r["captured"] and not r["keep_out_violated"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_pd_violates_keep_out_when_target_is_in_the_way():
|
|
42
|
+
# Documents the motivating failure: a naive controller flies straight
|
|
43
|
+
# through the keep-out zone. A safety filter should prevent this.
|
|
44
|
+
env = ApproachEnv(ApproachConfig(start_state=(0, 400, 0, 0, 0, 0)))
|
|
45
|
+
r = run_episode(env, PDAgent())
|
|
46
|
+
assert r["keep_out_violated"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_violation_detected_between_steps():
|
|
50
|
+
# Fast pass through the keep-out zone within a single step must be caught.
|
|
51
|
+
cfg = ApproachConfig(start_state=(0, 60, 0, 0, -12, 0), dt=10.0)
|
|
52
|
+
env = ApproachEnv(cfg)
|
|
53
|
+
env.reset()
|
|
54
|
+
_, _, _, _, info = env.step(np.zeros(3))
|
|
55
|
+
assert info["range_to_target_m"] > cfg.keep_out_radius # end point is outside...
|
|
56
|
+
assert info["keep_out_violated"] # ...but the path went through
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_parse_action():
|
|
60
|
+
assert np.allclose(parse_action('{"ax": 0.1, "ay": -2, "az": 0}'), [0.1, -1.0, 0.0])
|
|
61
|
+
assert parse_action("no json here") is None
|
|
62
|
+
assert parse_action('{"ax": "fast"}') is None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_llm_agent_with_fake_model_logs_everything():
|
|
66
|
+
env = ApproachEnv(ApproachConfig(start_state=(0, 400, 0, 0, 0, 0), max_steps=5))
|
|
67
|
+
agent = LLMAgent(lambda prompt: '{"ax": 0, "ay": -0.2, "az": 0}', name="fake-llm")
|
|
68
|
+
r = run_episode(env, agent)
|
|
69
|
+
assert len(agent.log) == 5
|
|
70
|
+
assert not any(e["parse_failed"] for e in agent.log)
|
|
71
|
+
assert r["dv_used_mps"] > 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_filtered_agent_counts_interventions():
|
|
75
|
+
env = ApproachEnv(ApproachConfig(start_state=(0, 400, 0, 0, 0, 0), max_steps=5))
|
|
76
|
+
agent = FilteredAgent(PDAgent(), PassThroughFilter())
|
|
77
|
+
r = run_episode(env, agent)
|
|
78
|
+
assert r["filter_interventions"] == 0
|