Source code for ssapy_toolkit.Plots.groundtrack_dashboard

import numpy as np
import matplotlib.pyplot as plt
from matplotlib import gridspec
from matplotlib.lines import Line2D
from matplotlib.ticker import FixedLocator, FixedFormatter

from ssapy import groundTrack
# from ..Yastropy import astropy_gcrf_to_llh
from ..Compute import find_smallest_bounding_cube
from ..constants import EARTH_RADIUS
from ..Time_Functions import to_gps
from .plotutils import save_plot, valid_orbits


def clean_lonlat(lon, lat):
    wraps = np.abs(np.diff(lon)) > 180
    lon_nan = np.insert(lon, np.where(wraps)[0] + 1, np.nan)
    lat_nan = np.insert(lat, np.where(wraps)[0] + 1, np.nan)
    return lon_nan, lat_nan


[docs] def groundtrack_dashboard( r, t, save_path=None, pad=500, show=False, show_legend=True, t0=None, limit=None, fontsize=18, labels=None, ): """ Visualizes multiple satellite ground tracks, altitude/velocity over time, and 3D trajectories. Parameters ---------- r : array_like or list of array_like Position vectors in meters. Either a single (n,3) array or a list of (n,3) arrays for multiple orbits. t : array_like or list of array_like Time arrays in seconds or any format convertible by `to_gps`. Single array or list matching `r`. save_path : str or None, optional If provided, saves the figure to the specified path. pad : float, optional Padding (in meters) for the 3D plot's bounding cube. Defaults to 500 m. show : bool, optional If True, displays the figure. show_legend : bool, optional If True, shows legends. t0 : float or None, optional Reference start time in GPS seconds for relative-time plots. If None, uses the min of first timestamps. limit : float or None, optional Fixed half-extent (in km) for 3D axes. If None, computed from data with padding. fontsize : int, optional Base fontsize. Other text scales relative to this. labels : list of str or None, optional Optional per-orbit labels. If None, defaults to ["Orbit 1", "Orbit 2", ...]. Returns ------- fig : matplotlib.figure.Figure The complete dashboard figure object. Author: Travis Yeager """ def force_title(ax, text, size, y=1.02): ax.set_title("") if hasattr(ax, "text2D"): ax.text2D(0.5, y, text, transform=ax.transAxes, ha="center", va="bottom", fontsize=size) else: ax.text(0.5, y, text, transform=ax.transAxes, ha="center", va="bottom", fontsize=size) r, t = valid_orbits(r, t) n_orbits = len(r) # Normalize labels if labels is None: labels_norm = [f"Orbit {i+1}" for i in range(n_orbits)] else: labels_arr = list(labels) if len(labels_arr) < n_orbits: labels_arr = labels_arr + [f"Orbit {i+1}" for i in range(len(labels_arr), n_orbits)] labels_norm = labels_arr[:n_orbits] # Ensure times are converted to GPS and normalized t_gps = [to_gps(ti) for ti in t] if t0 is None: try: t0 = min(float(ti[0]) for ti in t_gps if len(ti) > 0) except Exception: t0 = 0.0 t_rel = [ti - t0 for ti in t_gps] # Process each orbit lons, lats, altitudes, velocities, x_gts, y_gts, z_gts = [], [], [], [], [], [], [] for r_i, t_i in zip(r, t): xyz = np.array(r_i) x_gt, y_gt, z_gt = groundTrack(xyz, t_i, format='cartesian') lon, lat, height = groundTrack(xyz, t_i, format='geodetic') # lon, lat, height = astropy_gcrf_to_llh(xyz, t_i) try: velocity = np.linalg.norm(np.gradient(xyz, axis=0), axis=1) except Exception: velocity = 0 lons.append(np.degrees(lon)) lats.append(np.degrees(lat)) altitudes.append(height) velocities.append(velocity) x_gts.append(x_gt) y_gts.append(y_gt) z_gts.append(z_gt) # Earth surface for 3D plots phi_earth = np.linspace(0, np.pi, 50) theta_earth = np.linspace(0, 2 * np.pi, 50) phi_earth, theta_earth = np.meshgrid(phi_earth, theta_earth) earth_x = EARTH_RADIUS * np.sin(phi_earth) * np.cos(theta_earth) earth_y = EARTH_RADIUS * np.sin(phi_earth) * np.sin(theta_earth) earth_z = EARTH_RADIUS * np.cos(phi_earth) # Create figure and grid fig = plt.figure(figsize=(24, 16)) gs = gridspec.GridSpec(2, 3, figure=fig) # Ground track plot (lon/lat axes) ax_ground = fig.add_subplot(gs[0, 0:2]) ax_ground.set_xlim(-180, 180) ax_ground.set_ylim(-90, 90) ax_ground.set_xlabel('Longitude (deg)', fontsize=fontsize) ax_ground.set_ylabel('Latitude (deg)', fontsize=fontsize) ax_ground.set_title('', fontsize=fontsize + 6) ax_ground.grid(True, alpha=0.3) ax_ground.tick_params(axis='both', labelsize=18) # Optional background image try: from .plotutils import load_earth_file ax_ground.imshow(load_earth_file(), extent=[-180, 180, -90, 90], aspect='auto', zorder=-1) except Exception: pass colors = plt.cm.tab10(np.linspace(0, 1, n_orbits)) ground_handles = [] for i, (lon, lat) in enumerate(zip(lons, lats)): lon_c, lat_c = clean_lonlat(lon, lat) h_line = ax_ground.plot(lon_c, lat_c, color=colors[i], linewidth=2.5, label=labels_norm[i])[0] ax_ground.plot(lon[0], lat[0], '*', color=colors[i], markersize=20) ax_ground.plot(lon[-1], lat[-1], 'x', color=colors[i], markersize=14) ground_handles.append(h_line) # Legends on ground track if show_legend: legend_elements = [ Line2D([0], [0], color='black', linewidth=2.5, label='Orbit Track'), Line2D([0], [0], marker='*', color='black', linestyle='None', markersize=12, label='Orbit Start'), Line2D([0], [0], marker='x', color='black', linestyle='None', markersize=10, label='Orbit End') ] leg1 = ax_ground.legend(handles=legend_elements, loc='lower left', fontsize=fontsize) ax_ground.add_artist(leg1) ax_ground.legend(handles=ground_handles, loc='lower right', fontsize=fontsize-2, title="Orbits") # Altitude plot ax_alt = fig.add_subplot(gs[1, 0]) altmax = 0 for i, (ti, alt) in enumerate(zip(t_rel, altitudes)): ax_alt.plot(ti / 60, alt / 1e3, color=colors[i], linewidth=2.5, label=labels_norm[i]) altmax = max(altmax, np.max(alt)) ax_alt.set_ylim(0, altmax / 1e3 * 1.1 if altmax > 0 else 1) ax_alt.set_xlabel('Time (minutes)', fontsize=fontsize) ax_alt.set_ylabel('Altitude (km)', fontsize=fontsize) force_title(ax_alt, "Altitude vs Time", fontsize) ax_alt.tick_params(axis='both', labelsize=fontsize) ax_alt.grid(True) if show_legend: ax_alt.legend(fontsize=fontsize - 4, title="Orbits") # Velocity plot ax_velocity = fig.add_subplot(gs[1, 1]) vmax = 0 for i, (ti, vel) in enumerate(zip(t_rel, velocities)): if np.ndim(vel) > 0 and len(vel) > 3: ti = ti[1:-1] vel = vel[1:-1] ax_velocity.plot(ti / 60, vel / 1e3, color=colors[i], linewidth=2.5, label=labels_norm[i]) vmax = max(vmax, np.max(vel) if np.size(vel) > 0 else 0) ax_velocity.set_ylim(0, vmax / 1e3 * 1.1 if vmax > 0 else 1) ax_velocity.set_xlabel('Time (minutes)', fontsize=fontsize) ax_velocity.set_ylabel('Velocity (km/s)', fontsize=fontsize) force_title(ax_velocity, "Velocity vs Time", fontsize) ax_velocity.tick_params(axis='both', labelsize=fontsize) ax_velocity.grid(True) if show_legend: ax_velocity.legend(fontsize=fontsize - 4, title="Orbits") # ITRF 3D plot ax_itrf = fig.add_subplot(gs[0, 2], projection='3d') ax_itrf.plot_surface(earth_x / 1e3, earth_y / 1e3, earth_z / 1e3, color='blue', alpha=0.5, linewidth=0) for i, (x_gt, y_gt, z_gt) in enumerate(zip(x_gts, y_gts, z_gts)): ax_itrf.plot(x_gt / 1e3, y_gt / 1e3, z_gt / 1e3, color=colors[i], linewidth=2.5) ax_itrf.scatter(x_gt[0] / 1e3, y_gt[0] / 1e3, z_gt[0] / 1e3, color=colors[i], marker='*', s=120) ax_itrf.scatter(x_gt[-1] / 1e3, y_gt[-1] / 1e3, z_gt[-1] / 1e3, color=colors[i], marker='x', s=100) force_title(ax_itrf, "ITRF", fontsize) ax_itrf.set_xlabel('X (km)', fontsize=fontsize) ax_itrf.set_ylabel('Y (km)', fontsize=fontsize) ax_itrf.set_zlabel('Z (km)', fontsize=fontsize) ax_itrf.tick_params(axis='both', labelsize=fontsize - 2) # Set 3D plot limits if limit is None: all_xyz = np.concatenate([r_i for r_i in r], axis=0) lower_bound, upper_bound = find_smallest_bounding_cube(all_xyz, pad=pad) max_bound = np.max(np.abs([lower_bound, upper_bound])) / 1e3 limit = max(10.0, max_bound) ax_itrf.set_xlim([-limit, limit]) ax_itrf.set_ylim([-limit, limit]) ax_itrf.set_zlim([-limit, limit]) ax_itrf.set_xticks([-limit, 0, limit]) # GCRF 3D plot ax_gcrf = fig.add_subplot(gs[1, 2], projection='3d') ax_gcrf.plot_surface(earth_x / 1e3, earth_y / 1e3, earth_z / 1e3, color='blue', alpha=0.5, linewidth=0) for i, r_i in enumerate(r): x, y, z = r_i[:, 0], r_i[:, 1], r_i[:, 2] ax_gcrf.plot(x / 1e3, y / 1e3, z / 1e3, color=colors[i], linewidth=2.5) ax_gcrf.scatter(x[0] / 1e3, y[0] / 1e3, z[0] / 1e3, color=colors[i], marker='*', s=120) ax_gcrf.scatter(x[-1] / 1e3, y[-1] / 1e3, z[-1] / 1e3, color=colors[i], marker='x', s=100) force_title(ax_gcrf, "GCRF", fontsize) ax_gcrf.set_xlabel('X (km)', fontsize=fontsize) ax_gcrf.set_ylabel('Y (km)', fontsize=fontsize) ax_gcrf.set_zlabel('Z (km)', fontsize=fontsize) ax_gcrf.tick_params(axis='both', labelsize=fontsize - 2) ax_gcrf.set_xlim([-limit, limit]) ax_gcrf.set_ylim([-limit, limit]) ax_gcrf.set_zlim([-limit, limit]) ax_gcrf.set_xticks([-limit, 0, limit]) ticks = [-limit, 0, limit] for ax in (ax_itrf, ax_gcrf): ax.xaxis.set_major_locator(FixedLocator(ticks)) ax.yaxis.set_major_locator(FixedLocator(ticks)) ax.zaxis.set_major_locator(FixedLocator(ticks)) ax.xaxis.set_major_formatter(FixedFormatter(["", "0", f"{limit:.0f}"])) ax.yaxis.set_major_formatter(FixedFormatter(["", "0", ""])) ax.zaxis.set_major_formatter(FixedFormatter(["", "0", f"{limit:.0f}"])) ax.tick_params(pad=2) try: ax.set_box_aspect((1, 1, 1)) except Exception: pass try: ax.set_proj_type('ortho') except Exception: pass if save_path: save_plot(fig, save_path) if show: plt.show() return fig