[docs]
def groundtrack_dashboard_gamma_heading(r, t, save_path=None, pad=500, show=False, show_legend=True, t0=None, limit=None, fontsize=18):
"""
Visualizes multiple satellite ground tracks, altitude/velocity over time,
gamma over time, heading over time, and 3D trajectories (ITRF and GCRF).
Layout (2 rows x 4 cols):
Row 0: [ Ground map (1x2 span) | ITRF 3D | GCRF 3D ]
Row 1: [ Gamma vs Time | Heading vs Time | Altitude vs Time | Velocity vs Time ]
"""
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 ..Compute import find_smallest_bounding_cube
from ..constants import EARTH_RADIUS
from ..Time_Functions import to_gps
from ..Orbital_Mechanics.gamma_and_heading import calc_gamma_and_heading
from .plotutils import save_plot, valid_orbits
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)
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
r, t = valid_orbits(r, t)
# Times -> GPS seconds and relative timeline
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]
# Per-orbit derived series
lons, lats, altitudes, velocities = [], [], [], []
x_gts, y_gts, z_gts = [], [], []
gammas, headings = [], []
for r_i, t_i in zip(r, t):
xyz = np.array(r_i) # (n,3)
# Ground track + geodetic
x_gt, y_gt, z_gt = groundTrack(xyz, t_i, format="cartesian")
lon, lat, height = groundTrack(xyz, t_i, format="geodetic")
# Simple speed magnitude from finite differences (display only)
try:
vel = np.linalg.norm(np.gradient(xyz, axis=0), axis=1)
except Exception:
vel = 0
# Gamma/Heading
try:
g_deg, h_deg = calc_gamma_and_heading(xyz, t_i)
except Exception:
n = xyz.shape[0]
g_deg = np.full(n, np.nan)
h_deg = np.full(n, np.nan)
lons.append(np.degrees(lon))
lats.append(np.degrees(lat))
altitudes.append(height)
velocities.append(vel)
x_gts.append(x_gt); y_gts.append(y_gt); z_gts.append(z_gt)
gammas.append(g_deg); headings.append(h_deg)
# 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)
# Figure and grid: 2 rows x 4 columns
fig = plt.figure(figsize=(28, 16))
gs = gridspec.GridSpec(2, 4, figure=fig)
# TOP ROW: Ground map (spans 0:2), ITRF 3D (col 2), GCRF 3D (col 3)
ax_ground = fig.add_subplot(gs[0, 0:2])
ax_itrf = fig.add_subplot(gs[0, 2], projection="3d")
ax_gcrf = fig.add_subplot(gs[0, 3], projection="3d")
# Ground map setup and plotting
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)
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, len(r)))
for i, (lon, lat) in enumerate(zip(lons, lats)):
lon_c, lat_c = clean_lonlat(lon, lat)
ax_ground.plot(lon_c, lat_c, color=colors[i], linewidth=2.5)
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)
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"),
]
ax_ground.legend(handles=legend_elements, loc="lower left", fontsize=fontsize)
# ITRF 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)
# GCRF 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)
# 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, float(max_bound))
for ax in (ax_itrf, ax_gcrf):
ax.set_xlim([-limit, limit])
ax.set_ylim([-limit, limit])
ax.set_zlim([-limit, limit])
ax.set_xticks([-limit, 0, limit])
ax.xaxis.set_major_locator(FixedLocator([-limit, 0, limit]))
ax.yaxis.set_major_locator(FixedLocator([-limit, 0, limit]))
ax.zaxis.set_major_locator(FixedLocator([-limit, 0, limit]))
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
# BOTTOM ROW: all time series (Gamma, Heading, Altitude, Velocity)
ax_gamma = fig.add_subplot(gs[1, 0])
ax_heading = fig.add_subplot(gs[1, 1])
ax_alt = fig.add_subplot(gs[1, 2])
ax_velocity = fig.add_subplot(gs[1, 3])
# Gamma vs Time
for i, (ti, g) in enumerate(zip(t_rel, gammas)):
n = min(len(ti), len(g))
if n == 0:
continue
ax_gamma.plot(ti[:n] / 60.0, g[:n], color=colors[i], linewidth=2.2, label=f"orbit {i+1}")
ax_gamma.set_xlabel("Time (minutes)", fontsize=fontsize)
ax_gamma.set_ylabel("Gamma (deg)", fontsize=fontsize)
ax_gamma.set_ylim(-90, 90)
ax_gamma.grid(True)
force_title(ax_gamma, "Gamma vs Time", fontsize)
ax_gamma.tick_params(axis="both", labelsize=fontsize)
if show_legend and len(gammas) > 1:
ax_gamma.legend(fontsize=fontsize - 4, loc="best")
# Heading vs Time
for i, (ti, h) in enumerate(zip(t_rel, headings)):
n = min(len(ti), len(h))
if n == 0:
continue
ax_heading.plot(ti[:n] / 60.0, h[:n], color=colors[i], linewidth=2.2, label=f"orbit {i+1}")
ax_heading.set_xlabel("Time (minutes)", fontsize=fontsize)
ax_heading.set_ylabel("Heading (deg)", fontsize=fontsize)
ax_heading.set_ylim(0, 360)
ax_heading.grid(True)
force_title(ax_heading, "Heading vs Time", fontsize)
ax_heading.tick_params(axis="both", labelsize=fontsize)
if show_legend and len(headings) > 1:
ax_heading.legend(fontsize=fontsize - 4, loc="best")
# Altitude vs Time
altmax = 0.0
for i, (ti, alt) in enumerate(zip(t_rel, altitudes)):
ax_alt.plot(ti / 60.0, alt / 1e3, color=colors[i], linewidth=2.5, label=f"orbit {i+1}")
if np.size(alt) > 0:
altmax = max(altmax, float(np.nanmax(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 and len(altitudes) > 1:
ax_alt.legend(fontsize=fontsize - 4, loc="best")
# Velocity vs Time
vmax = 0.0
for i, (ti, vel) in enumerate(zip(t_rel, velocities)):
if np.ndim(vel) > 0 and len(vel) > 3:
ti_plot = ti[1:-1]
vel_plot = vel[1:-1]
else:
ti_plot = ti
vel_plot = vel
ax_velocity.plot(ti_plot / 60.0, np.asarray(vel_plot) / 1e3, color=colors[i], linewidth=2.5)
if np.size(vel_plot) > 0:
vmax = max(vmax, float(np.nanmax(vel_plot)))
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 save_path:
save_plot(fig, save_path)
if show:
plt.show()
return fig