Skip to content

Components

Protocols and reference implementations for the env's swappable building blocks.

Policies

A single Policy protocol covers reactive obs-only policies and state-aware planners alike. State-aware policies (e.g. MCTSPolicy) require an agent_view carrying enough state to step a model — wire through belief_rollout with a Belief whose mean is the flat state, or pass the flat state directly when calling the policy manually.

orbitalgym.policies.base.Policy

Bases: Protocol

Structural protocol for a role-agnostic policy.

Implementations are typically frozen dataclasses with a command_cls field (the per-side Command class) and an n_vehicles field (defaulting to None/0) that the env populates via dataclasses.replace at construction. The call returns a Command pytree — never a raw array.

orbitalgym.policies.zero.ZeroControl dataclass

ZeroControl(command_cls: Any = None, n_vehicles: int = 0)

Always emits the identity Command (zeros for every component).

Env-populated init knobs (filled by OrbitalGymEnv via dataclasses.replace): command_cls: the per-side Command class (built by build_command_class) n_vehicles: number of vehicles on this side

Replaces the previous (n_vehicles, action_dim) raw-array contract.

orbitalgym.policies.uniform_random.UniformRandomDiscretePolicy dataclass

UniformRandomDiscretePolicy(
    action_grid: Array,
    n_vehicles: int = 0,
    command_cls: Any = None,
)

Uniform-random over a (A, dv_dim) discrete action grid, per vehicle.

Env-populated init knobs (filled by OrbitalGymEnv via dataclasses.replace): command_cls: per-side Command class n_vehicles: number of vehicles on this side

The action_grid row count is the discrete action-space size; rows are Δv vectors whose dimensionality is padded to the side's command dv_dim if needed (e.g. 2-D RT grid into a 3-D RTN command).

Search policies

Both planners take a coordination mode for multi-vehicle teams. "joint" plans the whole fleet at once: one MCTS tree over the joint action space, or one MPPI sampler over the fleet's Delta-v sequence. "independent" plans one vehicle at a time and models the teammates with teammate_model, the same-side analogue of opponent_model; cost then grows linearly rather than exponentially in the fleet size, and the per-vehicle results are stacked into one command. The modes coincide for a single vehicle.

orbitalgym.policies.mcts.MCTSPolicy dataclass

MCTSPolicy(
    env_model: POMDPAdapter,
    side: Side,
    action_grid: Array,
    opponent_model: Any,
    opponent_action_grid: Array,
    num_simulations: int = 32,
    max_depth: int | None = None,
    leaf_value_fn: Callable[[Array], Array] | None = None,
    discount: float | None = None,
    variant: Literal[
        "muzero", "gumbel_muzero"
    ] = "gumbel_muzero",
    n_vehicles: int = 0,
    command_cls: Any = None,
    coordination: Literal["joint", "independent"] = "joint",
    teammate_model: Any = None,
    opponent_schedule: Any = None,
    max_num_considered_actions: int = 16,
)

JAX-native Gumbel MuZero / PUCT search backed by mctx.

See module docstring for the joint vs independent coordination modes.

orbitalgym.policies.mppi.MPPIPolicy dataclass

MPPIPolicy(
    env_model: Any,
    side: Side,
    opponent_model: Any,
    n_samples: int = 256,
    horizon: int = 10,
    temperature: float = 1.0,
    noise_sigma: float = 0.1,
    dv_max: float = 0.5,
    terminal_value_fn: Callable[[Array], Array]
    | None = None,
    discount: float | None = None,
    n_vehicles: int = 0,
    command_cls: Any = None,
    template_env_state: Any = None,
    coordination: Literal["joint", "independent"] = "joint",
    teammate_model: Any = None,
)

Sampling-based receding-horizon planner conforming to the Policy protocol.

See the module docstring for the joint and independent coordination modes.

orbitalgym.policies.heuristic.lead_intercept.LeadInterceptPursuer dataclass

LeadInterceptPursuer(
    max_dv_mps: float = 0.05,
    dt: float = 10.0,
    n_vehicles: int = 0,
    command_cls: Any = None,
)

Bandit (or guard) policy that closes on the opponent.

Assumes the obs is a FullObservation flat layout in 1v1 RTN: [own_rtn(6), opp_rtn(6)]. For other observation channels, wrap in a LeadInterceptPursuer-shaped adapter that reconstructs the same own/opp split.

orbitalgym.policies.heuristic.evader.OrthogonalEvader dataclass

OrthogonalEvader(
    max_dv_mps: float = 0.05,
    n_vehicles: int = 0,
    command_cls: Any = None,
)

Evades an approaching opponent by thrusting perpendicular to relative velocity.

Assumes obs layout [own_rtn(6), opp_rtn(6)]. Action lies in the orthogonal complement of the relative velocity vector.

