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 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"]
@@ -0,0 +1,5 @@
1
+ from .base import Agent, DriftAgent
2
+ from .llm import LLMAgent, parse_action
3
+ from .pd import PDAgent
4
+
5
+ __all__ = ["Agent", "DriftAgent", "LLMAgent", "PDAgent", "parse_action"]
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)
@@ -0,0 +1,3 @@
1
+ from .cw import MU_EARTH, R_EARTH, cw_rhs, cw_stm, mean_motion, propagate
2
+
3
+ __all__ = ["MU_EARTH", "R_EARTH", "cw_rhs", "cw_stm", "mean_motion", "propagate"]
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
@@ -0,0 +1,3 @@
1
+ from .approach import ApproachConfig, ApproachEnv
2
+
3
+ __all__ = ["ApproachConfig", "ApproachEnv"]
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]
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.