Source code for ADCS.helpers.plot.orbit.orbitpositionplot

__all__ = ["OrbitPositionPlot"]

import numpy as np
import matplotlib.gridspec as gridspec

from ..subplot import Subplot


def _normalize_orbit_sources(sources: list[str] | None) -> list[str]:
    if not sources:  # None or []a
        return ["real"]
    allowed = {"real", "estimated"}
    out: list[str] = []
    for s in sources:
        s2 = str(s).strip().lower()
        if s2 not in allowed:
            raise ValueError(f"Invalid sources {s2!r}. Allowed: {sorted(allowed)}")
        if s2 not in out:
            out.append(s2)
    return out


def _get_R_series(sim, source: str) -> np.ndarray | None:
    if source == "real":
        hist = getattr(sim, "os_hist", None)
        get_R = lambda os: os.R
    elif source == "estimated":
        hist = getattr(sim, "est_os_hist", None)
        get_R = lambda os: os.os.R  # unwrap EstimatedOrbital_State
    else:
        raise ValueError(f"Unknown source: {source}")

    if hist is None or len(hist) == 0:
        return None

    rows = [
        np.asarray(get_R(os), dtype=float).reshape(3)
        for os in hist
        if os is not None
    ]

    if not rows:
        return None

    return np.vstack(rows)


def _source_style_orbit(source: str) -> dict:
    return {"linestyle": "-" if source == "real" else "--"}


def _source_suffix_orbit(source: str) -> str:
    return " (real)" if source == "real" else " (est)"


