vbar 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vbar/__init__.py +7 -0
- vbar/agents/__init__.py +5 -0
- vbar/agents/base.py +29 -0
- vbar/agents/llm.py +100 -0
- vbar/agents/pd.py +40 -0
- vbar/dynamics/__init__.py +3 -0
- vbar/dynamics/cw.py +118 -0
- vbar/envs/__init__.py +3 -0
- vbar/envs/approach.py +139 -0
- vbar/runner.py +43 -0
- vbar/safety/__init__.py +48 -0
- vbar/viz.py +38 -0
- vbar-0.0.1.dist-info/METADATA +103 -0
- vbar-0.0.1.dist-info/RECORD +16 -0
- vbar-0.0.1.dist-info/WHEEL +4 -0
- vbar-0.0.1.dist-info/licenses/LICENSE +21 -0
vbar/__init__.py
ADDED
|
@@ -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"]
|
vbar/agents/__init__.py
ADDED
vbar/agents/base.py
ADDED
|
@@ -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)
|
vbar/agents/llm.py
ADDED
|
@@ -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
|
vbar/agents/pd.py
ADDED
|
@@ -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)
|
vbar/dynamics/cw.py
ADDED
|
@@ -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
|
vbar/envs/__init__.py
ADDED
vbar/envs/approach.py
ADDED
|
@@ -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
|
vbar/runner.py
ADDED
|
@@ -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]
|
vbar/safety/__init__.py
ADDED
|
@@ -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
|
vbar/viz.py
ADDED
|
@@ -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,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
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
vbar/__init__.py,sha256=2ydnwI5qLPbI7WjKUpKN_tLgar56S0wuWUWDRjs5NT0,267
|
|
2
|
+
vbar/runner.py,sha256=GcStaBsq1O6juslcWKP0PvlP0rk4iKOl8mQV4mKXPAQ,1481
|
|
3
|
+
vbar/viz.py,sha256=UL4qMhSfoPfm8L-8I7qmh-BYrDWIsnIbl5JQJSQioNU,1440
|
|
4
|
+
vbar/agents/__init__.py,sha256=PIys_bTuH7fyGqXL0jFMYhHnoDH9oXB3i7bDLtVGd5c,174
|
|
5
|
+
vbar/agents/base.py,sha256=LmWqfCPcyZv3RGLmYf6TMl5aO-Qx41v77oAvGjhWWXI,701
|
|
6
|
+
vbar/agents/llm.py,sha256=UUcM9u_A0AUga-1h_zCAGdbfyLdloQc0zT-cR01NAW0,3486
|
|
7
|
+
vbar/agents/pd.py,sha256=GagjADKBZatprUIVEyuLhyLxZN4-U1FLIFGBOl1LvJM,1425
|
|
8
|
+
vbar/dynamics/__init__.py,sha256=87D68Ra2nRVpsVdWtpixha8hzkYjop9-WfRNT4gShgA,157
|
|
9
|
+
vbar/dynamics/cw.py,sha256=0d_0DXqKs5YxEjYDSRVQqqQrZiMVW5WkUvXjB7BT1DA,3734
|
|
10
|
+
vbar/envs/__init__.py,sha256=l1ZDBDI5q6xu4cEjz0lR7muDhKazsaVLXeadY2jVc-w,95
|
|
11
|
+
vbar/envs/approach.py,sha256=07SqPq61gVMzSKVSlWink5y3Gf1BHg6C-qa-MKE5jLE,5928
|
|
12
|
+
vbar/safety/__init__.py,sha256=R_XodYV62PrGWf8keWsljLk3S9FdeexBhM7Iq1RUN1E,1551
|
|
13
|
+
vbar-0.0.1.dist-info/METADATA,sha256=U6V3yE5pJtSnS4zYecUrIUHJwhYCvbRYx1VRsuY-N6A,4026
|
|
14
|
+
vbar-0.0.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
15
|
+
vbar-0.0.1.dist-info/licenses/LICENSE,sha256=cDHetqbE_LnMj1mM7gvxVCuDU1i6nAI3bebIYdoULgw,1072
|
|
16
|
+
vbar-0.0.1.dist-info/RECORD,,
|
|
@@ -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.
|