Action components¶
OrbitalGymEnv composes per-side action components the same way it
composes state components. Each action component declares a static
field schema (fields(), zeros(n)) plus an instance method
apply(...) that mutates the side state. The env folds the configured
tuple of components per side, in registration order, every step.
This page covers the protocol, the default (IMPULSIVE_MANEUVER,)
setup, the LBG-with-comms composition, and the worked example for
adding a second component to a side.
What ships¶
| Key | What it carries | Per-agent fields |
|---|---|---|
ActionComponentKey.IMPULSIVE_MANEUVER |
Impulsive Δv (writes applied_dv; env.step propagates) |
dv (action_frame.dim,) — 2 for RT, 3 for RTN/ECI |
ActionComponentKey.COMMUNICATE |
Per-step broadcast flag + payload | active (), payload (6,) |
ActionComponentKey.ATTITUDE_CONTROL |
Body-frame torque (writes applied_torque; env.step integrates attitude) |
torque (rotation_dim,) — 1 for RT, 3 for RTN |
ImpulsiveManeuver is frame-aware: its dv field shape is read
from its action_frame setting (which the env wires from
cfg.action_frame, defaulting to the truth-dynamics frame). RT
scenarios get a 2-D Δv; RTN and ECI scenarios get 3-D. This means
fields() and zeros() are instance methods (not static) on
ImpulsiveManeuver and Communicate — they read self. The
build_command_class helper takes component instances, not
classes, so the assembled Command pytree carries the correct shape.
ImpulsiveManeuver.apply reads command.dv, converts to the truth frame,
optionally deducts propellant, and writes the result to state.applied_dv.
It does not invoke dynamics — env.step owns an explicit translational
dynamics block that runs after the action fold. Communicate is
state-identity; its only effect on rewards/observations flows through
the Actions pytree that downstream consumers receive.
The component protocol¶
fields() -> Mapping[str, tuple[int, ...]] # per-agent field shapes
zeros(n) -> Mapping[str, jax.Array] # identity command (n agents)
apply(command, side_state, side_params, dt, ref_eci6, key) -> side_state
fields and zeros are static — they describe the schema and the
"do nothing" command. apply is the instance method — it consumes
one component's slice of the per-side Command pytree and returns the
post-component side state. Action components write control inputs (e.g.
applied_dv, applied_torque) onto transient state fields and return; the
env loop owns the dynamics calls that consume those fields.
OrbitalGymEnv exposes the per-side Command class as
env.guard_command_cls and env.bandit_command_cls. Both are
flax-struct dataclasses (JAX pytrees) with one attribute per field
contributed by any registered component.
The (IMPULSIVE_MANEUVER,) default¶
Every bundled scenario starts both sides with a single IMPULSIVE_MANEUVER component:
from orbitalgym import OrbitalGymEnv, make_lady_bandit_guard
from orbitalgym.registry import ActionComponentKey
cfg = make_lady_bandit_guard(n_guards=1, n_bandits=1, seed=0)
# Every bundled scenario defaults both sides to a single
# IMPULSIVE_MANEUVER component.
assert cfg.guard_action_components == (ActionComponentKey.IMPULSIVE_MANEUVER,)
assert cfg.bandit_action_components == (ActionComponentKey.IMPULSIVE_MANEUVER,)
env = OrbitalGymEnv(cfg)
# The per-side Command class exposes one field per component field —
# IMPULSIVE_MANEUVER contributes `dv` of shape (N_side, 3).
cmd = env.guard_command_cls.zeros(cfg.n_guards)
assert cmd.dv.shape == (cfg.n_guards, 3)
ScenarioConfig.guard_action_components and
ScenarioConfig.bandit_action_components both default to
(ActionComponentKey.IMPULSIVE_MANEUVER,). Under that default the
per-side Command pytree carries exactly one field, dv, with shape
(N_side, action_frame.dim) — which is (N_side, 3) for RTN/ECI and
(N_side, 2) for the 2-D RT case.
Composing components¶
Set guard_action_components (or bandit_action_components) to a
longer tuple to add components. The LBG-with-comms factory does this
to add a Communicate component to the guard side:
from orbitalgym import OrbitalGymEnv, make_lady_bandit_guard
cfg = make_lady_bandit_guard(
n_guards=1, n_bandits=1, with_communication=True, comm_cost=5.0, seed=0
)
env = OrbitalGymEnv(cfg)
# The guard side now composes (IMPULSIVE_MANEUVER, COMMUNICATE) — its
# Command class carries `dv` (from IMPULSIVE_MANEUVER) plus `active` and
# `payload` (from COMMUNICATE).
cmd = env.guard_command_cls.zeros(cfg.n_guards)
assert cmd.dv.shape == (cfg.n_guards, 3)
assert cmd.active.shape == (cfg.n_guards,)
assert cmd.payload.shape == (cfg.n_guards, 6)
# The bandit side keeps the (IMPULSIVE_MANEUVER,) default — only `dv`.
bandit_cmd = env.bandit_command_cls.zeros(cfg.n_bandits)
assert hasattr(bandit_cmd, "dv")
assert not hasattr(bandit_cmd, "active")
The guard's Command class now carries dv and active and
payload. The bandit's Command class is unchanged — sides compose
independently.
Registration order = apply order
Components in guard_action_components are applied in tuple order
each step. With (IMPULSIVE_MANEUVER, COMMUNICATE) the guard's
IMPULSIVE_MANEUVER runs first (propagating the dynamics), then
COMMUNICATE runs on the post-maneuver state. Reorder the tuple to
reorder the apply sequence.
Worked example: LBG with communication¶
make_lady_bandit_guard(with_communication=True, comm_cost=5.0)
swaps in:
guard_action_components = (IMPULSIVE_MANEUVER, COMMUNICATE)— the guard's Command pytree gains theactive/payloadfields.LbgWithCommsReward(comm_cost=5.0)— chargescomm_costper active broadcast on top of the distance-to-reference baseline.
The bandit side is unchanged. Step the env from the same starting state with comms on vs off, and the reward delta isolates the comm cost:
import jax
import jax.numpy as jnp
from orbitalgym import Actions, BySide, OrbitalGymEnv, make_lady_bandit_guard
cfg = make_lady_bandit_guard(with_communication=True, comm_cost=5.0, seed=0)
env = OrbitalGymEnv(cfg)
state, _ = env.reset(jax.random.PRNGKey(42))
# Step from the SAME state with comms off, then comms on. Δv is zero in
# both, so the only difference is the `active` flag — and the reward
# delta should equal exactly comm_cost.
rewards = []
for active in (False, True):
guard_cmd = env.guard_command_cls.zeros(cfg.n_guards).replace(active=jnp.array([active]))
actions = Actions(
sides=BySide(
guard=guard_cmd,
bandit=env.bandit_command_cls.zeros(cfg.n_bandits),
)
)
out = env.step(jax.random.PRNGKey(1), state, actions)
rewards.append(float(out.outputs.guard.reward))
silent_reward, comms_reward = rewards
assert jnp.isclose(silent_reward - comms_reward, 5.0, atol=1e-5)
silent_reward - comms_reward is exactly comm_cost: the
distance-to-reference baseline cancels (same starting state, zero Δv
on both branches), the only difference is the active flag the
reward function reads off the guard's command.
Building a Command class directly¶
You usually don't construct a Command class by hand — the env builds
one per side from the configured tuple at OrbitalGymEnv.__init__
time. But the same build_command_class helper is available if you
need it (for unit tests, custom adapters, or documentation that
constructs a policy in isolation):
from orbitalgym.actions.assemble import build_command_class
from orbitalgym.actions.components import Communicate, ImpulsiveManeuver
from orbitalgym.registry import Frame
# Per-side Command classes are built from a tuple of ActionComponent
# *instances*. Order in this tuple determines apply order in env.step.
maneuver = ImpulsiveManeuver(
action_frame=Frame.RTN,
truth_frame=Frame.RTN,
)
cmd_cls = build_command_class(
(maneuver, Communicate()),
n_agents=2,
class_name="DocCmd",
)
cmd = cmd_cls.zeros(2)
assert cmd.dv.shape == (2, 3)
assert cmd.active.shape == (2,)
assert cmd.payload.shape == (2, 6)
build_command_class takes a tuple of component instances, the
per-side agent count, and a class name. The returned class is a
flax.struct.dataclass with a zeros(n) classmethod that produces the
identity command — exactly what ZeroControl returns.
Instances, not classes
Earlier versions accepted component classes with @staticmethod
fields() / zeros(). The frame-aware ImpulsiveManeuver reads
self.action_frame to size its dv field, so callers now pass
instances (e.g. (ImpulsiveManeuver(truth_dynamics=...,
action_frame=Frame.RT, truth_frame=Frame.RT, track_mass=True),))
and the env's _build_component_instances helper does this for
you when you configure via the registry key.
AttitudeControl¶
AttitudeControl is the torque-commanding component for scenarios with rigid-body
attitude dynamics. It mirrors ImpulsiveManeuver in the unified action-to-dynamics
pattern: the component writes control output to a transient field; env.step
consumes it.
| Field | Shape | Meaning |
|---|---|---|
torque |
(rotation_dim,) |
Commanded body-frame torque (N·m) |
rotation_dim is 1 for RT (2D) scenarios — the policy emits a single scalar
torque about the body-z / N axis. For RTN (3D) scenarios, rotation_dim=3 and
the policy emits a full 3-vector. AttitudeControl.apply lifts the 1-D scalar
to [0, 0, τz] before writing applied_torque; in the 3-D case it passes the
vector through.
torque_max is an optional per-axis actuator clip (Nx, Ny, Nz) applied
before writing applied_torque. It models the maximum torque the actuator
hardware can produce, separately from the reaction-wheel saturation cap
(omega_max) in AttitudeParams — both limits may be active simultaneously,
or only one.
AttitudeControl requires both ATTITUDE and BODY_RATES on the side it is
wired into; ScenarioConfig.__post_init__ raises if they are absent. To add
attitude control to a guard-only setup:
from orbitalgym.registry import ActionComponentKey, StateComponentKey, AttitudeDynamicsKey
from orbitalgym.dynamics.attitude import AttitudeParams
import jax.numpy as jnp
cfg = ScenarioConfig(
# ...
guard_components=(
StateComponentKey.RTN,
StateComponentKey.MASS,
StateComponentKey.ATTITUDE,
StateComponentKey.BODY_RATES,
),
guard_action_components=(
ActionComponentKey.IMPULSIVE_MANEUVER,
ActionComponentKey.ATTITUDE_CONTROL,
),
attitude_dynamics_key=AttitudeDynamicsKey.RIGID_BODY,
guard_attitude_params=AttitudeParams(
inertia_diag=jnp.array([10.0, 10.0, 5.0]),
omega_max=jnp.array([0.5, 0.5, 1.0]),
),
attitude_control_torque_max=(0.5, 0.5, 0.5),
# ...
)
AttitudeControl is applied after ImpulsiveManeuver in the action fold
when both are listed. Registration order in guard_action_components controls
the apply order — the tuple is folded left to right each step.
Where it lives¶
src/orbitalgym/actions/components.py— theActionComponentprotocol plusImpulsiveManeuver,Communicate, andAttitudeControl.src/orbitalgym/actions/assemble.py—build_command_class, the per-side Command-class builder. Caches by component-tuple identity sojax.lax.while_loopcarry-pytree-structure equality holds across resets.src/orbitalgym/env/core.py— wiresguard_action_components/bandit_action_componentsfrom the config into per-side_action_component_instancestuples andguard_command_cls/bandit_command_cls.
Writing a new action component¶
The pattern mirrors the State layout walkthrough:
- Component dataclass. A frozen dataclass with a
nameClassVar keying it intoActionComponentKey. Implementfields(),zeros(n), andapply(...)per the protocol. - Register it.
@register(ActionComponentKey.YOUR_KEY)so the env can resolve the key from aScenarioConfig. - Wire the side. Add the key to
guard_action_componentsorbandit_action_componentson the scenario config (typically inside amake_<game>builder, behind a kwarg likewith_communication). - Reward / observation hook (optional). If your component should
cost something or be visible to the opponent, write a custom
reward / observation that reads the new command field off the
Actionspytree — exactly howLbgWithCommsRewardreadsaction.sides.guard.active.
The env loop and the rollout machinery require no changes — they fold whatever tuple you configure, in tuple order.
Where to next¶
- Dynamics — the translational and attitude dynamics layers
that
env.stepruns after the action fold. Action components and dynamics are independently swappable. - State layout & adding a Power component — the state-side analogue. State components carry data; action components carry commands and apply them.
- API reference → Components — the
ActionComponentprotocol signature.