Skip to content

Visualization

Interactive Plotly and statistical Seaborn visualizations for link budgets, missions, trades, and payloads.

Timeline Plots

timeline

Time-series visualization functions using Plotly.

plot_link_margin_timeline(times_s, margin_db, outages_mask=None, threshold_db=0.0, title='Link Margin vs Time')

Interactive link margin timeline with hover tooltips and outage shading.

Parameters:

Name Type Description Default
times_s ndarray

1-D array of time stamps in seconds.

required
margin_db ndarray

1-D array of link margin values in dB, same length as times_s.

required
outages_mask ndarray or None

Boolean array indicating outage intervals (True = outage). When provided, outage points are plotted as red markers. Default is None (no outage shading).

None
threshold_db float

Margin threshold in dB drawn as a horizontal dashed line. Default is 0.0.

0.0
title str

Plot title. Default is "Link Margin vs Time".

'Link Margin vs Time'

Returns:

Type Description
Figure

Interactive Plotly figure with margin trace, outage markers, and threshold line.

Source code in src/opensatcom/viz/timeline.py
def plot_link_margin_timeline(
    times_s: np.ndarray,
    margin_db: np.ndarray,
    outages_mask: np.ndarray | None = None,
    threshold_db: float = 0.0,
    title: str = "Link Margin vs Time",
) -> Any:
    """Interactive link margin timeline with hover tooltips and outage shading.

    Parameters
    ----------
    times_s : np.ndarray
        1-D array of time stamps in seconds.
    margin_db : np.ndarray
        1-D array of link margin values in dB, same length as *times_s*.
    outages_mask : np.ndarray or None, optional
        Boolean array indicating outage intervals (``True`` = outage).
        When provided, outage points are plotted as red markers.
        Default is ``None`` (no outage shading).
    threshold_db : float, optional
        Margin threshold in dB drawn as a horizontal dashed line.
        Default is ``0.0``.
    title : str, optional
        Plot title. Default is ``"Link Margin vs Time"``.

    Returns
    -------
    plotly.graph_objects.Figure
        Interactive Plotly figure with margin trace, outage markers, and
        threshold line.
    """
    import plotly.graph_objects as go

    fig = go.Figure()

    # Valid margin line
    valid = ~outages_mask if outages_mask is not None else np.ones(len(times_s), dtype=bool)
    fig.add_trace(go.Scatter(
        x=times_s[valid],
        y=margin_db[valid],
        mode="lines",
        name="Margin (dB)",
        line=dict(color="#1565c0", width=2),
        hovertemplate="Time: %{x:.1f}s<br>Margin: %{y:.2f} dB<extra></extra>",
    ))

    # Outage regions
    if outages_mask is not None and np.any(outages_mask):
        outage_times = times_s[outages_mask]
        fig.add_trace(go.Scatter(
            x=outage_times,
            y=np.zeros(len(outage_times)),
            mode="markers",
            name="Outage",
            marker=dict(color="red", size=4, symbol="x"),
        ))

    # Threshold line
    fig.add_hline(y=threshold_db, line_dash="dash", line_color="red",
                  annotation_text=f"Threshold: {threshold_db} dB")

    fig.update_layout(
        title=title,
        xaxis_title="Time (s)",
        yaxis_title="Margin (dB)",
        template="plotly_white",
        hovermode="x unified",
    )
    return fig

plot_elevation_profile

plot_elevation_profile(times_s, elev_deg, title='Elevation Profile')

Interactive elevation vs time plot.

Parameters:

Name Type Description Default
times_s ndarray

1-D array of time stamps in seconds.

required
elev_deg ndarray

1-D array of elevation angles in degrees, same length as times_s.

required
title str

Plot title. Default is "Elevation Profile".

'Elevation Profile'

Returns:

Type Description
Figure

Interactive Plotly figure showing elevation angle over time.

