Source code for ADCS.helpers.plot.sensors.sensorsplot

__all__ = ["SensorsPlot", "SensorsPlotSingle", "SensorsPlotCombined"]

import math
import numpy as np
import matplotlib.gridspec as gridspec

from ..subplot import Subplot


def _normalize_sources(sources):
    if sources is None or len(sources) == 0:
        return ["real"]
    allowed = {"real", "clean"}
    bad = [s for s in sources if s not in allowed]
    if bad:
        raise ValueError(f"Invalid sources {bad}. Allowed: {sorted(allowed)}")
    # de-dup but keep order
    out = []
    for s in sources:
        if s not in out:
            out.append(s)
    return out


def _get_sensor_matrix(sim, which: str):
    if which == "real":
        hist = sim.sensor_hist
    elif which == "clean":
        hist = sim.clean_sensor_hist
    else:
        raise ValueError("which must be 'real' or 'clean'")

    if hist is None or len(hist) == 0:
        return None
    return np.vstack([np.asarray(v) for v in hist])


[docs] class SensorsPlot(Subplot): r""" Multi-panel visualization of sensor measurement histories. This class displays each sensor channel in its own subplot, arranged automatically in a grid. Multiple data sources, such as real and clean sensor readings, may be overlaid for comparison. The plot is configured primarily through user settings for sources, labeling, units, and axis scaling. :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 sensor readings. :type units: str :param labels: Optional list of labels for each sensor channel. :type labels: list[str] or None :param log_y: If True, the y-axes use logarithmic scaling. :type log_y: bool :param sources: List of sensor data sources to display. Supported values are real and clean. :type sources: list[str] or None """ def __init__( self, *, time: str = "time_s", title: str = "Sensors", units: str = "", labels: list[str] | None = None, log_y: bool = False, sources: list[str] | None = None, # ["real", "clean"] ): self.time = time self.title = title self.units = units self.labels = labels self.log_y = log_y self.sources = _normalize_sources(sources)
[docs] def plot(self, ax, sim) -> None: runs = getattr(sim, "runs", None) if runs is None: runs = [sim] ax.set_frame_on(False) ax.tick_params(left=False, labelleft=False, bottom=False, labelbottom=False) # Find first run with usable data for layout first_mat = None first_run = None for run in runs: mats = {src: _get_sensor_matrix(run, src) for src in self.sources} first = next((m for m in mats.values() if m is not None), None) if first is not None: first_mat = first first_run = run break if first_mat is None or first_run is None: self._plot_no_data(ax) return n_sens = int(first_mat.shape[1]) # layout grid ncols = int(math.ceil(math.sqrt(n_sens))) nrows = int(math.ceil(n_sens / ncols)) gs = gridspec.GridSpecFromSubplotSpec(nrows, ncols, subplot_spec=ax.get_subplotspec()) axes = [] for i in range(n_sens): r, c = divmod(i, ncols) axes.append(ax.figure.add_subplot(gs[r, c])) # labels if self.labels is None: labels = [rf"$y_{{{i}}}$" for i in range(n_sens)] else: if len(self.labels) != n_sens: raise ValueError(f"labels length ({len(self.labels)}) must match sensor dimension ({n_sens})") labels = self.labels style = {"real": "-", "clean": "--"} alpha = max(0.15, 1.0 / len(runs)) plotted_any = False # Plot all runs for run in runs: t0 = getattr(run, self.time, None) mats = {src: _get_sensor_matrix(run, src) for src in self.sources} first = next((m for m in mats.values() if m is not None), None) if first is None: continue N = first.shape[0] for m in mats.values(): if m is not None: N = min(N, m.shape[0]) t = None if t0 is not None: t = np.asarray(t0)[:N] else: t = np.arange(N) for i, ax_i in enumerate(axes): for src in self.sources: Y = mats.get(src, None) if Y is None: continue ax_i.plot( t, Y[:N, i], linestyle=style[src], alpha=alpha, label=None, ) plotted_any = True if not plotted_any: self._plot_no_data(ax) return # Format each subplot + clean legend (sources only) for i, ax_i in enumerate(axes): ylabel = f"{labels[i]} [{self.units}]" if self.units else labels[i] ax_i.set_ylabel(ylabel) if self.log_y: ax_i.set_yscale("log") ax_i.grid(True, which="both") # legend only shows sources if len(self.sources) > 1: handles, labs = [], [] for src in self.sources: handles.append(ax_i.plot([], [], linestyle=style[src], color="k")[0]) labs.append(src) ax_i.legend(handles, labs) # hide unused cells for j in range(n_sens, nrows * ncols): r, c = divmod(j, ncols) ax_unused = ax.figure.add_subplot(gs[r, c]) ax_unused.axis("off") for ax_i in axes[-ncols:]: ax_i.set_xlabel("Time [s]") axes[0].set_title(self.title, loc="left", pad=10)
def _plot_no_data(self, ax): 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 sensor history available", ha="center", va="center")
[docs] class SensorsPlotSingle(Subplot): r""" Visualization of a single sensor channel over time. This class plots one selected sensor channel, optionally overlaying multiple data sources such as real and clean measurements. It is intended for focused inspection of a specific sensor output. User settings control the selected channel, labeling, color, and axis scaling. :param index: Index of the sensor channel to plot. :type index: int :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 sensor readings. :type units: str :param label: Label used for the sensor channel. :type label: str or None :param color: Color used for the plotted sensor signal. :type color: str or None :param log_y: If True, the y-axis uses logarithmic scaling. :type log_y: bool :param sources: List of sensor data sources to display. Supported values are real and clean. :type sources: list[str] or None """ def __init__( self, index: int, *, time: str = "time_s", title: str | None = None, units: str = "", label: str | None = None, color: str | None = None, log_y: bool = False, sources: list[str] | None = None, # ["real", "clean"] ): self.index = index self.time = time self.title = title self.units = units self.label = label self.color = color self.log_y = log_y self.sources = _normalize_sources(sources)
[docs] def plot(self, ax, sim) -> None: runs = getattr(sim, "runs", None) if runs is None: runs = [sim] # Find first run with data to validate index first_mat = None for run in runs: mats = {src: _get_sensor_matrix(run, src) for src in self.sources} first = next((m for m in mats.values() if m is not None), None) if first is not None: first_mat = first break if first_mat is None: self._plot_no_data(ax) return n_sens = int(first_mat.shape[1]) if self.index < 0 or self.index >= n_sens: raise ValueError(f"Sensor index {self.index} out of bounds for {n_sens} channels.") lbl = self.label or rf"$y_{{{self.index}}}$" title = self.title or f"Sensor Channel {self.index}" style = {"real": "-", "clean": "--"} alpha = max(0.15, 1.0 / len(runs)) plotted_any = False for run in runs: t0 = getattr(run, self.time, None) mats = {src: _get_sensor_matrix(run, src) for src in self.sources} first = next((m for m in mats.values() if m is not None), None) if first is None: continue N = first.shape[0] for m in mats.values(): if m is not None: N = min(N, m.shape[0]) t = np.asarray(t0)[:N] if t0 is not None else np.arange(N) for src_i, src in enumerate(self.sources): Y = mats.get(src, None) if Y is None: continue kw = {} if self.color and src_i == 0: kw["color"] = self.color ax.plot( t, Y[:N, self.index], linestyle=style[src], alpha=alpha, label=None, **kw, ) plotted_any = True if not plotted_any: self._plot_no_data(ax) return ylabel = f"{lbl} [{self.units}]" if self.units else lbl ax.set_ylabel(ylabel) ax.set_xlabel("Time [s]") ax.set_title(title) if self.log_y: ax.set_yscale("log") # Clean legend: sources only handles, labs = [], [] for src in self.sources: handles.append(ax.plot([], [], linestyle=style[src], color="k")[0]) labs.append(src if len(self.sources) > 1 else lbl) ax.legend(handles, labs) ax.grid(True, which="both")
def _plot_no_data(self, ax): ax.axis("off") ax.set_title(self.title or "Sensor", loc="left", pad=10) ax.text(0.5, 0.5, "No sensor history available", ha="center", va="center", transform=ax.transAxes)
[docs] class SensorsPlotCombined(Subplot): r""" Combined plot of all sensor channels on a single axis. This class overlays all sensor channels on one set of axes, optionally including multiple data sources. It provides a compact, high-level view of sensor activity and relative magnitudes. The plot emphasizes user-defined configuration of labels, colors, units, and axis scaling. :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 sensor readings. :type units: str :param labels: Optional list of labels for each sensor channel. :type labels: list[str] or None :param log_y: If True, the y-axis uses logarithmic scaling. :type log_y: bool :param colors: Optional list of colors used cyclically for sensor channels. :type colors: list[str] or None :param sources: List of sensor data sources to display. Supported values are real and clean. :type sources: list[str] or None """ def __init__( self, *, time: str = "time_s", title: str = "Sensors", units: str = "", labels: list[str] | None = None, log_y: bool = False, colors: list[str] | None = None, sources: list[str] | None = None, # ["real", "clean"] ): self.time = time self.title = title self.units = units self.labels = labels self.log_y = log_y self.colors = colors self.sources = _normalize_sources(sources)
[docs] def plot(self, ax, sim) -> None: runs = getattr(sim, "runs", None) if runs is None: runs = [sim] # Find first run with data for dimensioning first_mat = None for run in runs: mats = {src: _get_sensor_matrix(run, src) for src in self.sources} first = next((m for m in mats.values() if m is not None), None) if first is not None: first_mat = first break if first_mat is None: self._plot_no_data(ax) return n_sens = int(first_mat.shape[1]) if self.labels is None: labels = [rf"$y_{{{i}}}$" for i in range(n_sens)] else: if len(self.labels) != n_sens: raise ValueError(f"labels length ({len(self.labels)}) must match sensor dimension ({n_sens})") labels = self.labels style = {"real": "-", "clean": "--"} alpha = max(0.15, 1.0 / len(runs)) plotted_any = False for run in runs: t0 = getattr(run, self.time, None) mats = {src: _get_sensor_matrix(run, src) for src in self.sources} first = next((m for m in mats.values() if m is not None), None) if first is None: continue N = first.shape[0] for m in mats.values(): if m is not None: N = min(N, m.shape[0]) t = np.asarray(t0)[:N] if t0 is not None else np.arange(N) for i in range(n_sens): color_arg = {} if self.colors: color_arg["color"] = self.colors[i % len(self.colors)] for src in self.sources: Y = mats.get(src, None) if Y is None: continue ax.plot( t, Y[:N, i], linestyle=style[src], alpha=alpha, label=None, **color_arg, ) plotted_any = True if not plotted_any: self._plot_no_data(ax) return ylabel = f"Sensor Reading [{self.units}]" if self.units else "Sensor Reading" ax.set_ylabel(ylabel) ax.set_xlabel("Time [s]") ax.set_title(self.title) if self.log_y: ax.set_yscale("log") # Legend (compact): colors proxy for channels (optional), linestyle proxy for sources handles, labs = [], [] if self.colors: kmax = min(n_sens, len(self.colors)) for i in range(kmax): handles.append(ax.plot([], [], color=self.colors[i], linestyle="-")[0]) labs.append(labels[i]) if n_sens > kmax: handles.append(ax.plot([], [], color="k", linestyle="-")[0]) labs.append("...") if len(self.sources) > 1: for src in self.sources: handles.append(ax.plot([], [], color="k", linestyle=style[src])[0]) labs.append(src) if handles: ax.legend(handles, labs, bbox_to_anchor=(1.05, 1), loc="upper left") else: ax.legend() ax.grid(True, which="both", linestyle="--", alpha=0.7)
def _plot_no_data(self, ax): ax.axis("off") ax.set_title(self.title, loc="left", pad=10) ax.text(0.5, 0.5, "No sensor history available", ha="center", va="center", transform=ax.transAxes)