orbitalgym.policies.heuristic.sun_tracker.SunTrackerBlocker dataclass

SunTrackerBlocker(
    sun_dir_rtn: Array = _default_sun_dir(),
    max_dv_mps: float = 0.05,
    n_vehicles: int = 0,
    command_cls: Any = None,
)

Sun-line-aware heuristic blocker.

Assumes obs layout [own_rtn(6), opp_rtn(6)]. Thrusts along the Sun-direction line, oriented by the sign of the opponent's projection onto that line (so the thrust closes on the Sun-side of the opponent).

orbitalgym.policies.heuristic.jittered.JitteredPolicy dataclass

JitteredPolicy(
    base: Policy,
    sigma: float = 0.01,
    n_vehicles: int = 0,
    command_cls: Any = None,
)

Wraps a base policy and adds PRNG-keyed Gaussian jitter to its action.

The jitter is drawn from N(0, sigma²I) with the same shape as the base action. Setting sigma=0.0 is a pure passthrough.

base is required — instantiate with JitteredPolicy(base=..., sigma=...).

Controlled-side wrappers

orbitalgym.policies.controlled.heuristic_with_fallback.HeuristicWithFallbackPolicy dataclass

HeuristicWithFallbackPolicy(
    primary: Policy,
    fallback: Policy,
    threshold: float = 0.5,
    n_vehicles: int = 0,
    command_cls: Any = None,
)

Confidence-gated routing wrapper.

policy_state semantics: a scalar confidence in [0, 1]. The wrapped primary's own state is not threaded through this class — primaries that need state should be wrapped with their own state-handling.

Only the dv field is gated by confidence. Non-dv Command fields (e.g. Communicate.active, Communicate.payload) propagate from the primary policy unchanged — the fallback's non-dv fields are discarded. If the fallback should drive non-dv fields too, replace this wrapper with a custom routing policy.

orbitalgym.policies.controlled.composite_action.CompositeActionPolicy dataclass

CompositeActionPolicy(
    base: Policy,
    offset: Policy,
    n_vehicles: int = 0,
    command_cls: Any = None,
)

Sums actions from base and offset. Both must produce same-shape actions.

Only the dv field is summed. Non-dv Command fields (e.g. Communicate.active, Communicate.payload) propagate from the base policy unchanged — the offset policy's non-dv fields are discarded. This matches the typical "scripted base + learned residual" usage where the residual is a Δv correction only.

Observations

orbitalgym.observations.base.ObservationFn

Bases: Protocol

Structural protocol for an observation function.

Returns a tuple of Observation channels. Most implementations return one channel; composite functions return more.

