Swarming Itokawa with cubesats#

In this example we consider a rather advanced mission option inspired to us by the European Space Agency HERA mission. HERA is an interplanetary spacecraft that will explore the binary system named Didymos in 2025. In the case of HERA, two cubesats called Milani and Juventas, will detach from the main mother satellite to perform some detailed exploration of the system, including the exploartion of its gravity field. The use of cubesats to increase the mission scientific output, while minimizing risks connected to operating in a highly unknown environment, is being considered as an option for several advanced missions.

We will show the use of cascade to study the dynamics of a large number of cubesats simultaneously orbiting an irregular body. We will assume a number of cubesats get deployed from a common orbit and compute the \(DV\) needed for such a swarm to avoid collision while keeping all the cubesats within some maximum and minimum range from the asteroid centre.

Note

This example concerns a case that has not driven the development of cascade. In particular, the events frequency (collision reentry and exit) is much higher if compared to the optimal timestep of Taylor integrator. As a consequence the overall algorithm, still very usable, becomes less efficient as dynamics computations (i.e. the Taylor coefficients) are thrown away at each event trigger.

Let us start with some imports:

# core imports
import pykep as pk
import numpy as np
import scipy
import pickle as pkl
import cascade as csc
import heyoka as hy
from copy import deepcopy

# plotting
from mpl_toolkits.mplot3d import Axes3D
from matplotlib import pyplot as plt
%matplotlib inline

Importing and defining the irregular body#

In the example we are going to consider the asteroid Itokawa. We will use mascons to have a representation of irregular bodies. In other words, Itokawa will be modelled as set of point masses located in specific positions and with some given mass.

with open("data/itokawa_mascon.pk", "rb") as file:
    mascon_points, mascon_masses, name = pkl.load(file)
print(name+":", "number of mascons is", len(mascon_masses))
Itokawa: number of mascons is 41748

In the case of Itokawa, the mascon model of the body is here provided by the pickled file itokawa_mascon.pk and was developed in the project geodesyNET. This particular mascon model corresponds to a perfectly uniform density asteroid. While Itokawa is actually inhomogeneous, for the purpose of this example a homogenous model can be used too. Some physical properties of the asteroid are defined next:

L = 350.44 # [m] units of length (used in the mascon model)
M = 3.51E10 # [kg] units of mass (the Itokawa mass)
G = 6.6743E-11 # [m^3/s^2/kg] units for G (the Cavendish constant)
T = np.sqrt(L**3/G/M) # [s] induced units of time
wz = 2*np.pi / (12.132 * 60. * 60. / T) # asteroid angular velocity is 12.132 hours, here computed in the selected units
w_vec = np.array([0., 0., wz]) # we assume a rotation around the z axis

we now visualize the mascon model:

# Define the 3d Axis
fig = plt.figure()
ax = fig.add_subplot(111, projection='3d')

# Plot all mascons 
ax.scatter3D(mascon_points[:,0], mascon_points[:,1], mascon_points[:,2], alpha=0.03, s=1, c='k')

# Plot a safety sphere around it
r = np.sqrt(1.1)
u, v = np.mgrid[0:2*np.pi:40j, 0:np.pi:40j]
X = r*np.cos(u)*np.sin(v)
Y = r*np.sin(u)*np.sin(v)
Z = r*np.cos(v)
ax.plot_wireframe(X, Y, Z, color="r", alpha=0.1)

# Set the axis limits 
limit=1
ax.set_xlim(-limit,limit)
ax.set_ylim(-limit,limit)
ax.set_zlim(-limit,limit)
ax.view_init(15,-45)
ax.set_title("Mascon model for Itokawa")
ax.set_xticks([-1,0,1])
ax.set_yticks([-1,0,1])
ax.set_zticks([-1,0,1]);
../_images/160df7e9b07299f64688dcc39f4704f4d12f9e5555c9a35fc5b2daebdf42c4bb.png

Exploring the dynamics of one single cubesat#

Before delving into a full cascade simulation, it is useful to investigate the dynamics of a single cubesat around the irregular body considered. We will do so using directly heyoka, knowing that cascade smulations also use Taylor integrators for the dynamics provided via heyoka.