Source code in src/opensatcom/viz/timeline.py
def plot_elevation_profile(
    times_s: np.ndarray,
    elev_deg: np.ndarray,
    title: str = "Elevation Profile",
) -> Any:
    """Interactive elevation vs time plot.

    Parameters
    ----------
    times_s : np.ndarray
        1-D array of time stamps in seconds.
    elev_deg : np.ndarray
        1-D array of elevation angles in degrees, same length as *times_s*.
    title : str, optional
        Plot title. Default is ``"Elevation Profile"``.

    Returns
    -------
    plotly.graph_objects.Figure
        Interactive Plotly figure showing elevation angle over time.
    """
    import plotly.graph_objects as go

    fig = go.Figure()
    fig.add_trace(go.Scatter(
        x=times_s,
        y=elev_deg,
        mode="lines",
        name="Elevation",
        line=dict(color="#2e7d32", width=2),
        hovertemplate="Time: %{x:.1f}s<br>Elevation: %{y:.1f} deg<extra></extra>",
    ))

    fig.update_layout(
        title=title,
        xaxis_title="Time (s)",
        yaxis_title="Elevation (deg)",
        template="plotly_white",
    )
    return fig

Heatmaps

heatmaps

Heatmap and 3D surface visualizations using Plotly.

plot_beam_map_interactive

plot_beam_map_interactive(beam_map_df, metric='sinr_db', title='Beam Coverage Map')

Interactive beam map heatmap with hover details.

Parameters:

Name Type Description Default
beam_map_df DataFrame

DataFrame with columns including az_deg, el_deg, and one or more metric columns (e.g., sinr_db, margin_db, beam_id).

required
metric str

Name of the column used for marker color scaling. Default is "sinr_db".

'sinr_db'
title str

Plot title. Default is "Beam Coverage Map".

'Beam Coverage Map'

Returns:

Type Description
Figure

Interactive Plotly scatter plot of beam positions colored by the selected metric, with per-point hover details.

Source code in src/opensatcom/viz/heatmaps.py
def plot_beam_map_interactive(
    beam_map_df: Any,
    metric: str = "sinr_db",
    title: str = "Beam Coverage Map",
) -> Any:
    """Interactive beam map heatmap with hover details.

    Parameters
    ----------
    beam_map_df : pd.DataFrame
        DataFrame with columns including ``az_deg``, ``el_deg``, and one or
        more metric columns (e.g., ``sinr_db``, ``margin_db``, ``beam_id``).
    metric : str, optional
        Name of the column used for marker color scaling.
        Default is ``"sinr_db"``.
    title : str, optional
        Plot title. Default is ``"Beam Coverage Map"``.

    Returns
    -------
    plotly.graph_objects.Figure
        Interactive Plotly scatter plot of beam positions colored by the
        selected metric, with per-point hover details.
    """
    import plotly.graph_objects as go

    hover_cols = [c for c in beam_map_df.columns if c not in ("az_deg", "el_deg")]
    hover_text = []
    for _, row in beam_map_df.iterrows():
        parts = [f"{c}: {row[c]:.2f}" if isinstance(row[c], float) else f"{c}: {row[c]}"
                 for c in hover_cols]
        hover_text.append("<br>".join(parts))

    fig = go.Figure()
    fig.add_trace(go.Scatter(
        x=beam_map_df["az_deg"],
        y=beam_map_df["el_deg"],
        mode="markers",
        marker=dict(
            size=10,
            color=beam_map_df[metric],
            colorscale="RdYlGn",
            colorbar=dict(title=metric),
            line=dict(width=0.5, color="black"),
        ),
        text=hover_text,
        hoverinfo="text",
    ))

    fig.update_layout(
        title=title,
        xaxis_title="Azimuth (deg)",
        yaxis_title="Elevation (deg)",
        template="plotly_white",
    )
    return fig

plot_rain_attenuation_surface

plot_rain_attenuation_surface(freqs_ghz, elevs_deg, rain_rate_mm_per_hr=25.0, title='Rain Attenuation vs Frequency & Elevation')

