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.
@@ -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
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .pytest_cache/
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ outputs/
@@ -0,0 +1,8 @@
1
+ cff-version: 1.2.0
2
+ message: "If you use this software, please cite it as below."
3
+ title: "V-BAR: Verified Benchmark for Autonomous Rendezvous"
4
+ authors:
5
+ - family-names: Hammoudeh
6
+ given-names: Jamil
7
+ version: 0.0.1
8
+ license: MIT
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,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"]
@@ -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,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"]
@@ -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,3 @@
1
+ from .approach import ApproachConfig, ApproachEnv
2
+
3
+ __all__ = ["ApproachConfig", "ApproachEnv"]
@@ -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