In the cascade.dynamics module we provide the dynamics of a spacecraft around a rotating asteroid. Such dynamics implements the following equations of motion:

\[\begin{split} \left\{ \begin{array}{l} \dot{\mathbf r} = \mathbf v \\ \dot {\mathbf v} = -G \sum_{j=0}^N \frac {m_j}{|\mathbf r - \mathbf r_j|^3} (\mathbf r - \mathbf r_j) - 2 \boldsymbol\omega \times \mathbf v - \boldsymbol \omega \times\boldsymbol\omega \times \mathbf r \end{array} \right. \end{split}\]

see the paper Reliable event detection for Taylor methods in astrodynamics for further information. Note that we are using units where \(G=1\) and \(GM=1\).

dyn = csc.dynamics.mascon_asteroid(Gconst = 1., masses=mascon_masses, points=mascon_points, omega = w_vec)

We now instantiate a Taylor integrator using heyoka’s hy.taylor_adaptive class. Note that we have added two terminal events to the equations as to perform a manouvre whenever the spacecarft is too close or too far:

\[ \Delta \mathbf V = - 2(\mathbf {\hat r} \cdot \mathbf v) \mathbf {\hat r} \]

where \(\mathbf {\hat r}\) represents the unit vector of the spacecraft position and \(\mathbf v\) its velocity.

Note how the taylor_adaptive constructor is called with the argument compact_mode set to True as otherwise the equation generation would take a considerable amount of time.

# Re-entry event
safety_radius = 1.1
# Exit event
exit_radius = 3.

x,y,z = hy.make_vars("x", "y", "z")
vx,vy,vz = hy.make_vars("vx", "vy", "vz")

# Applies the DV to the spacecraft state
def dv_invert_radial(ta, mr, d_sgn):
    global DV
    r = deepcopy(ta.state[:3])
    rotv = np.cross(w_vec, r)
    v = deepcopy(ta.state[3:6]) - rotv
    rhat = r / np.linalg.norm(r)
    ta.state[3:6] = v - 2*np.dot(rhat,v) * rhat + rotv
    DV+=np.linalg.norm(2*np.dot(rhat,v) * rhat)
    return True

# Define a terminal event that detects the entry in the safety sphere
reentry_ev = hy.t_event(
        # The event equation.
        x**2+y**2+z**2 - safety_radius**2,
        # The callback.
        callback = dv_invert_radial)

# Define a terminal event that detects the exit from a sphere
exit_ev = hy.t_event(
        # The event equation.
        x**2+y**2+z**2 - exit_radius**2,
        # The callback.
        callback = dv_invert_radial)

# Constructs the Taylor integrator
taylor = hy.taylor_adaptive(sys=dyn, state = [0.,0.,0.,0.,0.,0.], compact_mode=True, t_events=[reentry_ev, exit_ev])

We start simulating the orbit the cubesats will be deployed from. Such an orbit is here designed in the non-rotating frame with an initial position \(\mathbf r_{ic}\) and a circular velocity of magnitude \(\sqrt{\frac{1}{r_ic}}\). The resulting dynamics is very sensitive to these choices, given the irregular form of the body and the close proximity to the asteroid.

# This is a phase we can use to design the starting orbit
theta = 2*np.pi * 0/50
cs = np.cos(theta)
sn = np.sin(theta)
# We consider a polar orbit with velocity aligned to the x axis
r_ic = np.array([0., 1.7*sn, 1.7*cs])
vrot = np.cross(w_vec, r_ic)
v_ic = np.array([np.sqrt(1./np.linalg.norm(r_ic))*cs, 0., -np.sqrt(1./np.linalg.norm(r_ic))*sn]) - vrot
taylor.state[:3] = r_ic
taylor.state[3:6] = v_ic
taylor.time=0.
# The propagation time correspond to roughly 24 hours (i.e. roughly two Itokawa rotations)
tgrid = np.linspace(0., 2*10.19, 1000)
# Global DV in case manouvres are done
DV=0

We perform the numerical propagation.

Tip

The use of the propagate_grid method of heyoka’s Taylor integrator, in this case will ensure we are able to plot the entire trajectory at the end, while sacrificing performances only marginally.

mother = taylor.propagate_grid(tgrid)

The “mother” trajectory#

We now first plot the cubesat trajectory, then compute the control DV that is needed (if any) to keep the cubesat within the desired range from the asteroid.

# This helper function can be used to transform the simulation output into the inertial frame
def rotate(tgrid, states, w_vec):
    from scipy.spatial.transform import Rotation as rot
    import numpy as np
    from copy import deepcopy
    retval = deepcopy(states)
    direction = w_vec
    for t, item in zip(tgrid, retval):
        R = rot.from_rotvec(t * direction).as_matrix()
        item[:3] = R@item[:3]
        item[3:6] = R@item[3:6]
    return retval
mother_rotated=rotate(tgrid, mother[4], w_vec)
# Define the 3d Axis
fig = plt.figure(figsize = (13,7))

#Plot in the rotatting frame
ax = fig.add_subplot(121, projection='3d')

# Plot all mascons 
ax.scatter3D(mascon_points[:,0], mascon_points[:,1], mascon_points[:,2], alpha=0.05, s=2, c='k')

# Plot the safety sphere around it
r = np.sqrt(1.1)
u, v = np.mgrid[0:2*np.pi:40j, 0:np.pi:40j]
X = r*np.cos(u)*np.sin(v)
Y = r*np.sin(u)*np.sin(v)
Z = r*np.cos(v)
ax.plot_wireframe(X,Y,Z, color="r", alpha=0.1)

ax.plot3D(mother[4][:,0],mother[4][:,1],mother[4][:,2])

limit=4
ax.set_xlim(-limit,limit)
ax.set_ylim(-limit,limit)
ax.set_zlim(-limit,limit)
ax.view_init(0,90)
ax.set_title("Body frame")
ax.set_xticks([-2, -1,0,1, 2])
ax.set_yticks([-2, -1,0,1, 2])
ax.set_zticks([])

# Plot in the inertial frame
ax = fig.add_subplot(122, projection='3d')
# Plot the safety sphere around it
r = safety_radius
u, v = np.mgrid[0:2*np.pi:40j, 0:np.pi:40j]
X = r*np.cos(u)*np.sin(v)
Y = r*np.sin(u)*np.sin(v)
Z = r*np.cos(v)
ax.plot_wireframe(X,Y,Z, color="r", alpha=0.1)

ax.plot3D(mother_rotated[:,0],mother_rotated[:,1],mother_rotated[:,2])

limit=4
ax.set_xlim(-limit,limit)
ax.set_ylim(-limit,limit)
ax.set_zlim(-limit,limit)
ax.view_init(0,90)
ax.set_title("Inertial frame")
ax.set_xticks([-2, -1,0,1, 2])
ax.set_yticks([-2, -1,0,1, 2])
ax.set_zticks([]);
../_images/e418def0d271f3e800164a4ac96c91dbbf28c806d253db2ddd75592b370f2f75.png

In case the cubesat manouvres to keep itself in the desired range we evaluate the amount of DV that is provided by the cubesat propulsion system:

print("Total DV: ", DV * L / T, "m/s")
print("Trajectory duration: ", taylor.time*T/60/60/24, "days")
Total DV:  0.0 m/s
Trajectory duration:  1.0110078428455458 days

in this case zero, but in general not necessarily so.

Simulate cubesat swarming#

Having had a look at the dynamics of a single cubesat, we move on to study the behaviour of a swarm of cubesats under the same dynamics. We assume that each cubesat has the capability to perform manouvres whenever it gets too close to another element of the swarm, to the central body or whenever it flies too far away.

We thus instantiate a cascade.sim object to perform a collision simulation where each body has the same collisional radius. Upon the detection of a collision event we will perfrom an avoidance manouvre. Upon detection of a reentry or exit event (in this case corresponding to entering the safe sphere) we perform a manouvre to invert the radial component of the velocity.

At the beginning only the mother craft is in the simulation. At each deployment a new cubesat will be co-orbiting the asteroid.

# Number of cubesats to simulate
n_cubesats = 50
# Collisional radius (5m)
radius = 5. / L
# The initial state is the mother only
ic_state = np.ones((1,7)) * radius
ic_state[0][:6] = mother[4][0]

We are now ready to instantiate a cascade simulation. With respect to previous examples we here need to make use of the argument compact_mode else the just in time compilation of the internal Taylor integrators will take too much time. With compact_mode we are sacrificing some optimizations in the jitted code but we allow the simulation object to be built in a few seconds only even if the mascon model has tens of thousands of mascons adding to the right hand side of the dynamics.

We have also set an exit_radius argument as to be able to trigger events when cubesats are going too far from the center and thus keep the entire swarm always close to the asteroid.

# Number of collisional timesteps to be processed in parallel.
n_par_ct = 100
# Timestep of the simulation
simulation_step = 2 * np.pi / 10.
# Collisional timestep
collisional_step = simulation_step / n_par_ct
# Re-entry event
safety_radius = 1.1
# Exit event
exit_radius = 3.
# The simulation object
sim = csc.sim(ic_state, collisional_step, dyn=dyn, reentry_radius=safety_radius, n_par_ct = 100, compact_mode=True, exit_radius = exit_radius)

We now run a simulation where collisions are avoided by a collision avoidance manouvre simulating a virtual elastic collision (and using cubesat \(DV\)) and to remain within the exit and the safety radius (using cubesat \(DV\)).

# Simulation length in time units
final_t = 4 * pk.DAY2SEC / T
# We will store for each cubesat the cumulated DV
DV = np.zeros(n_cubesats)
# Counters
n_coll, n_sm, n_am = 0, 0, 0
# We store the positions of the events
ev_pos = []
# We also store the position of all cubesats at the end of each step
all_pos = deepcopy(ic_state)
counter= 1 
# Start of the simulation
while sim.time < final_t:
    # One step
    oc = sim.step()
    if counter<n_cubesats:
        # copying current swarm
        new_ic_state = deepcopy(sim.state)
        # adding one element where the mother is
        new_ic_state = np.concatenate((new_ic_state, [new_ic_state[0]]))
        # adding deployment 
        new_ic_state[-1][:3] = new_ic_state[-1][:3] + new_ic_state[-1][:3]/np.linalg.norm(new_ic_state[-1][:3]) * 10/L
        sim.set_new_state_pars(new_ic_state)
        counter+=1

    # Appending the cubesat positions
    all_pos = np.concatenate((all_pos, sim.state))
    # Cleaning the print
    print("\r                                                                              ",end='')
    # Printing time
    print(f"\rTime: {sim.time:3.3f} - cubesats: {counter}", end='')

    # Checking events
    if oc == csc.outcome.collision:
        pi, pj = sim.interrupt_info
        # Collision Avoidance Manouvre
        print(" - CAM:", pi,pj, end='')
        # sat i inertial state
        ri = deepcopy(sim.state[pi][:3])
        vroti =  np.cross(w_vec, ri)
        vi = sim.state[pi][3:6] - vroti
        # sat j inertial state
        rj = deepcopy(sim.state[pj][:3])
        vrotj =  np.cross(w_vec, rj)
        vj = sim.state[pj][3:6] - vrotj
        # Elastic collision: the velocity components along rij are exchanged
        rij = (ri-rj) / np.linalg.norm(ri-rj)
        vi_r = np.dot(vi, rij) * rij
        vj_r = np.dot(vj, rij) * rij
        vi = vi - vi_r + vj_r
        vj = vj - vj_r + vi_r
        # ... in the rotating frame
        sim.state[pi][3:6] = vi + vroti
        sim.state[pj][3:6] = vj + vrotj
        # Updating DVs
        DV_mag = np.linalg.norm(vj_r-vi_r)
        DV[pi]+= DV_mag
        DV[pj]+= DV_mag
        # Updating the number of collisions counter and the event positions
        n_coll+=1
        ev_pos.append(ri)

    if oc == csc.outcome.reentry:
        pi = sim.interrupt_info
        # Safety Manouvre
        print(" - SM:", pi, end='')
        # sat i inertial state
        ri = deepcopy(sim.state[pi][:3])
        vrot = np.cross(w_vec, ri)
        vi = sim.state[pi][3:6] - vrot
        # Inverting the radial inertial velocity component and transforming back to the rotating frame
        hatr = ri / np.linalg.norm(ri)
        sim.state[pi][3:6] = vi - 2*np.dot(hatr,vi) * hatr + vrot
        # Updating DVs
        DV[pi]+=np.linalg.norm(2*np.dot(hatr,vi) * hatr)
        # Updating the number of safety manouvres counter and the event positions
        n_sm+=1
        ev_pos.append(ri)

    if oc == csc.outcome.exit:
        pi = sim.interrupt_info
        # Re-approach Manouvre
        print(" - RAM:", pi, end='')
        # sat i inertial state
        ri = deepcopy(sim.state[pi][:3])
        vrot = np.cross(w_vec, ri)
        vi = sim.state[pi][3:6] - vrot
        # Inverting the radial inertial velocity component and transforming back to the rotating frame
        hatr = ri / np.linalg.norm(ri)
        sim.state[pi][3:6] = vi - 2*np.dot(hatr,vi) * hatr + vrot
        # Updating DVs
        DV[pi]+=np.linalg.norm(2*np.dot(hatr,vi) * hatr)
        # Updating the number of safety manouvres counter and the event positions
        n_am+=1
        ev_pos.append(ri)

ev_pos = np.array(ev_pos)
print("\nNumber of collisions: ", n_coll)
print("Number of safety manouvres: ", n_sm)
print("Number of re-approach manouvres: ", n_am)
Time: 81.010 - cubesats: 50                                                   
Number of collisions:  115
Number of safety manouvres:  6
Number of re-approach manouvres:  4

Finally we plot the cubesats positions during the simulation and the positions of all manouvres performed.

ev_pos = np.array(ev_pos)

def plot_swarm(az, el, ax, title):
    # Plot all mascons 
    ax.scatter3D(mascon_points[:,0], mascon_points[:,1], mascon_points[:,2], alpha=0.03, s=1, c='k')

    # Plot swarm
    ax.scatter3D(all_pos[:,0], all_pos[:,1], all_pos[:,2], alpha=0.03, s=1, c='r')

    # Plot events
    ax.scatter3D(ev_pos[:,0], ev_pos[:,1], ev_pos[:,2], alpha=1., s=3., c='k')

    # Plot initial state
    ax.scatter3D(ic_state[:,0], ic_state[:,1], ic_state[:,2], alpha=1., s=3., c='g')

    # Plot a safety sphere around it
    r = 1.1
    u, v = np.mgrid[0:2*np.pi:40j, 0:np.pi:40j]
    X = r*np.cos(u)*np.sin(v)
    Y = r*np.sin(u)*np.sin(v)
    Z = r*np.cos(v)
    ax.plot_wireframe(X, Y, Z, color="r", alpha=0.1)

    # Set the axis limits 
    limit=3
    ax.set_xlim(-limit,limit)
    ax.set_ylim(-limit,limit)
    ax.set_zlim(-limit,limit)
    ax.view_init(15,-45)
    ax.set_title(title)
    ax.set_xticks([-1,0,1])
    ax.set_yticks([-1,0,1])
    ax.set_zticks([-1,0,1])
    ax.view_init(az, el)
    return ax

# Define the 3d Axis
fig = plt.figure(figsize = (10,20))
ax = fig.add_subplot(121, projection='3d')
plot_swarm(90,90,ax, "top view")
ax = fig.add_subplot(122, projection='3d')
plot_swarm(0,90,ax, "side view")
plt.tight_layout()
../_images/56db6e86d7e476223bc4fa65dc47939dd5dfb8e295938c4eb4187c553a04859e.png

Note

The collision avoidance manouvres appear clusered in specific areas defined by the complex dynamics. If present, all manouvres to keep the swarm within the defined range happen, as expected, at the surface of the spheres defining the allowed zone.

We also plot the \(DV\) distribution.

fig = plt.figure()
plt.bar(range(n_cubesats), DV)
plt.xlabel("Cubesat N.")
plt.ylabel("DV consumption [m/s]")
Text(0, 0.5, 'DV consumption [m/s]')
../_images/66a84807a65a8720819cdde5a2017f1c660186ee83f08c6f9f4e1c076724025f.png

That is all folks! Pretty cool stuff if you ask me.