3D surface plot of rain loss vs frequency vs elevation.

Computes rain attenuation using the ITU-R P.618 model over a grid of frequencies and elevation angles, then renders an interactive 3-D surface.

Parameters:

Name Type Description Default
freqs_ghz ndarray

1-D array of carrier frequencies in GHz (x-axis).

required
elevs_deg ndarray

1-D array of elevation angles in degrees (y-axis).

required
rain_rate_mm_per_hr float

Rain rate used by the P.618 model, in mm/hr. Default is 25.0.

25.0
title str

Plot title. Default is "Rain Attenuation vs Frequency & Elevation".

'Rain Attenuation vs Frequency & Elevation'

Returns:

Type Description
Figure

Interactive Plotly 3-D surface figure with frequency on the x-axis, elevation on the y-axis, and rain loss in dB on the z-axis.

Source code in src/opensatcom/viz/heatmaps.py
def plot_rain_attenuation_surface(
    freqs_ghz: np.ndarray,
    elevs_deg: np.ndarray,
    rain_rate_mm_per_hr: float = 25.0,
    title: str = "Rain Attenuation vs Frequency & Elevation",
) -> Any:
    """3D surface plot of rain loss vs frequency vs elevation.

    Computes rain attenuation using the ITU-R P.618 model over a grid of
    frequencies and elevation angles, then renders an interactive 3-D surface.

    Parameters
    ----------
    freqs_ghz : np.ndarray
        1-D array of carrier frequencies in GHz (x-axis).
    elevs_deg : np.ndarray
        1-D array of elevation angles in degrees (y-axis).
    rain_rate_mm_per_hr : float, optional
        Rain rate used by the P.618 model, in mm/hr. Default is ``25.0``.
    title : str, optional
        Plot title. Default is ``"Rain Attenuation vs Frequency & Elevation"``.

    Returns
    -------
    plotly.graph_objects.Figure
        Interactive Plotly 3-D surface figure with frequency on the x-axis,
        elevation on the y-axis, and rain loss in dB on the z-axis.
    """
    import plotly.graph_objects as go

    from opensatcom.core.models import PropagationConditions
    from opensatcom.propagation.rain import RainAttenuationP618

    model = RainAttenuationP618(rain_rate_mm_per_hr=rain_rate_mm_per_hr)
    cond = PropagationConditions()

    freq_grid, elev_grid = np.meshgrid(freqs_ghz, elevs_deg)
    loss_grid = np.zeros_like(freq_grid)
    for i in range(freq_grid.shape[0]):
        for j in range(freq_grid.shape[1]):
            loss_grid[i, j] = model.total_path_loss_db(
                freq_grid[i, j] * 1e9, elev_grid[i, j], 1e6, cond
            )

    fig = go.Figure(data=[go.Surface(
        x=freqs_ghz,
        y=elevs_deg,
        z=loss_grid,
        colorscale="YlOrRd",
        colorbar=dict(title="Loss (dB)"),
    )])

    fig.update_layout(
        title=title,
        scene=dict(
            xaxis_title="Frequency (GHz)",
            yaxis_title="Elevation (deg)",
            zaxis_title="Rain Loss (dB)",
        ),
    )
    return fig

Trade Study Plots

trades

Trade study visualizations using Plotly.

plot_pareto_interactive

plot_pareto_interactive(df, x_col, y_col, pareto_df, title='Pareto Front Analysis')

Interactive Plotly scatter with Pareto front highlighted.

Renders all design points as semi-transparent markers and overlays the Pareto-optimal subset as a connected star-marker trace.

Parameters:

Name Type Description Default
df DataFrame

Full DOE / trade-study results containing at least x_col and y_col columns.

required
x_col str

Column name used for the x-axis (e.g., "cost_usd").

required
y_col str

Column name used for the y-axis (e.g., "throughput_p50").

