Getting started¶
Install¶
Python 3.11 through 3.14. For development, see CONTRIBUTING.
An episode, directly¶
Environments follow the gymnax
interface: pure functions taking a PRNG key, a state and parameters, and
returning the next state. Nothing is hidden in instance attributes, so the
whole thing composes with jit, vmap and scan.
import jax
import jax.numpy as jnp
from target_gym import CSTR, CSTRParams
env = CSTR()
params = CSTRParams(max_steps_in_episode=100)
key = jax.random.PRNGKey(0)
obs, state = env.reset_env(key, params)
step = jax.jit(env.step_env)
total = 0.0
for _ in range(params.max_steps_in_episode):
action = jnp.array([0.0])
obs, state, reward, terminated, info = step(key, state, action, params)
total += float(reward)
if bool(terminated):
break
print(total)
step_env reports natural termination only -- the plant reaching a state
it cannot come back from. The time limit is separate, and gymnax's six-value
env.step applies it, returning terminated and truncated separately. Use
step_env when you want to reason about the physics, and step when you want
the standard RL episode boundary.
Many episodes at once¶
The reason for the functional interface: rollouts vectorise.
import jax
import jax.numpy as jnp
from target_gym import CSTR, CSTRParams
env, params = CSTR(), CSTRParams(max_steps_in_episode=100)
def episode_return(key):
_, state = env.reset_env(key, params)
def body(carry, _):
state, total = carry
_, state, reward, _, _ = env.step_env(key, state, jnp.zeros(1), params)
return (state, total + reward), None
(_, total), _ = jax.lax.scan(body, (state, 0.0), None, params.max_steps_in_episode)
return total
keys = jax.random.split(jax.random.PRNGKey(0), 256)
returns = jax.jit(jax.vmap(episode_return))(keys)
print(returns.mean())
The Gymnasium interface¶
For libraries that expect the classic API, every environment has a wrapper:
from target_gym import GymnasiumPlane
env = GymnasiumPlane()
obs, info = env.reset(seed=0)
obs, reward, terminated, truncated, info = env.step((0.8, 0.0))
gym_wrapper_factory builds the same wrapper for any of the JAX environments.
Note that the wrapper is stateful and returns NumPy -- unlike step_env,
it must not be wrapped in jax.jit.
Working from the registry¶
The registry is how the library refers to environments generically -- it is what the test suite, the baselines and the figure generation are all driven by, and the most convenient way to write code that works across environments.
from target_gym.registry import REGISTRY
spec = REGISTRY["boiler_drum"]
env, params = spec.make_env(), spec.params_cls()
pid = spec.make_pid() # the shipped baseline, or None
pid.reset()
See the environment reference for every registry name.
Rendering¶
Each environment renders a control-room dashboard: a live plant schematic, an instrument stack with limit and setpoint markers, and strip charts. Quantities the controller cannot measure are marked, so a frame shows both what the agent knows and what it is up against.
Full guide, including the two rendering toolkits, headless setup and how to regenerate the shipped media: Rendering.
from target_gym import Plane, PlaneParams
env = Plane()
params = PlaneParams(max_steps_in_episode=1_000)
env.save_video(
lambda obs: (0.8, 0.0), seed=42,
folder="videos", episode_index=0, params=params, format="gif",
)
The multi-agent patrol¶
(both aircraft learn a cooperative formation):
import jax
from target_gym import PlanePatrolMARL
env = PlanePatrolMARL(num_wingmen=4) # 5 planes: 1 lead + 4 wingmen
params = env.default_params
key = jax.random.PRNGKey(0)
obs, state = env.reset(key, params) # obs = {"lead": ..., "wingman_0": ..., ...}
actions = {agent: env.action_space(agent).sample(key) for agent in env.agents}
obs, state, rewards, dones, info = env.step(key, state, actions, params)
# rewards/dones are dicts keyed by agent (+ dones["__all__"]); reward is shared.
Wind and turbulence¶
Every plane-based env (Plane, Plane3D, PlanePatrol, PlanePatrolMARL)
inherits a wind model applied to the air-relative aerodynamics. It is an
unobservable disturbance by default — pass observe_wind=True for a
fully-observable baseline. Formations feel a single shared gust field.
from target_gym import Plane, PlaneParams
params = PlaneParams(
wind_x=-15.0, # steady mean wind (m/s), world frame
wind_shear_x=0.02, # + linear altitude shear: +0.02 m/s per metre above...
shear_ref_alt=5000.0, # ...this reference altitude
turbulence_sigma=3.0, # + Ornstein-Uhlenbeck gusts (0 = off); theta = turbulence_theta
)
hidden = Plane() # wind is a hidden disturbance (POMDP)
baseline = Plane(observe_wind=True) # appends the realized wind to the observation