Source code for configurable_spectrograms.plotting

"""Single-output spectrogram rendering.

These functions render one figure (or one panel of a figure) for a single
item -- a single CDF, orbit, or caller-supplied dataset. Batch/loop callers
(:mod:`configurable_spectrograms.generic_batch`,
:mod:`configurable_spectrograms.fast.process_orbit`) call these same
functions once per item rather than re-implementing rendering logic, so a
single-plot CLI script and a batch driver always produce identical output
for identical inputs.
"""

from datetime import datetime, timezone

import matplotlib

matplotlib.use("Agg")  # Use non-interactive backend for batch and headless rendering.

import matplotlib.colors as mcolors  # noqa: E402
import matplotlib.dates as mdates  # noqa: E402
import numpy as np  # noqa: E402
from matplotlib import _pylab_helpers  # noqa: E402
from matplotlib.backends.backend_agg import FigureCanvasAgg as FigureCanvas  # noqa: E402
from matplotlib.dates import date2num  # noqa: E402
from matplotlib.figure import Figure  # noqa: E402

from configurable_spectrograms.constants import (  # noqa: E402
    AXIS_LABEL_FONT_SIZE,
    COLLAPSE_FUNCTION,
    PLOT_FIGURE_HEIGHT_INCHES,
    PLOT_FIGURE_WIDTH_INCHES,
    TICK_LABEL_FONT_SIZE,
)
from configurable_spectrograms.cusp_marking import (  # noqa: E402
    draw_cusp_both_markers,
    draw_cusp_bracket_marker,
    draw_cusp_line_markers,
)
from configurable_spectrograms.logging_utils import log_message  # noqa: E402
from configurable_spectrograms.percentile_utils import compute_percentile_bounds  # noqa: E402

_CUSP_MARKER_DRAWERS = {
    "line": draw_cusp_line_markers,
    "bracket": draw_cusp_bracket_marker,
    "both": draw_cusp_both_markers,
}
#: Colormaps whose high end is already red, so the cusp line marker's top
#: line switches to white to stay visible against it.
_RED_HEAVY_COLORMAPS = {"turbo"}