required
pareto_df DataFrame

Subset of df representing the Pareto-optimal designs.

required
title str

Plot title. Default is "Pareto Front Analysis".

'Pareto Front Analysis'

Returns:

Type Description
Figure

Interactive Plotly figure with all designs and the Pareto front.

Source code in src/opensatcom/viz/trades.py
def plot_pareto_interactive(
    df: pd.DataFrame,
    x_col: str,
    y_col: str,
    pareto_df: pd.DataFrame,
    title: str = "Pareto Front Analysis",
) -> Any:
    """Interactive Plotly scatter with Pareto front highlighted.

    Renders all design points as semi-transparent markers and overlays the
    Pareto-optimal subset as a connected star-marker trace.

    Parameters
    ----------
    df : pd.DataFrame
        Full DOE / trade-study results containing at least *x_col* and
        *y_col* columns.
    x_col : str
        Column name used for the x-axis (e.g., ``"cost_usd"``).
    y_col : str
        Column name used for the y-axis (e.g., ``"throughput_p50"``).
    pareto_df : pd.DataFrame
        Subset of *df* representing the Pareto-optimal designs.
    title : str, optional
        Plot title. Default is ``"Pareto Front Analysis"``.

    Returns
    -------
    plotly.graph_objects.Figure
        Interactive Plotly figure with all designs and the Pareto front.
    """
    import plotly.graph_objects as go

    fig = go.Figure()

    # All points
    hover_cols = [c for c in df.columns if c not in (x_col, y_col)]
    hover_text = []
    for _, row in df.iterrows():
        parts = [f"{c}: {row[c]:.3f}" if isinstance(row[c], float) else f"{c}: {row[c]}"
                 for c in hover_cols[:5]]  # Limit to 5 hover columns
        hover_text.append("<br>".join(parts))

    fig.add_trace(go.Scatter(
        x=df[x_col],
        y=df[y_col],
        mode="markers",
        name="All designs",
        marker=dict(color="#90caf9", size=8, opacity=0.5),
        text=hover_text,
        hoverinfo="text+x+y",
    ))

    # Pareto front
    pf = pareto_df.sort_values(x_col)
    fig.add_trace(go.Scatter(
        x=pf[x_col],
        y=pf[y_col],
        mode="lines+markers",
        name="Pareto front",
        line=dict(color="#c62828", width=2, dash="dash"),
        marker=dict(color="#1565c0", size=12, symbol="star"),
    ))

    fig.update_layout(
        title=title,
        xaxis_title=x_col,
        yaxis_title=y_col,
        template="plotly_white",
        hovermode="closest",
    )
    return fig

plot_doe_parallel_coords

plot_doe_parallel_coords(df, objectives=None, title='DOE Parallel Coordinates')

Parallel coordinates plot for DOE results.

Displays all numeric columns of the DOE results as parallel coordinate axes, colored by the first objective column when provided.

Parameters:

Name Type Description Default
df DataFrame

DOE results with parameter and objective columns. Only numeric columns are included as axes.

required
objectives list of str or None

Column names to highlight as objectives. The first entry is used as the color dimension. Default is None (color by the last numeric column).

None
title str

Plot title. Default is "DOE Parallel Coordinates".

'DOE Parallel Coordinates'

Returns:

Type Description
Figure

Interactive Plotly parallel-coordinates figure.

