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

__all__ = ["OrbitVelocityPlot", "OrbitVelocityPlotSingle", "OrbitVelocityPlotCombined"]

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 []
        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_V_series(sim, source: str) -> np.ndarray | None:
    """
    Return Nx3 ECI velocity history for a given source.

    - real      -> sim.os_hist[*].V
    - estimated -> sim.est_os_hist[*].os.V   (unwrap EstimatedOrbital_State)
    """
    if source == "real":
        hist = getattr(sim, "os_hist", None)
        get_V = lambda os: os.V
    elif source == "estimated":
        hist = getattr(sim, "est_os_hist", None)
        get_V = lambda os: os.os.V
    else:
        raise ValueError(f"Unknown source: {source}")

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

    rows = [
        np.asarray(get_V(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 OrbitVelocityPlot(Subplot): r""" Multi-panel visualization of spacecraft velocity components in ECI. This class displays the Cartesian velocity components and the velocity magnitude as functions of time, arranged in a fixed grid layout. Real and estimated velocity histories can be shown together for comparison. User configuration focuses on selecting data sources, visual styling, units, and axis scaling. :param sources: List of orbit velocity sources to display. Supported values are real and estimated. If None, only the real velocity 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 velocity components. :type units: str :param colors: Colors used for the x, y, and z velocity components. :type colors: tuple[str, str, str] :param mag_color: Color used for the velocity 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 Velocity (ECI)", units: str = "km/s", 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_vx = ax.figure.add_subplot(gs[0, 0]) ax_vmag = ax.figure.add_subplot(gs[0, 1]) ax_vy = ax.figure.add_subplot(gs[1, 0]) ax_vz = ax.figure.add_subplot(gs[1, 1]) labels = [r"$v_x$", r"$v_y$", r"$v_z$"] axes = [ax_vx, ax_vy, ax_vz] 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: V = _get_V_series(run, source) if V is None: continue N = V.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) vmag = np.linalg.norm(V, axis=1) for i, ax_i in enumerate(axes): ax_i.plot( t, V[:, 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_vmag.plot( t, vmag, color=self.mag_color, alpha=alpha, label=None, **style, ) ax_vmag.set_ylabel(f"$\\|v\\|$ [{self.units}]") if self.log_y: ax_vmag.set_yscale("log") ax_vmag.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 velocity 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_vmag.plot( [], [], color=self.mag_color, **_source_style_orbit(source), )[0] ) labs.append(r"$\|v\|$" + _source_suffix_orbit(source)) ax_vmag.legend(handles, labs) ax_vy.set_xlabel("Time [s]" if getattr(runs[0], self.time, None) is not None else "Sample") ax_vz.set_xlabel("Time [s]" if getattr(runs[0], self.time, None) is not None else "Sample") ax_vx.set_title(self.title, loc="left", pad=10)
[docs] class OrbitVelocityPlotSingle(Subplot): r""" Single-component visualization of spacecraft velocity in ECI. This class plots one selected velocity component or the velocity magnitude as a function of time. It supports overlaying real and estimated data and is suited for focused inspection of a single velocity dimension. The user controls the component selection, labeling, color, and axis scaling. :param component: Velocity component to plot. Must be one of x, y, z, or m for magnitude. :type component: str :param sources: List of orbit velocity 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 velocity values. :type units: str :param color: Color used for the plotted velocity 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/s", 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"$v_x$", "y": r"$v_y$", "z": r"$v_z$", "m": r"$\|v\|$", } self.title = title
[docs] 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 Velocity (ECI)" t_label = "Time [s]" for run in runs: t0 = getattr(run, self.time, None) for source in self.sources: V = _get_V_series(run, source) if V is None: continue N = V.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 = V[:, 0] label = self.labels["x"] + suf base_color = self.color or "tab:blue" elif self.component == "y": y = V[:, 1] label = self.labels["y"] + suf base_color = self.color or "tab:orange" elif self.component == "z": y = V[:, 2] label = self.labels["z"] + suf base_color = self.color or "tab:green" else: y = np.linalg.norm(V, axis=1) label = self.labels["m"] + suf base_color = self.color or "tab:red" 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 velocity 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")
[docs] class OrbitVelocityPlotCombined(Subplot): r""" Combined plot of all spacecraft velocity components in ECI. This class overlays the x, y, and z velocity components on a single set of axes, enabling direct comparison of component magnitudes and trends over time. Real and estimated sources may be displayed simultaneously. The plot emphasizes user-defined appearance options such as colors, labels, and axis scaling. :param sources: List of orbit velocity 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 velocity values. :type units: str :param colors: Colors used for the x, y, and z velocity 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 velocity components. :type labels: list[str] or None """ def __init__( self, *, sources: list[str] | None = None, # ["real","estimated"] time: str = "time_s", title: str = "Orbit Velocity (ECI)", units: str = "km/s", 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"$v_x$", r"$v_y$", r"$v_z$"]
[docs] 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: V = _get_V_series(run, source) if V is None: continue N = V.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, V[:, 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 velocity 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"Velocity [{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")