[docs] def close_all_axes_and_clear(fig) -> None: """Close axes/subplots and clear a figure to free memory. Parameters ---------- fig : matplotlib.figure.Figure Figure instance to clear and dispose. Returns ------- None Notes ----- Ensures axes are deleted, the canvas is closed/detached, and removes the figure from the global Gcf registry when possible to mitigate memory growth during large batch operations. """ for axis in list(fig.axes): try: fig.delaxes(axis) except Exception as axis_close_error: log_message(f"[WARN] Error closing axis: {axis_close_error}") fig.clf() if hasattr(fig, "canvas") and fig.canvas is not None: try: fig.canvas.close() except Exception as canvas_close_error: log_message(f"[WARN] Error closing canvas: {canvas_close_error}") try: fig.canvas.figure = None except Exception as canvas_figure_clear_error: log_message(f"[WARN] Error clearing canvas figure: {canvas_figure_clear_error}") fig.canvas = None try: if hasattr(fig, "number") and fig.number is not None: _pylab_helpers.Gcf.destroy(fig.number) except Exception as gcf_registry_error: log_message(f"[WARN] Error removing figure from Gcf registry: {gcf_registry_error}")
[docs] def make_spectrogram( x_axis_values, y_axis_values, data_array_3d, x_axis_min=None, x_axis_max=None, x_axis_is_unix=True, x_axis_label=None, center_timestamp=None, window_duration_seconds=None, y_axis_scale_function=None, y_axis_label=None, y_axis_min=0, y_axis_max=4000, z_axis_scale_function=None, z_axis_min=None, z_axis_max=None, z_axis_label=None, collapse_axis=1, colormap="viridis", axis_object=None, instrument_label=None, vertical_lines_unix=None, # list of unix timestamps to mark cusp_marker_style="both", cusp_marker_kwargs=None, ): """Plot a spectrogram by collapsing a 3D data array along an axis. Parameters ---------- x_axis_values : array-like 1D array for x (horizontal) axis (e.g., time sequence). y_axis_values : array-like 1D array for y (vertical) axis (e.g., energy bins). data_array_3d : numpy.ndarray 3D data array, e.g. ``(time, angle/pitch, energy)``. x_axis_min, x_axis_max : float, optional Explicit x-axis clipping bounds before plotting. x_axis_is_unix : bool, default True If ``True``, x-axis treated as UNIX seconds and converted to dates. x_axis_label : str, optional Custom x-axis label (default depends on ``x_axis_is_unix``). center_timestamp : float, optional Center of requested zoom window (UNIX seconds). window_duration_seconds : float, optional Duration of zoom window; both must be provided for zoom to apply. y_axis_scale_function : {'linear', 'log'}, optional Y-axis scaling; ``None`` behaves as ``'linear'``. y_axis_label : str, optional Y-axis label text. y_axis_min, y_axis_max : float, default 0, 4000 Y-axis clipping range applied before filtering / plotting. z_axis_scale_function : {'linear', 'log'}, optional Color scale mode; ``None`` behaves as ``'linear'``. z_axis_min, z_axis_max : float, optional Optional color scale bounds (percentiles chosen if omitted). z_axis_label : str, optional Colorbar label text. collapse_axis : int, default 1 Axis index along which to collapse the 3D data array. colormap : str, default 'viridis' Matplotlib colormap name. axis_object : matplotlib.axes.Axes, optional Existing axes to draw into; if ``None`` a new figure/axes created. instrument_label : str, optional Title string applied to the axes. vertical_lines_unix : list of float, optional UNIX timestamps to annotate with a cusp-boundary marker. cusp_marker_style : {'line', 'bracket', 'both'}, default 'both' Marker style for ``vertical_lines_unix``: ``'line'`` reproduces the original double-line marker; ``'bracket'`` draws a bracket spanning the boundary interval below the axis instead; ``'both'`` draws both styles together. cusp_marker_kwargs : dict or None, optional Extra keyword arguments forwarded to the selected marker-drawing function (see :mod:`configurable_spectrograms.cusp_marking`). Returns ------- axis_object : matplotlib.axes.Axes or None The axis object used for plotting (``None`` if no data plotted). x_axis_plot : numpy.ndarray or None X values actually used (possibly filtered / converted), or ``None`` if skipped. """ log_message( f"[DEBUG] make_spectrogram: y_axis_scale_function={y_axis_scale_function}, " f"z_axis_scale_function={z_axis_scale_function}, z_axis_min={z_axis_min}, " f"z_axis_max={z_axis_max}, colormap={colormap}" ) x_axis = np.asarray(x_axis_values) y_axis = np.asarray(y_axis_values) data_array = np.asarray(data_array_3d) # Collapse the 3D data array along the specified axis (e.g., sum over pitch angle). collapsed_matrix = COLLAPSE_FUNCTION(data_array, axis=collapse_axis) # Mask out columns that are all NaN and restrict to the valid energy range. nan_column_mask = ~np.all(np.isnan(collapsed_matrix), axis=0) valid_energy_mask = (y_axis >= y_axis_min) & (y_axis <= y_axis_max) combined_mask = nan_column_mask & valid_energy_mask collapsed_matrix = collapsed_matrix[:, combined_mask] y_axis = y_axis[combined_mask] if collapsed_matrix.size == 0 or y_axis.size == 0: log_message("[WARNING] All energy bins were filtered out. No data to plot.") return None, None if y_axis[0] > y_axis[-1]: y_axis = y_axis[::-1] collapsed_matrix = collapsed_matrix[:, ::-1] if center_timestamp is not None and window_duration_seconds is not None: half_window = window_duration_seconds / 2 left_bound = center_timestamp - half_window right_bound = center_timestamp + half_window zoom_mask = (x_axis >= left_bound) & (x_axis <= right_bound) x_axis = x_axis[zoom_mask] collapsed_matrix = collapsed_matrix[zoom_mask, :] if x_axis_min is not None or x_axis_max is not None: x_mask = np.ones_like(x_axis, dtype=bool) if x_axis_min is not None: x_mask &= x_axis >= x_axis_min if x_axis_max is not None: x_mask &= x_axis <= x_axis_max x_axis = x_axis[x_mask] collapsed_matrix = collapsed_matrix[x_mask, :] if x_axis_is_unix: x_axis_datetime = np.array([datetime.fromtimestamp(x, tz=timezone.utc) for x in x_axis]) x_axis_plot = date2num(x_axis_datetime) x_label = x_axis_label if x_axis_label is not None else "Time (UTC)" else: x_axis_plot = x_axis x_label = x_axis_label if x_axis_label is not None else "X" if axis_object is None: fig = Figure(figsize=(PLOT_FIGURE_WIDTH_INCHES, PLOT_FIGURE_HEIGHT_INCHES)) FigureCanvas(fig) axis_object = fig.add_subplot(1, 1, 1) else: fig = axis_object.figure matrix_plot = collapsed_matrix.T if center_timestamp is not None and window_duration_seconds is not None: if x_axis_is_unix: left_num = float( date2num(datetime.fromtimestamp(center_timestamp - window_duration_seconds / 2, tz=timezone.utc)) ) right_num = float( date2num(datetime.fromtimestamp(center_timestamp + window_duration_seconds / 2, tz=timezone.utc)) ) axis_object.set_xlim(left_num, right_num) else: axis_object.set_xlim( center_timestamp - window_duration_seconds / 2, center_timestamp + window_duration_seconds / 2, ) else: axis_object.set_xlim(x_axis_plot[0], x_axis_plot[-1]) if matrix_plot.size == 0: log_message("[WARNING] No data to plot after filtering. Skipping plot.") return None, None z_axis_min, z_axis_max = compute_percentile_bounds(matrix_plot, 1, 99, z_axis_min, z_axis_max) finite_positive = matrix_plot[np.isfinite(matrix_plot) & (matrix_plot > 0)] safe_vmin = np.nanmin(finite_positive) if finite_positive.size > 0 else 1e-10 if z_axis_scale_function == "log": if np.any(matrix_plot <= 0) or not ( np.isfinite(z_axis_min) and np.isfinite(z_axis_max) and z_axis_min > 0 and z_axis_max > 0 and z_axis_max > z_axis_min ): log_message( "[WARNING] Non-positive values found in matrix for log colorbar. " "Masking to z_axis_min and enforcing log scale." ) z_axis_min = float(max(z_axis_min, safe_vmin, 1e-10)) z_axis_max = float(z_axis_max) matrix_plot = np.where(~np.isfinite(matrix_plot) | (matrix_plot <= 0), z_axis_min, matrix_plot) norm = mcolors.LogNorm(vmin=z_axis_min, vmax=z_axis_max) im = axis_object.imshow( matrix_plot, aspect="auto", origin="lower", extent=(x_axis_plot[0], x_axis_plot[-1], y_axis[0], y_axis[-1]), cmap=colormap, norm=norm, ) min_exponent = int(np.floor(np.log10(z_axis_min))) max_exponent = int(np.ceil(np.log10(z_axis_max))) ticks = [10**i for i in range(min_exponent, max_exponent + 1) if z_axis_min <= 10**i <= z_axis_max] def log_tick_formatter(value, position=None): if value <= 0: return "" exponent = int(np.log10(value)) if np.isclose(value, 10**exponent): return f"$10^{{{exponent}}}$" return "" colorbar = fig.colorbar( im, ax=axis_object, label=z_axis_label if z_axis_label is not None else "Counts", ticks=ticks, format=log_tick_formatter, ) else: z_axis_min = float(z_axis_min) z_axis_max = float(z_axis_max) matrix_plot = np.where(np.isnan(matrix_plot), z_axis_min, matrix_plot) matrix_plot = np.where(np.isneginf(matrix_plot), z_axis_min, matrix_plot) matrix_plot = np.where(np.isposinf(matrix_plot), z_axis_max, matrix_plot) if not (np.isfinite(z_axis_min) and np.isfinite(z_axis_max) and z_axis_max > z_axis_min): z_axis_min = float(np.nanmin(matrix_plot)) z_axis_max = float(np.nanmax(matrix_plot)) im = axis_object.imshow( matrix_plot, aspect="auto", origin="lower", extent=(x_axis_plot[0], x_axis_plot[-1], y_axis[0], y_axis[-1]), cmap=colormap, vmin=z_axis_min, vmax=z_axis_max, ) colorbar = fig.colorbar( im, ax=axis_object, label=z_axis_label if z_axis_label is not None else "Counts", ) axis_object.set_xlabel(x_label) axis_object.set_ylabel(y_axis_label if y_axis_label is not None else "Energy (eV)") if instrument_label is not None: axis_object.set_title(instrument_label) if len(y_axis) >= 2: if y_axis_scale_function != "log": y_max_str = str(y_axis_max) y_max_digits = len(y_max_str) y_first_digit = int(y_max_str[0]) y_second_digit = int(y_max_str[1]) if y_second_digit >= 5: step_size = 10**y_max_digits y_max_tick = y_first_digit * 10 ** (y_max_digits - 1) else: step_size = 10 ** (y_max_digits - 1) y_max_tick = (y_first_digit + 0.5) * 10 ** (y_max_digits - 1) yticks = [i for i in range(y_axis_min, int(y_max_tick) + 1, step_size) if (i / y_max_tick) <= 1.1] if len(yticks) > 0: axis_object.set_yticks(yticks) axis_object.set_yticklabels([f"{int(e)}" for e in yticks]) else: axis_object.set_yscale("log") if x_axis_is_unix: x_limits = axis_object.get_xlim() left_datetime = mdates.num2date(x_limits[0], tz=timezone.utc) right_datetime = mdates.num2date(x_limits[1], tz=timezone.utc) displayed_time_range_seconds = (right_datetime - left_datetime).total_seconds() if displayed_time_range_seconds < 120: axis_object.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M:%S", tz=timezone.utc)) else: axis_object.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M", tz=timezone.utc)) if vertical_lines_unix is not None and len(vertical_lines_unix) > 0: if x_axis_is_unix: vertical_lines_plot = date2num( [datetime.fromtimestamp(timestamp, tz=timezone.utc) for timestamp in vertical_lines_unix] ) x_min_plot = x_axis_plot[0] x_max_plot = x_axis_plot[-1] vertical_lines_plot = [v for v in vertical_lines_plot if x_min_plot <= v <= x_max_plot] else: vertical_lines_plot = [v for v in vertical_lines_unix if x_axis_plot[0] <= v <= x_axis_plot[-1]] draw_marker = _CUSP_MARKER_DRAWERS.get(cusp_marker_style, draw_cusp_both_markers) marker_kwargs = dict(cusp_marker_kwargs or {}) marker_kwargs.setdefault("line_color", "white" if colormap in _RED_HEAVY_COLORMAPS else "red") draw_marker(axis_object, vertical_lines_plot, **marker_kwargs) axis_object.tick_params(axis="both", which="major", labelsize=TICK_LABEL_FONT_SIZE, length=8, width=1) axis_object.tick_params(axis="both", which="minor", labelsize=TICK_LABEL_FONT_SIZE, length=5, width=1) colorbar.ax.tick_params(labelsize=TICK_LABEL_FONT_SIZE, length=6, width=1) colorbar.ax.tick_params(which="minor", labelsize=TICK_LABEL_FONT_SIZE, length=3, width=1) axis_object.xaxis.label.set_fontsize(AXIS_LABEL_FONT_SIZE) axis_object.yaxis.label.set_fontsize(AXIS_LABEL_FONT_SIZE) colorbar.ax.set_ylabel("Counts", fontsize=AXIS_LABEL_FONT_SIZE) return axis_object, x_axis_plot
[docs] def generic_plot_spectrogram_set( datasets, collapse_axis=1, zoom_center=None, zoom_window_seconds=None, vertical_lines=None, x_is_unix=True, y_scale="linear", z_scale="linear", colormap="viridis", figure_title=None, show=False, y_min=None, y_max=None, z_min=None, z_max=None, cusp_marker_style="both", cusp_marker_kwargs=None, ): """Plot a vertical stack of generic spectrograms. Parameters ---------- datasets : list of dict Each dict requires keys ``'x'``, ``'y'``, ``'data'`` and may include optional keys: ``'label'``, ``'y_label'``, ``'z_label'``, ``'y_min'``, ``'y_max'``, ``'z_min'``, ``'z_max'``. collapse_axis : int, default 1 Axis index of the 3D array collapsed prior to plotting. zoom_center : float, optional Center (UNIX time) for zoom column when used. zoom_window_seconds : float, optional Duration of zoom window (seconds) when ``zoom_center`` provided. vertical_lines : list of float, optional UNIX timestamps to annotate with a cusp-boundary marker. x_is_unix : bool, default True If ``True``, x values are treated as UNIX seconds and formatted. y_scale : {'linear', 'log'}, default 'linear' Y-axis scaling mode. z_scale : {'linear', 'log'}, default 'linear' Color (intensity) scale mode. colormap : str, default 'viridis' Matplotlib colormap name. figure_title : str, optional Figure-level title (sup-title). show : bool, default False If ``True``, display interactively (requires GUI backend). y_min : float, optional Global Y min fallback when per-row not supplied. Defaults to 0 if omitted and per-row missing. y_max : float, optional Global Y max fallback when per-row not supplied. If both global and per-row absent, inferred. z_min : float, optional Global colorbar lower bound fallback. z_max : float, optional Global colorbar upper bound fallback. cusp_marker_style : {'line', 'bracket', 'both'}, default 'both' Marker style forwarded to :func:`make_spectrogram`. cusp_marker_kwargs : dict or None, optional Extra keyword arguments forwarded to the marker-drawing function. Returns ------- tuple ``(fig, canvas)`` or ``(None, None)`` if ``datasets`` is empty. """ if not datasets: return None, None fig = Figure(figsize=(10, 3 * len(datasets))) canvas = FigureCanvas(fig) for row_index, dataset in enumerate(datasets): axis_obj = fig.add_subplot(len(datasets), 1, row_index + 1) dataset_y_min = dataset.get("y_min", y_min) dataset_y_max = dataset.get("y_max", y_max) dataset_z_min = dataset.get("z_min", z_min) dataset_z_max = dataset.get("z_max", z_max) inferred_y_max = dataset["y"].max() if dataset_y_max is None and dataset.get("y") is not None else dataset_y_max make_spectrogram( x_axis_values=dataset["x"], y_axis_values=dataset["y"], data_array_3d=dataset["data"], collapse_axis=collapse_axis, center_timestamp=zoom_center, window_duration_seconds=zoom_window_seconds, x_axis_is_unix=x_is_unix, y_axis_scale_function=y_scale, z_axis_scale_function=z_scale, y_axis_min=dataset_y_min if dataset_y_min is not None else 0, y_axis_max=inferred_y_max if inferred_y_max is not None else 4000, z_axis_min=dataset_z_min, z_axis_max=dataset_z_max, colormap=colormap, y_axis_label=dataset.get("y_label", "Energy (eV)"), z_axis_label=dataset.get("z_label", "Counts"), x_axis_label="Time (UTC)" if x_is_unix else dataset.get("x_label"), vertical_lines_unix=vertical_lines, cusp_marker_style=cusp_marker_style, cusp_marker_kwargs=cusp_marker_kwargs, axis_object=axis_obj, ) if dataset.get("label"): axis_obj.set_title(dataset["label"]) if figure_title: fig.suptitle(figure_title) fig.tight_layout(rect=(0, 0, 1, 0.97)) if show: import matplotlib.pyplot as plt plt.show() return fig, canvas
[docs] def generic_plot_multirow_optional_zoom( datasets, vertical_lines=None, zoom_duration_minutes=6.25, y_scale="linear", z_scale="linear", colormap="viridis", show=False, title=None, row_label_pad=50, row_label_rotation=90, y_min=None, y_max=None, z_min=None, z_max=None, cusp_marker_style="both", cusp_marker_kwargs=None, ): """Render a multi-row spectrogram grid with an optional zoom column. Parameters ---------- datasets : list of dict Each dict must contain keys: * ``'x'`` -- 1D UNIX epoch seconds (float) array * ``'y'`` -- 1D energy (eV) array (unfiltered, 0-4000 typical) * ``'data'`` -- 3D ndarray that can be collapsed (time, pitch/angle, energy) Optional per-row keys (all honored when present): * ``'label'`` -- Row label placed on the left (rotated) * ``'y_label'`` -- Units label for y-axis (default: ``'Energy (eV)'``) * ``'z_label'`` -- Color scale label (default: ``'Counts'``) * ``'y_min'`` / ``'y_max'`` -- Energy bounds (overrides global ``y_min`` / ``y_max`` args) * ``'z_min'`` / ``'z_max'`` -- Color bounds (overrides global ``z_min`` / ``z_max`` args) * ``'vmin'`` / ``'vmax'`` -- Precomputed percentile (or fixed) color bounds used when ``z_min`` / ``z_max`` not provided. vertical_lines : list of float, optional UNIX timestamps defining the cusp boundary and potential zoom window. zoom_duration_minutes : float, default 6.25 Desired zoom window length in minutes (may auto-expand to include full marked span). y_scale : {'linear', 'log'}, default 'linear' Y-axis scaling. z_scale : {'linear', 'log'}, default 'linear' Color (intensity) scale. colormap : str, default 'viridis' Matplotlib colormap. show : bool, default False If ``True``, display interactively. title : str, optional Figure suptitle. row_label_pad : int, default 50 Padding for row labels. row_label_rotation : int, default 90 Rotation angle (degrees) for row labels. y_min, y_max, z_min, z_max : float, optional Global override bounds applied uniformly when provided. Any per-row ``y_min`` / ``y_max`` / ``z_min`` / ``z_max`` in a dataset dict take precedence. cusp_marker_style : {'line', 'bracket', 'both'}, default 'both' Marker style forwarded to :func:`make_spectrogram`. cusp_marker_kwargs : dict or None, optional Extra keyword arguments forwarded to the marker-drawing function. Returns ------- tuple ``(fig, canvas)`` or ``(None, None)`` if ``datasets`` is empty. Notes ----- Determines need for a zoom column dynamically: only rendered if at least one dataset contains non-NaN values inside the computed zoom window. """ if not datasets: return None, None zoom_needed = False center_value = None duration = None if vertical_lines and len(vertical_lines) > 0: if len(vertical_lines) == 1: center_value = vertical_lines[0] duration = zoom_duration_minutes * 60 else: center_value = 0.5 * (vertical_lines[0] + vertical_lines[1]) min_window = abs(vertical_lines[1] - vertical_lines[0]) * 1.5 requested_window = zoom_duration_minutes * 60 duration = max(requested_window, min_window) left = center_value - duration / 2 right = center_value + duration / 2 for ds in datasets: t = ds["x"] d = ds["data"] mask_zoom = (t >= left) & (t <= right) if np.any(~np.isnan(d[mask_zoom])): zoom_needed = True break number_rows = len(datasets) number_columns = 2 if zoom_needed else 1 fig = Figure(figsize=(12 * number_columns, 3 * number_rows)) canvas = FigureCanvas(fig) axes = np.empty((number_rows, number_columns), dtype=object) for i in range(number_rows): for j in range(number_columns): axes[i, j] = fig.add_subplot(number_rows, number_columns, i * number_columns + j + 1) for i, ds in enumerate(datasets): times = ds["x"] energy = ds["y"] data3d = ds["data"] vmin = ds.get("vmin") vmax = ds.get("vmax") make_spectrogram( x_axis_values=times, y_axis_values=energy, data_array_3d=data3d, collapse_axis=1, x_axis_min=times[0], x_axis_max=times[-1], x_axis_is_unix=True, instrument_label=None, y_axis_scale_function=y_scale, z_axis_scale_function=z_scale, vertical_lines_unix=vertical_lines, cusp_marker_style=cusp_marker_style, cusp_marker_kwargs=cusp_marker_kwargs, z_axis_min=vmin if z_min is None else z_min, z_axis_max=vmax if z_max is None else z_max, axis_object=axes[i, 0], colormap=colormap, ) if number_columns == 2: make_spectrogram( x_axis_values=times, y_axis_values=energy, data_array_3d=data3d, collapse_axis=1, center_timestamp=center_value, window_duration_seconds=duration, x_axis_is_unix=True, instrument_label=None, y_axis_scale_function=y_scale, z_axis_scale_function=z_scale, vertical_lines_unix=vertical_lines, cusp_marker_style=cusp_marker_style, cusp_marker_kwargs=cusp_marker_kwargs, z_axis_min=vmin if z_min is None else z_min, z_axis_max=vmax if z_max is None else z_max, axis_object=axes[i, 1], colormap=colormap, ) for i, ds in enumerate(datasets): axes[i, 0].set_ylabel( ds.get("label", ""), fontsize=AXIS_LABEL_FONT_SIZE, rotation=row_label_rotation, labelpad=row_label_pad, va="center", ) if number_columns == 2: axes[0, 0].set_title("Full", fontsize=AXIS_LABEL_FONT_SIZE) axes[0, 1].set_title("Zoomed", fontsize=AXIS_LABEL_FONT_SIZE) else: axes[0, 0].set_title("Full", fontsize=AXIS_LABEL_FONT_SIZE) if title: fig.suptitle(title, fontsize=AXIS_LABEL_FONT_SIZE + 2) base_times = datasets[0]["x"] t0 = datetime.fromtimestamp(base_times[0], tz=timezone.utc) t1 = datetime.fromtimestamp(base_times[-1], tz=timezone.utc) data_timespan_str = f"Data timespan: {t0.strftime('%Y-%m-%d %H:%M:%S')} to {t1.strftime('%Y-%m-%d %H:%M:%S')} UTC" marked_str = "" if vertical_lines and len(vertical_lines) > 0: v0 = datetime.fromtimestamp(min(vertical_lines), tz=timezone.utc) v1 = datetime.fromtimestamp(max(vertical_lines), tz=timezone.utc) marked_str = f"\nMarked range: {v0.strftime('%Y-%m-%d %H:%M:%S')} to {v1.strftime('%Y-%m-%d %H:%M:%S')} UTC" fig.subplots_adjust(bottom=0.18) fig.text(0.5, 0.01, data_timespan_str, ha="center", va="bottom", fontsize=13) if marked_str: fig.text( 0.5, 0.045, marked_str.strip(), ha="center", va="bottom", fontsize=13, color="red", ) fig.tight_layout(rect=(0, 0.08, 1, 0.95)) if show: import matplotlib.pyplot as plt plt.show() return fig, canvas