Source code in src/opensatcom/viz/trades.py
def plot_doe_parallel_coords(
    df: pd.DataFrame,
    objectives: list[str] | None = None,
    title: str = "DOE Parallel Coordinates",
) -> Any:
    """Parallel coordinates plot for DOE results.

    Displays all numeric columns of the DOE results as parallel coordinate
    axes, colored by the first objective column when provided.

    Parameters
    ----------
    df : pd.DataFrame
        DOE results with parameter and objective columns. Only numeric
        columns are included as axes.
    objectives : list of str or None, optional
        Column names to highlight as objectives. The first entry is used
        as the color dimension. Default is ``None`` (color by the last
        numeric column).
    title : str, optional
        Plot title. Default is ``"DOE Parallel Coordinates"``.

    Returns
    -------
    plotly.graph_objects.Figure
        Interactive Plotly parallel-coordinates figure.
    """
    import plotly.graph_objects as go

    # Select numeric columns
    numeric_cols = df.select_dtypes(include="number").columns.tolist()

    color_col: str | None = None
    if objectives and objectives[0] in numeric_cols:
        color_col = objectives[0]
    else:
        color_col = numeric_cols[-1] if numeric_cols else None

    dimensions = []
    for col in numeric_cols:
        dimensions.append(dict(
            label=col,
            values=df[col],
            range=[float(df[col].min()), float(df[col].max())],
        ))

    fig = go.Figure(data=go.Parcoords(
        line=dict(
            color=df[color_col] if color_col else None,
            colorscale="Viridis",
            showscale=True,
            colorbar=dict(title=color_col) if color_col else None,
        ),
        dimensions=dimensions,
    ))

    fig.update_layout(title=title)
    return fig

Statistical Plots

statistical

Statistical distribution visualizations using Seaborn and Matplotlib.

plot_margin_distribution

plot_margin_distribution(margin_db, title='Link Margin Distribution')

KDE + histogram of margin distribution.

Plots a Seaborn histogram with a kernel-density estimate overlay and a vertical reference line at zero margin.

Parameters:

Name Type Description Default
margin_db ndarray

1-D array of link margin values in dB. NaN entries are automatically excluded.

required
title str

Plot title. Default is "Link Margin Distribution".

'Link Margin Distribution'

Returns:

Type Description
Figure

Matplotlib figure containing the histogram/KDE plot.

Source code in src/opensatcom/viz/statistical.py
def plot_margin_distribution(
    margin_db: np.ndarray,
    title: str = "Link Margin Distribution",
) -> Any:
    """KDE + histogram of margin distribution.

    Plots a Seaborn histogram with a kernel-density estimate overlay and a
    vertical reference line at zero margin.

    Parameters
    ----------
    margin_db : np.ndarray
        1-D array of link margin values in dB. ``NaN`` entries are
        automatically excluded.
    title : str, optional
        Plot title. Default is ``"Link Margin Distribution"``.

    Returns
    -------
    matplotlib.figure.Figure
        Matplotlib figure containing the histogram/KDE plot.
    """
    import matplotlib

    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    import seaborn as sns

    valid = margin_db[~np.isnan(margin_db)]

    fig, ax = plt.subplots(figsize=(8, 5))
    sns.histplot(valid, kde=True, ax=ax, color="#1565c0", alpha=0.6)
    ax.axvline(0, color="red", linestyle="--", alpha=0.7, label="Zero margin")
    ax.set_xlabel("Margin (dB)")
    ax.set_ylabel("Count")
    ax.set_title(title)
    ax.legend()
    fig.tight_layout()
    return fig

plot_modcod_waterfall

plot_modcod_waterfall(times_s, selected_modcod, title='ModCod Selection Over Time')

Stacked bar showing ModCod selection over time.

Each time step is rendered as a color-coded bar indicating the active ModCod, with a legend mapping colors to ModCod names.

Parameters:

Name Type Description Default
times_s ndarray

1-D array of time stamps in seconds.

required
selected_modcod list of str

ModCod name selected at each time step, same length as times_s. Empty strings are rendered in light grey.

required
title str

Plot title. Default is "ModCod Selection Over Time".

'ModCod Selection Over Time'

Returns:

Type Description
Figure

Matplotlib figure containing the color-coded ModCod waterfall bar chart.