actions is the full Actions pytree (both sides). When the observation fn is called pre-step (env.reset, single_agent's pre-decision opp obs), actions is the identity Command for both sides — no information leakage from a hypothetical step. When called post-step (env.step), actions is the action that produced next_state.

orbitalgym.observations.reference.FullObservation dataclass

FullObservation(layout: Any, epsilon: float = 1e-06)

One channel covering every tracked entity, no noise, always visible.

Layout requirements: must expose n_guards, n_bandits, dynamics_state_dim attributes.

orbitalgym.observations.range_limited.RangeLimitedObservation dataclass

RangeLimitedObservation(
    layout: Any,
    sensor_range_m: float,
    sigma_range: float = 1.0,
)

One channel: per-pair 3-D position measurement gated by distance.

H selects the first 3 components (position) from the d-dim dynamics state. Layout requirements: n_guards, n_bandits, dynamics_state_dim.

orbitalgym.observations.conical.ConicalObservation dataclass

ConicalObservation(
    layout: Any,
    sensor_boresights_body: Array,
    half_angle_rad: float | Array | tuple[float, ...],
    sigma_floor: float = 1.0,
    sigma_range_frac: float = 0.0,
)

One channel: per-pair full-state measurement gated by body-frame cone.

Parameters

layout: Scenario layout object exposing n_guards, n_bandits, and dynamics_state_dim. sensor_boresights_body: Shape (k, 3) array of unit vectors in the observer's body frame defining sensor pointing directions. half_angle_rad: Half-angle (radians) of each cone — either a scalar (same for all sensors) or a (k,) array / tuple of per-sensor half-angles. sigma_floor: Measurement-noise standard deviation at zero range. With sigma_range_frac=0.0 this is the noise std for every pair. Set both to 0.0 for noiseless (ground-truth) measurements. sigma_range_frac: Growth of the noise std per metre of observer-target range: sigma_ij = sigma_floor + sigma_range_frac * range_ij. The same sigma_ij scales every measurement row, so velocity rows are noised in proportion to range exactly as position rows are.

Rewards

orbitalgym.rewards.base.RewardFn

Bases: Protocol

Structural protocol for a per-step reward function.

Called as reward_fn(prev_state, action, next_state, side, params, t). action is a full Actions container (both sides) so multi-side rewards (e.g. zero-sum penalties using both sides' actions) can be expressed naturally. Returns shape per scope: PER_VEHICLE: (N_side,) PER_SIDE: () (scalar)

orbitalgym.rewards.base.RewardScope

Bases: StrEnum

orbitalgym.rewards.reference.DistanceToReferenceOrbit dataclass

DistanceToReferenceOrbit(
    scope: RewardScope = RewardScope.PER_SIDE,
)

Negative sum of guard-to-reference-orbit-origin distances.

Returns 0.0 for the bandit side (preserving the bootstrap's single-reward-on-guard-only behavior). Future zero-sum games negate explicitly inside their own reward classes.

orbitalgym.rewards.reference.ZeroReward dataclass

ZeroReward(scope: RewardScope = RewardScope.PER_SIDE)

No-op reward — returns 0.0 for any side.

The default reward for NoGame. Use as a transparent placeholder when the user is expected to supply a custom reward via the reward_fn= constructor kwarg on ScenarioConfig.

orbitalgym.rewards.lbg_zero_sum.LbgZeroSumReward dataclass

LbgZeroSumReward(
    shaping_scale_m: float = 300.0,
    shaping_gain: float = 50.0,
    shaping_discount: float = 1.0,
    home_weight: float = 0.0,
    guard_separation_m: float = 20.0,
    lady_keepout_m: float = 20.0,
    separation_cost: float = 10.0,
    r_catch: float = 1000.0,
    r_breach: float = 1000.0,
    catch_radius_m: float = 50.0,
    breach_radius_m: float = 5.0,
    catch_speed_mps: float = float("inf"),
    breach_speed_mps: float = float("inf"),
    catch_dwell_steps: int = 0,
    breach_dwell_steps: int = 0,
    escape_radius_m: float = 0.0,
    repel_on_empty_tank: bool = False,
    dv_cost: float = 0.0,
    scope: RewardScope = RewardScope.PER_SIDE,
)

Potential-shaped reward with zero-sum terminal events for Lady-Bandit-Guard.

The terminal events are mirrored; the shaping is not, because each side gets a potential over the distance it is trying to close. The shaping gives each side a dense gradient toward the right behavior without moving the game's equilibria, while the terminal events dominate the cumulative return when triggered. The potential is zero at an absorbing state, so the step that ends the episode pays no shaping beyond returning the potential the side had.

shaping_scale_m is the distance that makes a potential unity, shaping_gain scales the shaping term (zero for a terminal-only game), shaping_discount must match the planner's per-step discount for a side's shaped value to be exactly its unshaped value less its own potential, and home_weight prices a guard's distance from the lady inside the guard's potential.

catch_dwell_steps and breach_dwell_steps hold back the matching terminal bonus until a bandit has spent that many consecutive steps inside the radius, matching the termination condition so the bonus is paid on the step the episode ends. Zero pays it on the first step inside the radius.

guard_separation_m, lady_keepout_m and separation_cost set the guard-side crowding charge; dv_cost is the price of fuel in reward units per m/s of delta-v. Both are real per-side costs rather than shaping: each side pays only its own, so raising either makes that side more careful without handing the other an advantage.

potential

potential(state, side: Side) -> jax.Array

:func:lbg_potential for side at this reward's scale and home weight.

Termination

orbitalgym.termination.base.TerminationFn

Bases: Protocol

Episode-level termination — returns scalar bool.

Per-side done in SideOutput is this scalar broadcast (kept for shape uniformity). Per-side termination (one side gives up, the other does not) is a Phase-N concern; not needed by the four bootstrap games.

orbitalgym.termination.reference.MaxStepsOnly dataclass

MaxStepsOnly()

Episode ends when state.step >= params.max_steps. No spatial event.

The default termination for NoGame. Reads max_steps from the cfg at call time, so it can be constructed without a cfg in scope — which is what lets each Game subclass build its default termination cleanly inside default_termination_fn.

orbitalgym.termination.lbg_events.LbgEventTermination dataclass

LbgEventTermination(
    breach_radius_m: float,
    catch_radius_m: float = 0.0,
    breach_speed_mps: float = float("inf"),
    catch_speed_mps: float = float("inf"),
    catch_dwell_steps: int = 0,
    breach_dwell_steps: int = 0,
    escape_radius_m: float = 0.0,
    repel_on_empty_tank: bool = False,
)

Terminate on max_steps OR breach OR catch OR every bandit repelled.

The catch event is optional: set catch_radius_m=0 to disable, leaving only the max-steps and breach gates. catch_dwell_steps and breach_dwell_steps require the corresponding radius to be held for that many consecutive steps; zero, the default, fires on the first step inside it. Both repel gates are off by default: escape_radius_m=0 disables the distance gate and repel_on_empty_tank=False the propellant gate. max_steps, the step duration and the reference orbit are read from params at call time (single source of truth on the cfg).

Belief

Protocols

orbitalgym.belief.base.Belief

Bases: Protocol

Structural protocol for any belief representation.

Implementations must expose a mean: jax.Array attribute holding the per-pair state estimate. Shape conventions follow the concrete belief classes ((N_obs, N_total, d) for KF/EKF).

orbitalgym.belief.base.BeliefInitializer

Bases: Protocol

Initialize a belief state from the env's ground-truth state.

orbitalgym.belief.base.BeliefUpdater

Bases: Protocol

Update a belief given a new observation.

Kalman filter

orbitalgym.belief.kf.KFBelief

orbitalgym.belief.kf.KFBeliefUpdater dataclass

KFBeliefUpdater(
    stm: Array,
    control_matrix: Array,
    process_noise: Array,
    use_joseph_form: bool = True,
)

Linear Kalman filter, per-(observer, target) block-diagonal.

stm, control_matrix, process_noise are uniform across all tracked entities. Per-pair correction matrices H and R come from each Observation channel (different sensors may have different m, H, R).

orbitalgym.belief.kf.KFFromTruthInitializer dataclass

KFFromTruthInitializer(layout: Any, variance_diag: Array)

Initial belief: per-observer mean = truth of every tracked entity.

Layout requirements: must expose n_guards, n_bandits, dynamics_state_dim attributes.

orbitalgym.belief.kf.KFUniformDefaultInitializer dataclass

KFUniformDefaultInitializer(
    layout: Any, default_mean: Array, variance_diag: Array
)

Uninformed initial belief: every (observer, tracked) pair gets the same prior.

Extended Kalman filter

orbitalgym.belief.ekf.EKFBelief

orbitalgym.belief.ekf.EKFBeliefUpdater dataclass

EKFBeliefUpdater(
    dynamics_fn: Callable,
    process_noise: Array,
    dt: float,
    use_joseph_form: bool = True,
)

Extended Kalman filter with autodiff Jacobians.

dynamics_fn(x, u, dt) -> x_next is the per-target nonlinear propagator. Per-pair Jacobian F_ik = jax.jacfwd(dynamics_fn)(mean[i,k]) at the current mean. For Observation channels with obs_fn set, H_ik = jax.jacfwd(obs_fn)(predicted_mean[i,k]).

orbitalgym.belief.ekf.EKFFromTruthInitializer dataclass

EKFFromTruthInitializer(layout: Any, variance_diag: Array)

orbitalgym.belief.ekf.EKFUniformDefaultInitializer dataclass

EKFUniformDefaultInitializer(
    layout: Any, default_mean: Array, variance_diag: Array
)

Particle filter

orbitalgym.belief.pf.ParticleFilterBelief

Particle-cloud belief, per (observer, target) pair.

particles and log_weights carry the same leading (N_obs, N_total, K) indices; n_eff, weight_entropy, and resampled carry (N_obs, N_total). log_weights is stored already log-softmax-normalized along the particle (last) axis so taking softmax produces a valid probability vector at any moment.

The three pair-level metrics are pre-resample — they describe the posterior produced by the most recent measurement update. After a resample fires for a pair, weights are reset to uniform; storing the pre-resample values is what makes the collapse curve visible across a belief history (softmax(log_weights) after the resample is uniform by construction).

mean property

mean: Array

Weighted mean over the particle axis. Shape (..., N_obs, N_total, d).

orbitalgym.belief.pf.ParticleFilterBeliefUpdater dataclass

ParticleFilterBeliefUpdater(
    dynamics_fn: Callable,
    process_noise: Array,
    dt: float,
    n_eff_threshold: float = 0.5,
    resample_jitter_scale: float = 0.0,
    negative_info: NegativeInfoMode = Off(),
)

Sequential-importance-resampling particle filter.

Parameters

dynamics_fn: Per-vehicle propagator with signature (x, u, dt) -> x_next. Same shape contract as EKFBeliefUpdater.dynamics_fn. process_noise: (d, d) covariance Q used to draw additive Gaussian process noise during predict (and resample jitter). Pass a tiny positive-definite matrix when you want near-deterministic propagation; pure zeros are allowed but make resample-jitter a no-op. dt: Step size in seconds passed to dynamics_fn. n_eff_threshold: Resample fires per-pair when N_eff / K falls below this fraction. 0.5 is the standard default; 1.0 resamples every step, 0.0 never resamples. resample_jitter_scale: After resampling, add scale * chol(Q) * randn to particles so duplicate samples differ slightly. Default 0.0 (no jitter). Useful when the measurement is sharp and resamples produce many copies of the same particle.

orbitalgym.belief.pf.ParticleFilterFromTruthInitializer dataclass

ParticleFilterFromTruthInitializer(
    layout: Any,
    n_particles: int,
    jitter_scale: float = 0.001,
)

All particles start at truth, plus tiny isotropic jitter.

Use when the belief should begin as a near-delta on the true state — e.g. the guard knows its own state exactly and starts with a perfect cheat-prior on the bandit. Jitter prevents singular weight updates downstream.

orbitalgym.belief.pf.ParticleFilterRingInitializer dataclass

ParticleFilterRingInitializer(
    layout: Any,
    ring_radius_m: float | Array,
    mean_motion_rad_s: float,
    n_particles: int,
    truth_jitter_scale: float = 0.001,
    along_track_offset_m: float | Array = 0.0,
)

Uniform-on-bandit-ring prior for the LBG ring-intercept scenario.

For each cross-pair (observer i, target k where k targets the opposing side), sample K particles uniformly along the 2:1 RT-plane natural-motion ring at radius ring_radius_m. Velocity components are set from the closed-form natural-motion solution so every particle is a valid free-drift trajectory under HCW dynamics — the cloud stays on the ring as it propagates.

Self-pairs and same-side pairs are initialized to truth + tiny jitter so the observer's own-state estimate matches reality. The only ring-distributed pairs are (own-side observer, opposing-side target).

Layout requirements: n_guards, n_bandits, dynamics_state_dim (must be 4 for RT plane or 6 for RTN). RTN places zero cross-track position and rate, creating an in-plane ring.

Parameters

layout: cfg.layout from a built ScenarioConfig. ring_radius_m (scalar, or one radius per opposing vehicle): Radial-ellipse semi-major axis (matches the bandit's RelativeEllipse.radial_ellipse_m). Along-track amplitude is 2× this under HCW 2:1 motion. along_track_offset_m (scalar, or one offset per opposing vehicle): Along-track centre of the ring, matching the bandit's RelativeEllipse.along_track_offset_m. Added to the along-track position of every cross-pair particle; velocities are untouched, so the cloud is still a drift-free ring, centred this far ahead of the lady. mean_motion_rad_s: Reference orbit mean motion (use orbitalgym.reference_orbit.mean_motion(cfg.reference_orbit)). n_particles: Number of particles per (observer, target) pair. truth_jitter_scale: Jitter on self-pair / same-side particles (where particles are anchored to truth). Cross-pair particles do not get this jitter — their spread comes from the ring distribution itself.

orbitalgym.belief.pf.ParticleFilterTrackedInitializer dataclass

ParticleFilterTrackedInitializer(
    layout: Any,
    n_particles: int,
    sigma_pos_m: float,
    sigma_vel_mps: float,
    truth_jitter_scale: float = 0.001,
)

Stale-track prior: a Gaussian cloud around the opponent's truth.

Models an observer that has been tracking the opposing side and holds a track that is right but not sharp. For each cross-pair (observer i, opposing-side target k), particles are the target's true state plus independent Gaussian noise — sigma_pos_m on every position component and sigma_vel_mps on every velocity component. Self-pairs and same-side pairs are truth plus tiny jitter, as in the other particle initializers.

Layout requirements: dynamics_state_dim must be 4 (RT plane, components r, t, r_dot, t_dot) or 6 (RTN, with the cross-track position and rate drawn at the same sigmas).

Parameters

layout: cfg.layout from a built ScenarioConfig. n_particles: Number of particles per (observer, target) pair. sigma_pos_m: Standard deviation on each position component of a cross-pair particle. sigma_vel_mps: Standard deviation on each velocity component of a cross-pair particle. Pairing it with mean_motion * sigma_pos_m keeps the position and velocity uncertainty consistent under HCW relative motion. truth_jitter_scale: Jitter on the truth-anchored self-pair and same-side particles.

State

orbitalgym.state.layout.StateLayout dataclass

StateLayout(
    guard_state_cls: type,
    bandit_state_cls: type,
    n_guards: int,
    n_bandits: int,
    flat_dim: int,
    _flatten_fn: Callable,
    _unflatten_fn: Callable,
)

Bridge between the per-side pytree state and a single flat vector.

Bundles four things that derive together from n_guards, n_bandits, and the per-side state-component tuples (cfg.guard_components, cfg.bandit_components):

  • guard_state_cls, bandit_state_cls — the dynamically-assembled per-side dataclass types. These contain one field per state component (e.g. rtn for an RTN dynamics frame, mass, power, ...). The set of fields is determined by the cfg.{side}_components tuple.
  • n_guards, n_bandits — vehicle counts.
  • flat_dim — the size of the full flat state vector. For a 1v1 RTN scenario with masses, this is (6 + 1) + (6 + 1) = 14 (six dims of state per vehicle from RTN, one extra from MASS, times two sides). Custom component sets change this.
  • _flatten_fn, _unflatten_fn — JIT-compatible bijections between (guards_pytree, bandits_pytree) and a flat (flat_dim,) vector. POMDP planners use these to operate on the flat representation.

Built once per cfg in ScenarioConfig.__post_init__. Read it as cfg.layout.

dynamics_state_dim property

dynamics_state_dim: int

Per-vehicle dynamics state dim — read from the assembled state class.

bandit

bandit(bandit_state: Any, j: int) -> Any

Return the jth bandit's state as a per-vehicle pytree.

build classmethod

build(
    guard_state_cls: type,
    bandit_state_cls: type,
    n_guards: int,
    n_bandits: int,
) -> StateLayout

Construct a StateLayout using zero-initialized states to lock the pytree treedef. The treedef is what makes the flat ordering deterministic.

guard

guard(guard_state: Any, i: int) -> Any

Return the ith guard's state as a per-vehicle pytree (same fields, leading axis indexed away).

orbitalgym.state.assemble.build_state_class

build_state_class(
    components: Sequence[type[StateComponent]],
    n_vehicles: int,
    class_name: str,
) -> type[Any]

Return a flax.struct.dataclass type combining all components' fields.

The returned class exposes
  • one attribute per field (with shape (n_vehicles, *field_shape))
  • a classmethod zeros(n) that constructs a zero-initialized instance

Repeated calls with the same (components, n_vehicles, class_name) return the same class object (required for jax.lax.while_loop carry equality).

orbitalgym.state.components.RTState

2D in-plane relative state in the RT (radial + along-track) sub-frame.

rt layout: [r, t, r_dot, t_dot] — 4-dim per vehicle.

orbitalgym.state.components.RTNState

Full 3D relative state in the RTN frame.

rtn layout: [r, t, n, r_dot, t_dot, n_dot] — 6-dim per vehicle.

orbitalgym.state.components.ECIState

Absolute 6D inertial state (Earth-Centered Inertial).

eci layout: [rx, ry, rz, vx, vy, vz] — position [m] then velocity [m/s].

orbitalgym.state.components.Mass

Propellant-mass tracking.

Only propellant_mass is state; wet_mass is derived on demand as params.dry_mass_kg + state.propellant_mass.

orbitalgym.state.components.Power

Scalar state-of-charge. No-op dynamics in bootstrap (stub).

orbitalgym.state.components.Attitude

Quaternion attitude (w, x, y, z). Protocol-level stub in bootstrap.

orbitalgym.state.components.BodyRates

Body-frame angular rates (ωx, ωy, ωz). Protocol-level stub in bootstrap.

orbitalgym.state.components.AppliedDV

Per-step applied translational Δv (m/s) in the truth frame.

Transient: written by ImpulsiveManeuver.apply (or any other action component that produces Δv), consumed by the translational dynamics block in env.step, then zeroed.

Width is always 3 on state for shape uniformity. The dynamics step slices [:, :2] for Frame.RT, [:, :3] for RTN/ECI.

orbitalgym.state.components.AppliedTorque

Per-step applied body-frame torque (N·m).

Transient: written by AttitudeControl.apply (or any other torque- producing action component), consumed by rigid_body_attitude_step in env.step, then zeroed.

Action components

orbitalgym.actions.components.ActionComponent

Bases: Protocol

Composable per-side action-pytree component.

Mirrors StateComponent for the schema half (fields, zeros). Adds apply(command, side_state, side_params, dt, ref_eci6, key) -> side_state which is invoked by OrbitalGymEnv.step once per side, in component registration order. Components are responsible for any state mutation (including invoking dynamics — see ImpulsiveManeuver); the env loop only folds.

fields and zeros are instance methods so frame-aware components (e.g. ImpulsiveManeuver, where self.action_frame.dim decides the dv shape) can vary their schema. Components without per-instance state can simply ignore self.

apply

apply(
    command: Any,
    side_state: Any,
    side_params: Any,
    dt: float,
    ref_eci6: Array,
    key: Array,
) -> Any

Return the post-component side_state. Must not invoke any dynamics — translational and attitude propagation are both owned by env.step.

fields

fields() -> Mapping[str, tuple[int, ...]]

Per-agent shape of each contributed command field.

zeros

zeros(n: int) -> Mapping[str, jax.Array]

Identity command — what 'do nothing for this component' looks like. Returns dict field -> (n, *field_shape).

orbitalgym.actions.components.ImpulsiveManeuver dataclass

ImpulsiveManeuver(
    action_frame: Frame,
    truth_frame: Frame,
    track_mass: bool = True,
)

Impulsive Δv producer.

Reads command.dv (in action_frame), converts to truth_frame, optionally deducts propellant via the rocket equation, and writes the result onto side_state.applied_dv (always width 3, zero-padded for RT).

Does NOT integrate the dynamics — env.step owns the translational dynamics call (see the explicit propagation block in OrbitalGymEnv.step).

Per-step delta-v magnitude is limited to max_thrust_n * dt / m_wet and to the delta-v obtainable from remaining propellant. Direction is preserved when clipping. An empty tank produces no delta-v.

The Δv field shape follows action_frame.dim — 2 for RT (radial, along-track) and 3 for RTN/ECI. fields() and zeros() are instance methods (not staticmethods) so the assembled Command pytree carries the correct shape per scenario; build_command_class is given component instances so it can read this.

Init knobs (env wires these from ScenarioConfig): action_frame: frame the policy emits Δv in truth_frame: frame the truth dynamics integrates in track_mass: whether to deduct propellant via the rocket equation

orbitalgym.actions.components.Communicate dataclass

Communicate()

Decentralized-communication action component.

Per-agent fields

active: bool — whether this agent communicates this step payload: (D,) — broadcast payload (e.g. agent's belief mean)

Apply is state-identity: communication's effects are visible to other agents via the observation pipeline (which receives the full Actions pytree under the Phase 3 protocol change). The reward function reads active to charge a per-broadcast cost.

Payload shape is fixed at (6,) (RTN position+velocity) — JAX requires static field shapes for jit-stability. Scenarios with different payload dim must register a subclass with overridden fields()/zeros().

orbitalgym.actions.components.AttitudeControl dataclass

AttitudeControl(
    rotation_dim: int,
    torque_max: tuple[float, ...] | None = None,
)

Torque-commanding attitude control.

Reads command.torque (length rotation_dim — 1 in RT, 3 in RTN), lifts to a 3-vector in 2D (zero x,y), optionally clips per-axis to ±torque_max, writes the result onto side_state.applied_torque.

Does NOT integrate dynamics — env.step invokes rigid_body_attitude_step after the action fold. Mirror of the unified action↔dynamics pattern set by ImpulsiveManeuver: action produces, env consumes.

Attitude dynamics

orbitalgym.dynamics.attitude.AttitudeParams

Per-side rigid-body parameters.

inertia_diag: principal-axis inertias I_xx, I_yy, I_zz (kg m^2). omega_max: per-axis angular-rate saturation (rad/s).

orbitalgym.dynamics.attitude.rigid_body_attitude_step

rigid_body_attitude_step(
    quat: Array,
    omega: Array,
    torque: Array,
    params: AttitudeParams,
    dt: float,
) -> tuple[jax.Array, jax.Array]

Integrate (quat, omega) by dt under applied torque. RK4 + renormalize + clip.

IC sampling

orbitalgym.sampling.spec.ICSpec dataclass

ICSpec(
    guard_sampler: Callable,
    bandit_sampler: Callable,
    validators: tuple[Callable, ...] = (),
    max_attempts: int = 100,
)

Composable initial-condition specification.

Used as ScenarioConfig.ic_sampler. The env reads this once at reset, runs validators in a jax.lax.while_loop capped at max_attempts, and sets EnvState.ic_valid=False if the cap is hit.

orbitalgym.sampling.side.RelativeKeplerian dataclass

RelativeKeplerian(
    mean_delta_sma_m: ndarray | float = 0.0,
    sigma_delta_sma_m: ndarray | float = 0.0,
    mean_delta_ecc: ndarray | float = 0.0,
    sigma_delta_ecc: ndarray | float = 0.0,
    mean_delta_inc_rad: ndarray | float = 0.0,
    sigma_delta_inc_rad: ndarray | float = 0.0,
    mean_delta_raan_rad: ndarray | float = 0.0,
    sigma_delta_raan_rad: ndarray | float = 0.0,
    mean_delta_argp_rad: ndarray | float = 0.0,
    sigma_delta_argp_rad: ndarray | float = 0.0,
    mean_delta_mean_anomaly_rad: ndarray | float = 0.0,
    sigma_delta_mean_anomaly_rad: ndarray | float = 0.0,
    mass_sampler: ConstantMass | UniformMass | None = None,
    attitude_sampler: IdentityAttitude
    | FixedAttitude
    | UniformAttitude
    | UniformBodyRates
    | UniformAttitudeAndRates
    | None = None,
)

Sample Keplerian element differences around the reference orbit's osculating elements.

Each delta (delta_a, delta_e, delta_i, delta_RAAN, delta_omega, delta_M) is Gaussian: mean_* (default 0) and sigma_* (default 0). Scalars broadcast to all N vehicles; (N,) arrays specify per-vehicle parameters. M = mean anomaly (astrojax convention).

Singular at reference orbit e=0, i=0 (RAAN/omega/M ill-defined). Use a slightly inclined or eccentric reference orbit for clean sampling.

Precision note: this sampler relies on astrojax for KOE<->ECI conversions. The package configures astrojax for float64 at import time (orbitalgym/__init__.py), giving sub-mm round-trip residuals on Earth-orbit scenarios.

orbitalgym.sampling.side.RelativeEllipse dataclass

RelativeEllipse(
    radial_ellipse_m: ndarray | float = 0.0,
    cross_track_m: ndarray | float = 0.0,
    along_track_offset_m: ndarray | float = 0.0,
    along_track_drift_m_per_orbit: ndarray | float = 0.0,
    sigma_radial_ellipse_m: ndarray | float = 0.0,
    sigma_cross_track_m: ndarray | float = 0.0,
    sigma_along_track_offset_m: ndarray | float = 0.0,
    phase_rad: ndarray | float | None = None,
    mass_sampler: ConstantMass | UniformMass | None = None,
    attitude_sampler: IdentityAttitude
    | FixedAttitude
    | UniformAttitude
    | UniformBodyRates
    | UniformAttitudeAndRates
    | None = None,
)

Sample bounded relative orbits via NSROE with ellipse extents in meters.

The non-singular relative orbital elements (Schaub & Junkins; Lyu & D'Amico) map to physical extents as: a * |delta_e| = radial-ellipse semi-major axis (m) a * |delta_i| = cross-track amplitude (m) a * delta_lambda = mean along-track separation (m) a * delta_a = secular drift coefficient (1.5ndelta_a per radian)

radial_ellipse_m / cross_track_m: scalar broadcasts; (N,) per-vehicle. along_track_offset_m: same. along_track_drift_m_per_orbit: optional; default 0 -> bounded. sigma_*: optional Gaussian noise added to the corresponding extent. phase_rad: per-vehicle initial phase along the relative ellipse. None -> uniform random per vehicle. scalar / (N,) -> deterministic per vehicle.

orbitalgym.sampling.mass.ConstantMass dataclass

ConstantMass(propellant_mass_kg: float)

Same propellant mass for all N vehicles.

orbitalgym.sampling.mass.UniformMass dataclass

UniformMass(low_kg: float, high_kg: float)

Independent uniform [low, high] propellant mass per vehicle.

orbitalgym.sampling.validators.MinSeparation dataclass

MinSeparation(
    distance_m: float,
    scope: SeparationScope = SeparationScope.ALL,
)

Reject if the configured pair scope has any pair closer than distance_m.

scope

ALL - every pair across both sides WITHIN_GUARDS - guard-guard pairs only WITHIN_BANDITS - bandit-bandit pairs only CROSS - guard-bandit pairs only

orbitalgym.sampling.validators.MaxRange dataclass

MaxRange(
    distance_m: float, side: SideSelector = SideSelector.ALL
)

Reject if any selected vehicle is farther than distance_m from the reference orbit origin.

Since RTN positions are reference-orbit-relative, the reference orbit origin is (0,0,0) in this frame. side: ALL - both guards and bandits GUARDS - guards only BANDITS - bandits only

orbitalgym.sampling.attitude.IdentityAttitude dataclass

IdentityAttitude()

Identity quaternion (1, 0, 0, 0); zero body rates. Default behavior.

orbitalgym.sampling.attitude.FixedAttitude dataclass

FixedAttitude(
    quat_wxyz: tuple[float, float, float, float] = (
        1.0,
        0.0,
        0.0,
        0.0,
    ),
    omega_rad_s: tuple[float, float, float] = (
        0.0,
        0.0,
        0.0,
    ),
)

Pinned quaternion + body rates. Both broadcast to all N vehicles.

Useful for demonstrations and deterministic test scenarios where the user wants every spacecraft to start with the same known orientation and slow spin.

orbitalgym.sampling.attitude.UniformAttitude dataclass

UniformAttitude()

Uniformly random quaternion (Marsaglia method); zero body rates.

Samples q from N(0, I_4)^N and normalizes. Canonicalizes to the hemisphere with w >= 0 (since q and -q represent the same rotation).

orbitalgym.sampling.attitude.UniformBodyRates dataclass

UniformBodyRates(
    omega_max_rad_s: tuple[float, float, float] = (
        0.0,
        0.0,
        0.0,
    ),
)

Identity quaternion; uniform random body rates per axis.

omega ~ Uniform(-omega_max, omega_max) per axis, per vehicle.

orbitalgym.sampling.attitude.UniformAttitudeAndRates dataclass

UniformAttitudeAndRates(
    omega_max_rad_s: tuple[float, float, float] = (
        0.0,
        0.0,
        0.0,
    ),
)

Both quaternion and body rates randomized.