rans-env / server /spacecraft_physics.py
dpang's picture
feat: multi-mode control (server/spacecraft_physics.py)
90eb3a5 verified
# Copyright (c) Space Robotics Lab, SnT, University of Luxembourg, SpaceR
# RANS: arXiv:2310.07393
"""
2-D Spacecraft (Modular Floating Platform) Physics Simulation
=============================================================
Pure-NumPy implementation of the rigid-body dynamics described in the RANS
paper, Section III. This lets the environment run inside Docker containers
without NVIDIA Isaac Gym / Isaac Sim.
State vector: [x, y, ΞΈ, vx, vy, Ο‰]
x, y β€” world-frame position (m)
ΞΈ β€” heading / yaw angle (rad, wrapped to (βˆ’Ο€, Ο€])
vx, vy β€” world-frame linear velocity (m/s)
Ο‰ β€” angular velocity (rad/s)
Action: activations ∈ [0, 1]^n_thrusters (continuous) or {0,1}^n (binary).
Dynamics:
F_body = Ξ£_i a_i Β· F_max_i Β· dΜ‚_i (body-frame force vector)
F_world = R(ΞΈ) Β· F_body
a_linear = F_world / m (linear acceleration)
Ο„_i = (p_i Γ— F_max_i Β· dΜ‚_i)_z = p_xΒ·d_y βˆ’ p_yΒ·d_x
Ξ± = Ξ£_i a_i Β· Ο„_i / I (angular acceleration)
Integration: Euler with timestep dt
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from typing import List, Optional
import numpy as np
# ---------------------------------------------------------------------------
# Thruster configuration
# ---------------------------------------------------------------------------
@dataclass
class ThrusterConfig:
"""Configuration for a single thruster on the 2-D spacecraft."""
position: np.ndarray # [px, py] in body frame (m)
direction: np.ndarray # unit-vector [dx, dy] of applied force in body frame
force_max: float = 1.0 # peak thrust (N)
def __post_init__(self) -> None:
self.position = np.asarray(self.position, dtype=np.float64)
self.direction = np.asarray(self.direction, dtype=np.float64)
# Normalise direction
norm = np.linalg.norm(self.direction)
if norm > 1e-9:
self.direction = self.direction / norm
# ---------------------------------------------------------------------------
# Spacecraft configuration
# ---------------------------------------------------------------------------
@dataclass
class SpacecraftConfig:
"""
Physical parameters and thruster layout for the 2-D spacecraft.
Matches the MFP2D (Modular Floating Platform) configuration used in RANS.
"""
mass: float = 10.0 # kg
inertia: float = 0.50 # kgΒ·mΒ²
dt: float = 0.02 # simulation timestep (50 Hz)
max_episode_steps: int = 500
thrusters: List[ThrusterConfig] = field(default_factory=list)
@classmethod
def default_8_thruster(cls) -> "SpacecraftConfig":
"""
Standard 8-thruster MFP2D layout from the RANS paper.
Thrusters are arranged in four pairs:
β€’ Two pairs provide pure translational force (Β±X, Β±Y in body frame)
β€’ Two pairs create coupled translational + rotational force (diagonal)
This gives the platform full 3-DoF controllability (x, y, ΞΈ).
"""
r = 0.31 # m β€” radial distance from CoM to thruster attachment point
thrusters = [
# ── Translational thrusters (body Β±X) ──────────────────────────
# Mounted on Β±Y edges, thrust along +X body axis
ThrusterConfig(position=[ 0.0, r], direction=[1.0, 0.0]),
ThrusterConfig(position=[ 0.0, -r], direction=[1.0, 0.0]),
# Mounted on Β±Y edges, thrust along βˆ’X body axis
ThrusterConfig(position=[ 0.0, r], direction=[-1.0, 0.0]),
ThrusterConfig(position=[ 0.0, -r], direction=[-1.0, 0.0]),
# ── Rotational / combined thrusters (diagonal) ──────────────────
# CCW torque: thrusters at corners fire tangentially
ThrusterConfig(
position=[ r * 0.707, r * 0.707],
direction=[-0.707, 0.707],
),
ThrusterConfig(
position=[-r * 0.707, -r * 0.707],
direction=[ 0.707, -0.707],
),
# CW torque
ThrusterConfig(
position=[-r * 0.707, r * 0.707],
direction=[ 0.707, 0.707],
),
ThrusterConfig(
position=[ r * 0.707, -r * 0.707],
direction=[-0.707, -0.707],
),
]
return cls(thrusters=thrusters)
@classmethod
def default_4_thruster(cls) -> "SpacecraftConfig":
"""
Minimal 4-thruster layout (under-actuated in rotation).
Useful for simpler position-tracking experiments.
"""
r = 0.31
thrusters = [
ThrusterConfig(position=[ 0.0, r], direction=[1.0, 0.0]), # +X
ThrusterConfig(position=[ 0.0, -r], direction=[-1.0, 0.0]), # βˆ’X
ThrusterConfig(position=[ r, 0.0], direction=[0.0, 1.0]), # +Y
ThrusterConfig(position=[-r, 0.0], direction=[0.0, -1.0]), # βˆ’Y
]
return cls(thrusters=thrusters)
@classmethod
def from_num_thrusters(
cls,
n: int,
radius: float = 0.31,
force_max: float = 1.0,
) -> "SpacecraftConfig":
"""
Generate a symmetric N-thruster layout around a circle of given radius.
Thrusters are placed at angles ``2Ο€i/N`` and fire tangentially,
alternating CCW/CW so the platform retains 3-DoF controllability.
Args:
n: Number of thrusters (4 ≀ n ≀ 16, must be even).
radius: Radial distance from CoM to each thruster (m).
force_max: Peak thrust per thruster (N).
"""
if n < 4 or n > 16 or n % 2 != 0:
raise ValueError(
f"n must be an even integer in [4, 16], got {n}."
)
thrusters = []
for i in range(n):
theta = 2.0 * math.pi * i / n
px = radius * math.cos(theta)
py = radius * math.sin(theta)
# Alternate CCW / CW tangential firing direction
sign = 1.0 if i % 2 == 0 else -1.0
dx = -sign * math.sin(theta)
dy = sign * math.cos(theta)
thrusters.append(
ThrusterConfig(position=[px, py], direction=[dx, dy], force_max=force_max)
)
return cls(thrusters=thrusters)
# ---------------------------------------------------------------------------
# Spacecraft dynamics
# ---------------------------------------------------------------------------
class Spacecraft2D:
"""
2-D spacecraft rigid-body simulator.
State: s = [x, y, ΞΈ, vx, vy, Ο‰] (float64)
Action: a = [a_0, …, a_{Nβˆ’1}] ∈ [0, 1] per thruster
The simulation runs at ``config.dt`` seconds per step.
"""
def __init__(self, config: Optional[SpacecraftConfig] = None) -> None:
self.config = config or SpacecraftConfig.default_8_thruster()
self.n_thrusters = len(self.config.thrusters)
self._precompute_thruster_matrices()
self._state = np.zeros(6, dtype=np.float64)
# ------------------------------------------------------------------
# Pre-computation
# ------------------------------------------------------------------
def _precompute_thruster_matrices(self) -> None:
"""
Build static matrices for body-frame force and torque.
force_body_matrix [2 Γ— N]: col i = direction_i * force_max_i
torque_vector [N]: element i = (p Γ— d)_z * force_max_i
"""
n = self.n_thrusters
self._force_mat = np.zeros((2, n), dtype=np.float64)
self._torque_vec = np.zeros(n, dtype=np.float64)
for i, t in enumerate(self.config.thrusters):
self._force_mat[:, i] = t.direction * t.force_max
# Cross product z-component: px*dy βˆ’ py*dx
self._torque_vec[i] = (
t.position[0] * t.direction[1] - t.position[1] * t.direction[0]
) * t.force_max
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def reset(self, state: Optional[np.ndarray] = None) -> np.ndarray:
"""
Reset to a given state (or zeros).
Args:
state: [x, y, ΞΈ, vx, vy, Ο‰], or None β†’ zero state.
Returns:
Copy of the initial state.
"""
if state is None:
self._state = np.zeros(6, dtype=np.float64)
else:
self._state = np.asarray(state, dtype=np.float64).copy()
self._state[2] = self._wrap_angle(self._state[2])
return self._state.copy()
def step(self, activations: np.ndarray) -> np.ndarray:
"""
Advance simulation by one timestep.
Args:
activations: Thruster commands, shape [n_thrusters], clamped to [0,1].
Returns:
New state [x, y, ΞΈ, vx, vy, Ο‰].
"""
a = np.clip(activations, 0.0, 1.0)
x, y, theta, vx, vy, omega = self._state
dt = self.config.dt
# Body-frame force vector (shape [2])
F_body = self._force_mat @ a
# Rotate to world frame
c, s = math.cos(theta), math.sin(theta)
ax = (c * F_body[0] - s * F_body[1]) / self.config.mass
ay = (s * F_body[0] + c * F_body[1]) / self.config.mass
# Angular acceleration
alpha = (self._torque_vec @ a) / self.config.inertia
# Euler integration (position uses mid-point correction)
self._state[0] = x + vx * dt + 0.5 * ax * dt * dt
self._state[1] = y + vy * dt + 0.5 * ay * dt * dt
theta_new = theta + omega * dt + 0.5 * alpha * dt * dt
self._state[2] = self._wrap_angle(theta_new)
self._state[3] = vx + ax * dt
self._state[4] = vy + ay * dt
self._state[5] = omega + alpha * dt
return self._state.copy()
def step_force_torque(
self,
fx_world: float,
fy_world: float,
torque: float,
) -> np.ndarray:
"""
Advance the simulation by applying world-frame forces and yaw torque
directly, bypassing the thruster geometry.
Args:
fx_world: Force in world-frame X direction (N).
fy_world: Force in world-frame Y direction (N).
torque: Yaw torque (NΒ·m). Positive = CCW.
Returns:
New state [x, y, ΞΈ, vx, vy, Ο‰].
"""
x, y, theta, vx, vy, omega = self._state
dt = self.config.dt
ax = fx_world / self.config.mass
ay = fy_world / self.config.mass
alpha = torque / self.config.inertia
self._state[0] = x + vx * dt + 0.5 * ax * dt * dt
self._state[1] = y + vy * dt + 0.5 * ay * dt * dt
self._state[2] = self._wrap_angle(theta + omega * dt + 0.5 * alpha * dt * dt)
self._state[3] = vx + ax * dt
self._state[4] = vy + ay * dt
self._state[5] = omega + alpha * dt
return self._state.copy()
def step_velocity_target(
self,
vx_target: float,
vy_target: float,
omega_target: float,
kp: float = 5.0,
) -> np.ndarray:
"""
Advance the simulation by driving toward target velocities via a
proportional controller.
The controller computes the required world-frame force / torque and
clips them to the platform's physical limits before applying.
Args:
vx_target: Desired world-frame X velocity (m/s).
vy_target: Desired world-frame Y velocity (m/s).
omega_target: Desired yaw rate (rad/s).
kp: Proportional gain (default 5.0).
Returns:
New state [x, y, ΞΈ, vx, vy, Ο‰].
"""
_, _, _, vx, vy, omega = self._state
fx = kp * self.config.mass * (vx_target - vx)
fy = kp * self.config.mass * (vy_target - vy)
torque = kp * self.config.inertia * (omega_target - omega)
# Clip to physical limits
f_max = float(np.sum(np.linalg.norm(self._force_mat, axis=0)))
tau_max = float(np.sum(np.abs(self._torque_vec)))
fx = float(np.clip(fx, -f_max, f_max))
fy = float(np.clip(fy, -f_max, f_max))
torque = float(np.clip(torque, -tau_max, tau_max))
return self.step_force_torque(fx, fy, torque)
# ------------------------------------------------------------------
# Observation helpers
# ------------------------------------------------------------------
def get_thruster_transforms(self) -> np.ndarray:
"""
Return thruster layout as a float array for the observation.
Returns:
Shape [n_thrusters, 5]: each row = [px, py, dx, dy, force_max]
"""
T = np.zeros((self.n_thrusters, 5), dtype=np.float32)
for i, t in enumerate(self.config.thrusters):
T[i, 0:2] = t.position
T[i, 2:4] = t.direction
T[i, 4] = t.force_max
return T
def get_thruster_masks(self) -> np.ndarray:
"""Binary mask: 1.0 for every valid thruster slot."""
return np.ones(self.n_thrusters, dtype=np.float32)
# ------------------------------------------------------------------
# Properties
# ------------------------------------------------------------------
@property
def state(self) -> np.ndarray:
return self._state.copy()
@property
def position(self) -> np.ndarray:
return self._state[:2].copy()
@property
def heading(self) -> float:
return float(self._state[2])
@property
def linear_velocity(self) -> np.ndarray:
return self._state[3:5].copy()
@property
def angular_velocity(self) -> float:
return float(self._state[5])
# ------------------------------------------------------------------
# Internal utilities
# ------------------------------------------------------------------
@staticmethod
def _wrap_angle(angle: float) -> float:
return (angle + math.pi) % (2.0 * math.pi) - math.pi