Source code in src/opensatcom/viz/statistical.py
def plot_modcod_waterfall(
    times_s: np.ndarray,
    selected_modcod: list[str],
    title: str = "ModCod Selection Over Time",
) -> Any:
    """Stacked bar showing ModCod selection over time.

    Each time step is rendered as a color-coded bar indicating the active
    ModCod, with a legend mapping colors to ModCod names.

    Parameters
    ----------
    times_s : np.ndarray
        1-D array of time stamps in seconds.
    selected_modcod : list of str
        ModCod name selected at each time step, same length as *times_s*.
        Empty strings are rendered in light grey.
    title : str, optional
        Plot title. Default is ``"ModCod Selection Over Time"``.

    Returns
    -------
    matplotlib.figure.Figure
        Matplotlib figure containing the color-coded ModCod waterfall bar
        chart.
    """
    import matplotlib

    matplotlib.use("Agg")
    import matplotlib.pyplot as plt

    # Map unique ModCods to colors
    unique_modcods = sorted(set(m for m in selected_modcod if m))
    if not unique_modcods:
        fig, ax = plt.subplots(figsize=(10, 4))
        ax.set_title(title)
        return fig

    cmap = plt.colormaps.get_cmap("tab20").resampled(len(unique_modcods))
    mc_to_idx = {mc: i for i, mc in enumerate(unique_modcods)}

    fig, ax = plt.subplots(figsize=(12, 4))
    colors = [cmap(mc_to_idx.get(m, 0)) if m else (0.9, 0.9, 0.9, 1.0)
              for m in selected_modcod]
    ax.bar(times_s, 1, width=times_s[1] - times_s[0] if len(times_s) > 1 else 1,
           color=colors, edgecolor="none")

    # Legend
    import matplotlib.patches as mpatches

    patches = [mpatches.Patch(color=cmap(i), label=mc)
               for i, mc in enumerate(unique_modcods)]
    ax.legend(handles=patches, loc="upper right", fontsize=7, ncol=3)

    ax.set_xlabel("Time (s)")
    ax.set_title(title)
    ax.set_yticks([])
    fig.tight_layout()
    return fig

plot_availability_heatmap

plot_availability_heatmap(data, x_labels=None, y_labels=None, title='Availability Heatmap')

Seaborn heatmap of availability vs parameter pairs.

Parameters:

Name Type Description Default
data ndarray

2-D array of availability values, typically in the range [0, 1].

required
x_labels list of str or None

Tick labels for the columns (x-axis). Default is None (auto-generated by Seaborn).

None
y_labels list of str or None

Tick labels for the rows (y-axis). Default is None (auto-generated by Seaborn).

None
title str

Plot title. Default is "Availability Heatmap".

'Availability Heatmap'

Returns:

Type Description
Figure

Matplotlib figure containing the annotated Seaborn heatmap with a RdYlGn color scale clamped between 0.9 and 1.0.

Source code in src/opensatcom/viz/statistical.py
def plot_availability_heatmap(
    data: np.ndarray,
    x_labels: list[str] | None = None,
    y_labels: list[str] | None = None,
    title: str = "Availability Heatmap",
) -> Any:
    """Seaborn heatmap of availability vs parameter pairs.

    Parameters
    ----------
    data : np.ndarray
        2-D array of availability values, typically in the range [0, 1].
    x_labels : list of str or None, optional
        Tick labels for the columns (x-axis). Default is ``None``
        (auto-generated by Seaborn).
    y_labels : list of str or None, optional
        Tick labels for the rows (y-axis). Default is ``None``
        (auto-generated by Seaborn).
    title : str, optional
        Plot title. Default is ``"Availability Heatmap"``.

    Returns
    -------
    matplotlib.figure.Figure
        Matplotlib figure containing the annotated Seaborn heatmap with a
        ``RdYlGn`` color scale clamped between 0.9 and 1.0.
    """
    import matplotlib

    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    import seaborn as sns

    fig, ax = plt.subplots(figsize=(8, 6))
    sns.heatmap(
        data,
        ax=ax,
        cmap="RdYlGn",
        vmin=0.9,
        vmax=1.0,
        annot=True,
        fmt=".3f",
        xticklabels=x_labels if x_labels else "auto",
        yticklabels=y_labels if y_labels else "auto",
    )
    ax.set_title(title)
    fig.tight_layout()
    return fig

