File size: 14,313 Bytes
e8edc26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90eb3a5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e8edc26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90eb3a5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e8edc26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
# 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