Symmetric core & data shapes¶
The OrbitalGymEnv core is symmetric: the same step function and
the same data shapes work whether you're learning the guard, learning
the bandit, training both simultaneously, or planning over beliefs.
This page is the spine of the documentation — every other in-depth
page assumes you've internalised the types and indexing patterns
described here.
Sides¶
from orbitalgym import Side
Side.GUARD # "guard"
Side.BANDIT # "bandit"
Side.GUARD.opposite() # Side.BANDIT
Side is a Python-level StrEnum, never a traced JAX array.
That distinction matters: side identity is a static structural
property of the computation graph, not data flowing through it. You
can if side is Side.GUARD: ... inside a jit-traced function and
JAX will specialise the trace per-side.
BySide[T] — the universal one-per-side container¶
Anywhere a value is naturally per-side — actions, observations,
rewards, beliefs, scripted policies — the type is BySide[T].
import jax
import jax.numpy as jnp
from orbitalgym import BySide, Side
bs = BySide(guard=42.0, bandit=-1.0)
assert bs.get(Side.GUARD) == 42.0
assert bs.map(lambda x: x * 2).bandit == -2.0
BySide is registered as a JAX pytree, so vmap, lax.scan, and
tree.map traverse it transparently:
# BySide is a JAX pytree — vmap and tree.map traverse it.
bs_arr = BySide(guard=jnp.array([1.0, 2.0]), bandit=jnp.array([3.0, 4.0]))
bs_squared = jax.tree.map(lambda x: x**2, bs_arr)
assert bool(jnp.all(bs_squared.bandit == jnp.array([9.0, 16.0])))
The big four step types¶
env.step takes Actions(sides=BySide(guard, bandit)) and returns
StepOutput(state, outputs, episode_done, info), where:
state— the nextEnvState.outputs.guard,outputs.bandit—SideOutput(obs, reward, done).episode_done— scalar bool. Per-sidedonefields are this scalar broadcast for shape uniformity across sides with different vehicle counts.info["applied_dv"]—BySideof(N_side, 3)truth-frame Δv the step actually imparted, zero-padded to width 3. Thrust and remaining propellant both clip a command, so anything that predicts the ego motion, accounts for Δv spent, or draws a burn must read this rather than the commandeddv.rolloutstores it ontraj.applied_dv, where the episode metrics and the thrust quivers inRolloutScenepick it up.
Axis ordering: BATCH → TIME → VEHICLE → FEATURE¶
Outer-to-inner. Read every shape spec on this principle:
| Axis | When present |
|---|---|
B (batch) |
Only under jax.vmap (e.g. seed batch). |
T (time) |
Only on Trajectory leaves (added by lax.scan in rollout). |
N_side (vehicle) |
Always present per side, even if N=1. |
| feature | Innermost. action_dim=3 for HCW-RTN, 6 for RTN state. |
from orbitalgym import OrbitalGymEnv, SingleAgentView, make_pursuit_evasion
from orbitalgym.policies import ZeroControl
from orbitalgym.rollout import rollout_single_agent
cfg = make_pursuit_evasion(n_guards=2, n_bandits=1, seed=0, max_horizon_s=1000.0)
env = OrbitalGymEnv(cfg)
view = SingleAgentView(env)
guard_policy = ZeroControl(command_cls=env.guard_command_cls, n_vehicles=cfg.n_guards)
traj = rollout_single_agent(
view,
guard_policy,
lambda c, s, k: None,
jax.random.PRNGKey(0),
n_steps=cfg.max_steps,
)
# T=time, N=vehicle, feature
assert traj.sides.guard.action.dv.shape == (cfg.max_steps, cfg.n_guards, 3)
assert traj.sides.guard.reward.shape == (cfg.max_steps,)
assert traj.env_state.guards.rtn.shape == (cfg.max_steps, cfg.n_guards, 6)
Indexing recipes¶
# i-th guard's reward time series — well, reward is PER_SIDE for
# the bundled scope, so it's just `traj.sides.guard.reward`. For
# PER_VEHICLE rewards it would be `traj.sides.guard.reward[:, i]`.
side_reward_t = traj.sides.guard.reward # (T,)
# i-th guard's RTN trajectory:
guard0_rtn = traj.env_state.guards.rtn[:, 0] # (T, 6)
# Step t's full state across all guards:
step5_state = traj.env_state.guards.rtn[5] # (N_g, 6)
The recipe pattern: pick the axes you want as colons, slice or index the rest.
Episode termination and the mask¶
Trajectory.episode_done is (T,) bool, latched after
termination — once True, all subsequent entries are True. To restrict
analysis to valid steps:
from orbitalgym.rollout import episode_mask
valid = episode_mask(traj) # (T,) bool, latched
valid_rewards = side_reward_t[valid] # only steps before termination
Under vmap¶
Wrap a rollout in jax.vmap and every leaf gains a leading batch
dimension. The full B → T → N → feature order applies:
# Under vmap over seeds, every shape gains a leading B dimension:
def run_one(seed):
return rollout_single_agent(
view,
guard_policy,
lambda c, s, k: None,
seed,
n_steps=cfg.max_steps,
)
seeds = jax.vmap(jax.random.PRNGKey)(jnp.arange(4))
batched = jax.jit(jax.vmap(run_one))(seeds)
assert batched.sides.guard.action.dv.shape == (4, cfg.max_steps, cfg.n_guards, 3)
# B=batch, T=time, N=vehicle, feature
This is the workhorse pattern for seed-parallel evaluation; see How-to → vmap rollouts for a more detailed recipe and T5 — GPU / MPS for the acceleration story.
Single-agent vs multi-agent framing¶
The same env supports both. In multi-agent framing, the caller passes
both sides' actions and reads both sides' outputs (this is what the
PettingZoo adapter and the symmetric rollout use).
In single-agent framing, SingleAgentView reads
cfg.controlled_side (default Side.GUARD) and runs the opposite
side's cfg.<side>_policy internally. The view's step
takes only the controlled side's action and returns only its reward
and observation. The Gymnasium adapter wraps this view.
Why per-side policies live on the config¶
ScenarioConfig.guard_policy and bandit_policy are populated by the
make_ZeroControl by default. SingleAgentView
reads the opposite side's policy at construction. Multi-agent adapters
ignore both fields. This means a swap from "guard learning vs heuristic
bandit" to "bandit learning vs heuristic guard" is a single
dataclasses.replace(cfg, controlled_side=Side.BANDIT) call — see
How-to → Switch the controlled side.
Where to next¶
- State layout & adding a Power component — what
lives inside
traj.env_state.guards.<...>and how to add new fields. - Observations — what flows into the
obsaxis. - API reference → Types — the dataclass field lists
for
BySide,Actions,StepOutput,Trajectory.