Constellation Coverage

constellation

Constellation and coverage visualizations using Plotly.

plot_constellation_coverage

plot_constellation_coverage(sat_tracks, min_elevation_deg=10.0, title='Satellite Coverage (Az/El)')

Plotly polar plot of satellite tracks (az/el) with coverage cone.

Each satellite track is plotted in polar coordinates (azimuth as angle, 90 - elevation as radius so the zenith sits at the center). A dashed circle marks the minimum-elevation visibility boundary.

Parameters:

Name Type Description Default
sat_tracks dict of {str: tuple of (np.ndarray, np.ndarray)}

Mapping from satellite ID to a (azimuth_deg, elevation_deg) pair of 1-D arrays describing the track.

required
min_elevation_deg float

Minimum elevation angle in degrees for the visibility cone circle. Default is 10.0.

10.0
title str

Plot title. Default is "Satellite Coverage (Az/El)".

'Satellite Coverage (Az/El)'

Returns:

Type Description
Figure

Interactive Plotly polar figure with satellite tracks and minimum-elevation boundary.

Source code in src/opensatcom/viz/constellation.py
def plot_constellation_coverage(
    sat_tracks: dict[str, tuple[np.ndarray, np.ndarray]],
    min_elevation_deg: float = 10.0,
    title: str = "Satellite Coverage (Az/El)",
) -> Any:
    """Plotly polar plot of satellite tracks (az/el) with coverage cone.

    Each satellite track is plotted in polar coordinates (azimuth as angle,
    90 - elevation as radius so the zenith sits at the center). A dashed
    circle marks the minimum-elevation visibility boundary.

    Parameters
    ----------
    sat_tracks : dict of {str: tuple of (np.ndarray, np.ndarray)}
        Mapping from satellite ID to a ``(azimuth_deg, elevation_deg)``
        pair of 1-D arrays describing the track.
    min_elevation_deg : float, optional
        Minimum elevation angle in degrees for the visibility cone circle.
        Default is ``10.0``.
    title : str, optional
        Plot title. Default is ``"Satellite Coverage (Az/El)"``.

    Returns
    -------
    plotly.graph_objects.Figure
        Interactive Plotly polar figure with satellite tracks and
        minimum-elevation boundary.
    """
    import plotly.graph_objects as go

    fig = go.Figure()

    colors = ["#1565c0", "#c62828", "#2e7d32", "#e65100", "#6a1b9a",
              "#00838f", "#ef6c00", "#283593"]

    for i, (sat_id, (az, el)) in enumerate(sat_tracks.items()):
        color = colors[i % len(colors)]
        # Convert elevation to polar radius: 90 - elevation (zenith at center)
        r = 90.0 - el
        fig.add_trace(go.Scatterpolar(
            r=r,
            theta=az,
            mode="lines+markers",
            name=sat_id,
            line=dict(color=color, width=2),
            marker=dict(size=3),
        ))

    # Minimum elevation circle
    theta_circle = np.linspace(0, 360, 100)
    r_circle = np.full(100, 90.0 - min_elevation_deg)
    fig.add_trace(go.Scatterpolar(
        r=r_circle,
        theta=theta_circle,
        mode="lines",
        name=f"Min elev ({min_elevation_deg}°)",
        line=dict(color="red", width=1, dash="dash"),
        showlegend=True,
    ))

    fig.update_layout(
        title=title,
        polar=dict(
            radialaxis=dict(
                range=[0, 90],
                tickvals=[0, 15, 30, 45, 60, 75, 90],
                ticktext=["90°", "75°", "60°", "45°", "30°", "15°", "0°"],
            ),
            angularaxis=dict(direction="clockwise"),
        ),
    )
    return fig