[docs] class OrbitPositionPlot(Subplot): r""" Multi-panel visualization of spacecraft position components in ECI. This class plots the Cartesian position components and the position magnitude of the spacecraft as functions of time, using orbital state histories from the simulation. Real and estimated positions can be shown together for comparison, arranged automatically in a fixed grid layout. The plot emphasizes user-controlled settings such as data sources, colors, units, and axis scaling. :param sources: List of orbit position sources to display. Supported values are real and estimated. If None, only the real position is shown. :type sources: list[str] or None :param time: Name of the simulation attribute containing the time vector in seconds. :type time: str :param title: Title displayed at the top of the plot group. :type title: str :param units: Physical units of the position components. :type units: str :param colors: Colors used for the x, y, and z position components. :type colors: tuple[str, str, str] :param mag_color: Color used for the position magnitude plot. :type mag_color: str :param log_y: If True, the y-axes use logarithmic scaling. :type log_y: bool """ def __init__( self, *, sources: list[str] | None = None, # ["real","estimated"] time: str = "time_s", title: str = "Orbit Position (ECI)", units: str = "km", colors=("tab:blue", "tab:orange", "tab:green"), mag_color: str = "tab:red", log_y: bool = False, ): self.sources = _normalize_orbit_sources(sources) self.time = time self.title = title self.units = units self.colors = colors self.mag_color = mag_color self.log_y = log_y
[docs] def plot(self, ax, sim) -> None: ax.set_frame_on(False) ax.tick_params(left=False, labelleft=False, bottom=False, labelbottom=False) gs = gridspec.GridSpecFromSubplotSpec(2, 2, subplot_spec=ax.get_subplotspec()) ax_rx = ax.figure.add_subplot(gs[0, 0]) ax_rmag = ax.figure.add_subplot(gs[0, 1]) ax_ry = ax.figure.add_subplot(gs[1, 0]) ax_rz = ax.figure.add_subplot(gs[1, 1]) labels = [r"$r_x$", r"$r_y$", r"$r_z$"] axes = [ax_rx, ax_ry, ax_rz] runs = getattr(sim, "runs", None) if runs is None: runs = [sim] alpha = max(0.15, 1.0 / len(runs)) plotted_any = False for run in runs: t0 = getattr(run, self.time, None) for source in self.sources: R = _get_R_series(run, source) if R is None: continue N = R.shape[0] t = np.asarray(t0)[:N] if t0 is not None else np.arange(N) style = _source_style_orbit(source) suf = _source_suffix_orbit(source) rmag = np.linalg.norm(R, axis=1) for i, ax_i in enumerate(axes): ax_i.plot( t, R[:, i], color=self.colors[i], alpha=alpha, label=None, **style, ) ax_i.set_ylabel(f"{labels[i]} [{self.units}]") if self.log_y: ax_i.set_yscale("log") ax_i.grid(True, which="both") ax_rmag.plot( t, rmag, color=self.mag_color, alpha=alpha, label=None, **style, ) ax_rmag.set_ylabel(f"$\\|r\\|$ [{self.units}]") if self.log_y: ax_rmag.set_yscale("log") ax_rmag.grid(True, which="both") plotted_any = True if not plotted_any: ax_text = ax.figure.add_subplot(ax.get_subplotspec()) ax_text.axis("off") ax_text.set_title(self.title, loc="left", pad=10) ax_text.text(0.5, 0.5, "No orbit history available", ha="center", va="center") return # Clean legends: one entry per source per axis (not per run) for i, ax_i in enumerate(axes): handles = [] labs = [] for source in self.sources: handles.append( ax_i.plot( [], [], color=self.colors[i], **_source_style_orbit(source), )[0] ) labs.append(labels[i] + _source_suffix_orbit(source)) ax_i.legend(handles, labs) handles = [] labs = [] for source in self.sources: handles.append( ax_rmag.plot( [], [], color=self.mag_color, **_source_style_orbit(source), )[0] ) labs.append(r"$\|r\|$" + _source_suffix_orbit(source)) ax_rmag.legend(handles, labs) ax_ry.set_xlabel("Time [s]" if getattr(runs[0], self.time, None) is not None else "Sample") ax_rz.set_xlabel("Time [s]" if getattr(runs[0], self.time, None) is not None else "Sample") ax_rx.set_title(self.title, loc="left", pad=10)
class OrbitPositionPlotSingle(Subplot): r""" Single-component visualization of spacecraft position in ECI. This class plots one selected position component or the position magnitude as a function of time. It supports displaying real and estimated data together, making it suitable for focused analysis of a single spatial dimension. The user controls which component is shown, along with labeling, color, and axis scaling options. :param component: Position component to plot. Must be one of x, y, z, or m for magnitude. :type component: str :param sources: List of orbit position sources to display. Supported values are real and estimated. :type sources: list[str] or None :param time: Name of the simulation attribute containing the time vector in seconds. :type time: str :param title: Title of the plot. If None, a default title is used. :type title: str or None :param units: Physical units of the position values. :type units: str :param color: Color used for the plotted position signal. :type color: str or None :param log_y: If True, the y-axis uses logarithmic scaling. :type log_y: bool :param labels: Optional mapping from component identifiers to display labels. :type labels: dict[str, str] or None """ def __init__( self, *, component: str, sources: list[str] | None = None, # ["real","estimated"] time: str = "time_s", title: str | None = None, units: str = "km", color: str | None = None, log_y: bool = False, labels: dict[str, str] | None = None, ): if component not in {"x", "y", "z", "m"}: raise ValueError("component must be one of 'x', 'y', 'z', or 'm'") self.component = component self.sources = _normalize_orbit_sources(sources) self.time = time self.units = units self.color = color self.log_y = log_y self.labels = labels or { "x": r"$r_x$", "y": r"$r_y$", "z": r"$r_z$", "m": r"$\|r\|$", } self.title = title def plot(self, ax, sim) -> None: runs = getattr(sim, "runs", None) if runs is None: runs = [sim] alpha = max(0.15, 1.0 / len(runs)) plotted_any = False default_title = self.title or "Orbit Position (ECI)" t_label = "Time [s]" for run in runs: t0 = getattr(run, self.time, None) for source in self.sources: R = _get_R_series(run, source) if R is None: continue N = R.shape[0] if t0 is not None: t = np.asarray(t0)[:N] t_label = "Time [s]" else: t = np.arange(N) t_label = "Sample" style = _source_style_orbit(source) suf = _source_suffix_orbit(source) if self.component == "x": y = R[:, 0] label = self.labels["x"] + suf base_color = self.color or "tab:blue" title = self.title or "Orbit Position $r_x$ (ECI)" elif self.component == "y": y = R[:, 1] label = self.labels["y"] + suf base_color = self.color or "tab:orange" title = self.title or "Orbit Position $r_y$ (ECI)" elif self.component == "z": y = R[:, 2] label = self.labels["z"] + suf base_color = self.color or "tab:green" title = self.title or "Orbit Position $r_z$ (ECI)" else: y = np.linalg.norm(R, axis=1) label = self.labels["m"] + suf base_color = self.color or "tab:red" title = self.title or "Orbit Position $\\|r\\|$ (ECI)" ax.plot(t, y, color=base_color, alpha=alpha, label=None, **style) plotted_any = True if not plotted_any: ax.axis("off") ax.set_title(default_title, loc="left", pad=10) ax.text(0.5, 0.5, "No orbit history available", ha="center", va="center") return # Clean legend: one entry per source handles, labs = [], [] for source in self.sources: suf = _source_suffix_orbit(source) style = _source_style_orbit(source) if self.component == "x": base_color = self.color or "tab:blue" lab = self.labels["x"] + suf elif self.component == "y": base_color = self.color or "tab:orange" lab = self.labels["y"] + suf elif self.component == "z": base_color = self.color or "tab:green" lab = self.labels["z"] + suf else: base_color = self.color or "tab:red" lab = self.labels["m"] + suf handles.append(ax.plot([], [], color=base_color, **style)[0]) labs.append(lab) ax.set_xlabel(t_label) ax.set_ylabel(f"{self.labels[self.component]} [{self.units}]") ax.set_title(self.title or default_title, loc="left", pad=10) if self.log_y: ax.set_yscale("log") ax.legend(handles, labs) ax.grid(True, which="both") class OrbitPositionPlotCombined(Subplot): r""" Combined plot of all spacecraft position components in ECI. This class overlays the x, y, and z position components on a single set of axes, allowing direct comparison of component magnitudes and trends over time. Real and estimated sources can be displayed simultaneously. The plot focuses on user-defined appearance options such as colors, labels, and axis scaling. :param sources: List of orbit position sources to display. Supported values are real and estimated. :type sources: list[str] or None :param time: Name of the simulation attribute containing the time vector in seconds. :type time: str :param title: Title displayed at the top of the plot. :type title: str :param units: Physical units of the position values. :type units: str :param colors: Colors used for the x, y, and z position components. :type colors: tuple[str, str, str] :param log_y: If True, the y-axis uses logarithmic scaling. :type log_y: bool :param labels: Labels used for the position components. :type labels: list[str] or None """ def __init__( self, *, sources: list[str] | None = None, # ["real","estimated"] time: str = "time_s", title: str = "Orbit Position (ECI)", units: str = "km", colors=("tab:blue", "tab:orange", "tab:green"), log_y: bool = False, labels: list[str] | None = None, ): self.sources = _normalize_orbit_sources(sources) self.time = time self.title = title self.units = units self.colors = colors self.log_y = log_y self.labels = labels or [r"$r_x$", r"$r_y$", r"$r_z$"] def plot(self, ax, sim) -> None: runs = getattr(sim, "runs", None) if runs is None: runs = [sim] alpha = max(0.15, 1.0 / len(runs)) plotted_any = False t_label = "Time [s]" for run in runs: t0 = getattr(run, self.time, None) for source in self.sources: R = _get_R_series(run, source) if R is None: continue N = R.shape[0] if t0 is not None: t = np.asarray(t0)[:N] t_label = "Time [s]" else: t = np.arange(N) t_label = "Sample" style = _source_style_orbit(source) suf = _source_suffix_orbit(source) for i in range(3): ax.plot( t, R[:, i], color=self.colors[i], alpha=alpha, label=None, **style, ) plotted_any = True if not plotted_any: ax.axis("off") ax.set_title(self.title, loc="left", pad=10) ax.text(0.5, 0.5, "No orbit history available", ha="center", va="center") return # Clean legend: one entry per component per source handles, labs = [], [] for source in self.sources: suf = _source_suffix_orbit(source) style = _source_style_orbit(source) for i in range(3): handles.append(ax.plot([], [], color=self.colors[i], **style)[0]) labs.append(self.labels[i] + suf) ax.set_xlabel(t_label) ax.set_ylabel(f"Position [{self.units}]") ax.set_title(self.title, loc="left", pad=10) if self.log_y: ax.set_yscale("log") ax.legend(handles, labs) ax.grid(True, which="both")